diff --git a/app/db.py b/app/db.py index 5b008e2..a2e0626 100644 --- a/app/db.py +++ b/app/db.py @@ -105,6 +105,39 @@ def get_conn() -> sqlite3.Connection: return conn +def _migrasi_role_pengguna(conn: sqlite3.Connection) -> None: + """Skema lama hanya kenal role 'dosen'; pecah jadi dosen_mk/dosen_rps_mk. + + Akun 'dosen' lama dipetakan ke 'dosen_rps_mk' agar hak akses (termasuk + edit RPS) yang sudah mereka miliki tidak berkurang oleh migrasi ini. + """ + row = conn.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='pengguna'" + ).fetchone() + if not row or "dosen_rps_mk" in row["sql"]: + return + conn.execute("ALTER TABLE pengguna RENAME TO pengguna_lama") + conn.execute( + """CREATE TABLE pengguna ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + nama TEXT NOT NULL, + role TEXT NOT NULL + CHECK (role IN ('kaprodi','dosen_mk','dosen_rps_mk')), + pw_hash TEXT NOT NULL, + dibuat TEXT NOT NULL + )""" + ) + conn.execute( + """INSERT INTO pengguna (id, username, nama, role, pw_hash, dibuat) + SELECT id, username, nama, + CASE WHEN role='dosen' THEN 'dosen_rps_mk' ELSE role END, + pw_hash, dibuat + FROM pengguna_lama""" + ) + conn.execute("DROP TABLE pengguna_lama") + + def init_db() -> None: with get_conn() as conn: for tabel in ("rps", "portofolio", "nilai"): @@ -141,11 +174,13 @@ def init_db() -> None: id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, nama TEXT NOT NULL, - role TEXT NOT NULL CHECK (role IN ('kaprodi','dosen')), + role TEXT NOT NULL + CHECK (role IN ('kaprodi','dosen_mk','dosen_rps_mk')), pw_hash TEXT NOT NULL, dibuat TEXT NOT NULL )""" ) + _migrasi_role_pengguna(conn) conn.execute( """CREATE TABLE IF NOT EXISTS sesi ( token TEXT PRIMARY KEY, diff --git a/app/generator/rps_untan.py b/app/generator/rps_untan.py index e25123f..a6729b4 100644 --- a/app/generator/rps_untan.py +++ b/app/generator/rps_untan.py @@ -218,7 +218,10 @@ def build_rps(d: dict) -> bytes: r.bold = True r.font.size = Pt(11) - komponen = d.get("komponen", []) + komponen = [ + k for k in d.get("komponen", []) + if k.get("subs") or str(k.get("bobot") or "").strip() not in ("", "0", "0.0") + ] tk = doc.add_table(rows=1 + max(len(komponen), 1), cols=4) _grid_borders(tk) for ci, judul in enumerate( diff --git a/app/main.py b/app/main.py index db811e8..75d6f4b 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """Aplikasi Penyusunan RPS & Portofolio — Prodi Informatika UNTAN.""" +import json import pathlib import re @@ -17,6 +18,9 @@ BASE = pathlib.Path(__file__).resolve().parent app = FastAPI(title="RPS & Portofolio — Informatika UNTAN") app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static") templates = Jinja2Templates(directory=str(BASE / "templates")) +templates.env.filters["tojson"] = ( + lambda v: json.dumps(v, ensure_ascii=False).replace("", "<\\/") +) db.init_db() db.kurikulum() @@ -46,9 +50,19 @@ def render(request: Request, nama: str, ctx: dict): return templates.TemplateResponse(request, nama, ctx) -def boleh_kelola(user: dict, kode: str) -> bool: - """Pengampu MK ini atau kaprodi — syarat edit & akses nilai.""" - return user["role"] == "kaprodi" or kode in db.mk_diampu(user["id"]) +def boleh_rps(user: dict, kode: str) -> bool: + """Kaprodi, atau dosen RPS+MK yang ditugaskan ke MK ini — syarat edit RPS.""" + if user["role"] == "kaprodi": + return True + return user["role"] == "dosen_rps_mk" and kode in db.mk_diampu(user["id"]) + + +def boleh_mk(user: dict, kode: str) -> bool: + """Kaprodi, atau dosen (MK / RPS+MK) yang ditugaskan ke MK ini — + syarat edit nilai & portofolio.""" + if user["role"] == "kaprodi": + return True + return user["role"] in ("dosen_mk", "dosen_rps_mk") and kode in db.mk_diampu(user["id"]) def _masuk(user_id: int) -> RedirectResponse: @@ -147,7 +161,8 @@ async def admin_buat_pengguna(request: Request): form = await request.form() nip = _rapikan_nip(form.get("nip") or "") nama = (form.get("nama") or "").strip() - role = "kaprodi" if form.get("role") == "kaprodi" else "dosen" + role_in = form.get("role") + role = role_in if role_in in ("kaprodi", "dosen_mk", "dosen_rps_mk") else "dosen_mk" if not nip or not nama: return RedirectResponse( "/admin?pesan=Nama+dan+NIP+wajib+diisi.", status_code=303) @@ -243,15 +258,33 @@ async def admin_impor_mahasiswa(request: Request): return RedirectResponse(f"/admin?pesan={pesan}", status_code=303) -# bobot default komponen — dari sheet `Asessement` draft kurikulum (Tabel 25) +# komponen penilaian tetap — teknik asesmen baku prodi; bobot boleh 0 (opsional, +# tak dipakai MK ybs) tapi total seluruh komponen tak boleh melebihi 100%. KOMPONEN_DEFAULT = [ - {"nama": "Kuis", "subs": [], "bobot": 15}, - {"nama": "Ujian Tengah Semester (UTS)", "subs": [], "bobot": 20}, - {"nama": "Ujian Akhir Semester (UAS)", "subs": [], "bobot": 30}, - {"nama": "Tugas / Praktikum / Proyek", "subs": [], "bobot": 35}, + {"nama": "Partisipasi (Kuis)", "subs": [], "bobot": 10}, + {"nama": "Observasi (Praktik/Tugas)", "subs": [], "bobot": 20}, + {"nama": "Unjuk Kerja (Presentasi)", "subs": [], "bobot": 10}, + {"nama": "Tes Tulis (UTS)", "subs": [], "bobot": 25}, + {"nama": "Tes Tulis (UAS)", "subs": [], "bobot": 25}, + {"nama": "Tes Lisan (Tugas Kelompok)", "subs": [], "bobot": 10}, ] +def _normalisasi_komponen(tersimpan: list[dict]) -> list[dict]: + """Selaraskan komponen tersimpan ke struktur baku KOMPONEN_DEFAULT (cocokkan + berdasar nama; RPS lama dengan nama komponen berbeda kehilangan isian itu).""" + by_nama = {k.get("nama"): k for k in tersimpan} + out = [] + for default in KOMPONEN_DEFAULT: + ada = by_nama.get(default["nama"]) + out.append({ + "nama": default["nama"], + "subs": ada.get("subs", []) if ada else [], + "bobot": ada.get("bobot", "") if ada else "", + }) + return out + + def minggu_default() -> list[dict]: rows = [] for w in range(1, 17): @@ -289,7 +322,7 @@ def beranda(request: Request, semua: int = 0): n_total = len(daftar) mk_saya = db.mk_diampu(request.state.user["id"]) # dosen: bawaan hanya MK yang diampu; ?semua=1 menampilkan seluruh MK - hanya_saya = (request.state.user["role"] == "dosen" + hanya_saya = (request.state.user["role"] != "kaprodi" and bool(mk_saya) and not semua) if hanya_saya: daftar = [m for m in daftar if m["kode"] in mk_saya] @@ -324,6 +357,7 @@ def form_rps(request: Request, kode: str, pesan: str = ""): if tersimpan: f = tersimpan["data"] f.setdefault("referensi_teks", "\n".join(f.get("referensi", []))) + f["komponen"] = _normalisasi_komponen(f.get("komponen", [])) ctx_status = tersimpan else: f = form_kosong() @@ -339,17 +373,19 @@ def form_rps(request: Request, kode: str, pesan: str = ""): {"peta": peta, "f": f, "status": ctx_status["status"], "updated_at": ctx_status["updated_at"], "updated_by": ctx_status["updated_by"], "tahun": db.tahun_aktif(), "pesan": pesan, - "boleh_edit": boleh_kelola(request.state.user, kode), - "tahun_lain": db.tahun_rps_lain(kode) if not tersimpan else []}, + "boleh_edit": boleh_rps(request.state.user, kode), + "tahun_lain": db.tahun_rps_lain(kode) if not tersimpan else [], + "sub_uraian": {s["kode"]: s["uraian"] for s in peta["subs"]}}, ) @app.post("/rps/{kode}/salin") async def salin_rps(request: Request, kode: str): """Duplikasi RPS dari tahun ajaran lain ke tahun aktif (status: draft).""" - if not boleh_kelola(request.state.user, kode): + if not boleh_rps(request.state.user, kode): return RedirectResponse( - f"/rps/{kode}?pesan=Anda+bukan+pengampu+MK+ini.", status_code=303) + f"/rps/{kode}?pesan=Anda+tidak+memiliki+hak+edit+RPS+untuk+MK+ini.", + status_code=303) form = await request.form() dari = form.get("dari") or "" sumber = db.ambil_rps(kode, tahun=dari) if dari else None @@ -371,9 +407,10 @@ async def simpan_rps(request: Request, kode: str): peta = db.peta_mk(kode) if not peta: return RedirectResponse("/", status_code=303) - if not boleh_kelola(request.state.user, kode): + if not boleh_rps(request.state.user, kode): return RedirectResponse( - f"/rps/{kode}?pesan=Anda+bukan+pengampu+MK+ini.", status_code=303) + f"/rps/{kode}?pesan=Anda+tidak+memiliki+hak+edit+RPS+untuk+MK+ini.", + status_code=303) form = await request.form() identitas = { @@ -396,12 +433,9 @@ async def simpan_rps(request: Request, kode: str): "bobot": (form.get(f"m{w}_bobot") or "").strip(), }) komponen = [] - for i in range(1, len(KOMPONEN_DEFAULT) + 1): - nama = (form.get(f"k{i}_nama") or "").strip() - if not nama: - continue + for i, default in enumerate(KOMPONEN_DEFAULT, 1): komponen.append({ - "nama": nama, + "nama": default["nama"], "subs": [s for s in form.getlist(f"k{i}_subs")], "bobot": (form.get(f"k{i}_bobot") or "").strip(), }) @@ -540,7 +574,7 @@ def _gabung_nilai(lama: dict, baru: list[dict], sumber: str, oleh: str) -> dict: def form_nilai(request: Request, kode: str, pesan: str = ""): if not db.peta_mk(kode): return RedirectResponse("/", status_code=303) - if not boleh_kelola(request.state.user, kode): + if not boleh_mk(request.state.user, kode): return RedirectResponse( f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.", status_code=303) @@ -552,7 +586,7 @@ def template_nilai(request: Request, kode: str): peta = db.peta_mk(kode) if not peta: return RedirectResponse("/", status_code=303) - if not boleh_kelola(request.state.user, kode): + if not boleh_mk(request.state.user, kode): return RedirectResponse(f"/rps/{kode}", status_code=303) isi = nilai_xlsx.buat_template(peta, db.tahun_aktif(), db.mahasiswa_mk(kode)) return Response( @@ -568,7 +602,7 @@ async def simpan_nilai(request: Request, kode: str): peta = db.peta_mk(kode) if not peta: return RedirectResponse("/", status_code=303) - if not boleh_kelola(request.state.user, kode): + if not boleh_mk(request.state.user, kode): return RedirectResponse( f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.", status_code=303) @@ -705,7 +739,7 @@ def form_porto(request: Request, kode: str, pesan: str = ""): "status": tersimpan["status"] if tersimpan else "", "updated_at": tersimpan["updated_at"] if tersimpan else "", "updated_by": tersimpan["updated_by"] if tersimpan else "", - "boleh_edit": boleh_kelola(request.state.user, kode), + "boleh_edit": boleh_mk(request.state.user, kode), "ada_rps": bool(rps), "ada_nilai": bool(nilai)}, ) @@ -714,7 +748,7 @@ def form_porto(request: Request, kode: str, pesan: str = ""): async def simpan_porto(request: Request, kode: str): if not db.peta_mk(kode): return RedirectResponse("/", status_code=303) - if not boleh_kelola(request.state.user, kode): + if not boleh_mk(request.state.user, kode): return RedirectResponse( f"/portofolio/{kode}?pesan=Anda+bukan+pengampu+MK+ini.", status_code=303) form = await request.form() @@ -810,7 +844,7 @@ def _bahan_porto(kode: str) -> dict | None: @app.get("/portofolio/{kode}/unduh") def unduh_porto(request: Request, kode: str): # dokumen memuat matriks nilai per mahasiswa — batasi pengampu + kaprodi - if not boleh_kelola(request.state.user, kode): + if not boleh_mk(request.state.user, kode): return RedirectResponse( f"/portofolio/{kode}?pesan=Unduhan+portofolio+memuat+nilai+mahasiswa+-+hanya+pengampu+dan+kaprodi.", status_code=303) diff --git a/app/templates/admin.html b/app/templates/admin.html index 10b123e..a819390 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -5,15 +5,17 @@ {% if pesan %}
Dosen hanya dapat mengedit RPS/portofolio dan melihat nilai - mahasiswa pada MK yang ditugaskan kepadanya. Kaprodi dapat mengakses semuanya.
+Dosen RPS + MK dapat mengedit RPS, nilai, dan portofolio + pada MK yang ditugaskan. Dosen MK hanya dapat mengedit nilai & portofolio + pada MK yang ditugaskan — halaman RPS terbuka hanya untuk dibaca. Kaprodi dapat + mengakses semuanya.
| Nama | NIP | -Peran | +Peran | MK yang Diampu | Tindakan | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ p.nama }} | {{ p.username }} | -{{ 'Kaprodi' if p.role == 'kaprodi' else 'Dosen' }} | +{{ {'kaprodi': 'Kaprodi', 'dosen_rps_mk': 'Dosen RPS + MK', 'dosen_mk': 'Dosen MK'}[p.role] }} |
{% if p.role == 'kaprodi' %}
semua MK
@@ -71,8 +73,9 @@
diff --git a/app/templates/base.html b/app/templates/base.html
index cee2a58..8d20fe2 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -36,7 +36,7 @@
{% if user.role == 'kaprodi' %}{{ tautan('/admin', 'Kelola Akun & Mahasiswa') }}{% endif %}
diff --git a/app/templates/beranda.html b/app/templates/beranda.html
index fae536d..9f071e0 100644
--- a/app/templates/beranda.html
+++ b/app/templates/beranda.html
@@ -8,7 +8,7 @@
{{ st_porto.values()|selectattr('status', 'equalto', 'selesai')|list|length }} portofolio selesai
- {% if user.role == 'dosen' %}
+ {% if user.role != 'kaprodi' %}
{% if hanya_saya %}
Menampilkan {{ daftar|length }} MK yang Anda ampu.
RPS mata kuliah lain tetap terbuka baca — tampilkan seluruh {{ n_total }} MK.
diff --git a/app/templates/form_rps.html b/app/templates/form_rps.html
index a3d36ec..c84a9d2 100644
--- a/app/templates/form_rps.html
+++ b/app/templates/form_rps.html
@@ -16,8 +16,10 @@
{% if pesan %}{{ pesan }} {% endif %}
{% if not boleh_edit %}
- Anda bukan pengampu MK ini — form terbuka hanya untuk dibaca.
- Hubungi kaprodi bila penugasan MK Anda perlu diubah.
+ Anda tidak memiliki hak edit RPS untuk MK ini — form terbuka
+ hanya untuk dibaca. Ini karena Anda bukan pengampu MK ini, atau peran Anda
+ Dosen MK (hanya mengelola nilai & portofolio). Hubungi kaprodi bila ini
+ perlu diubah.
{% endif %}
{% if tahun_lain and boleh_edit %}
@@ -97,7 +99,23 @@
F. RENCANA MINGGUAN — total bobot: 0%-Minggu 8 = UTS, minggu 16 = UAS. Total bobot seluruh minggu harus 100 %. +Minggu 8 = UTS, minggu 16 = UAS. Total bobot seluruh minggu harus 100 %. + {% if boleh_edit %}Pilih Sub-CPMK lalu klik Isi Otomatis untuk mengisi kolom kosong dengan teks + bantu (termasuk usulan Bobot — UTS 20%, UAS 30%, minggu biasa 3,5% — sesuaikan lagi agar totalnya + pas 100%).{% endif %} + {% if boleh_edit %} +
+
+ {% endif %}
Lihat contoh teks bantu per kolom+
+ Indikator — turunan Sub-CPMK, mis. "Mahasiswa mampu menjelaskan …"; "Ketepatan dan kelengkapan dalam …"
+ + Kriteria & Teknik — mis. "Rubrik holistik; tes tertulis" / "Ketepatan jawaban; unjuk kerja" / "Kelengkapan tugas; observasi" + Tatap Muka — mis. "Ceramah, diskusi, tanya jawab [TM: 2x50 menit]" / "Studi kasus & presentasi kelompok [TM: 2x50 menit]" + Daring — mis. "Membaca modul & kuis di LMS [BT+BM: 2x60 menit]" / "Diskusi forum LMS [BT+BM: 2x60 menit]" + Kegiatan Pembelajaran — mis. "Mahasiswa menyimak paparan dosen, berdiskusi, dan mengerjakan latihan terkait materi minggu ini." + Materi [Pustaka] — rujuk nomor pustaka di Bagian H, mis. "Bab 3 (Pustaka Utama #1)" +
|