Tambah peran Dosen MK/RPS+MK, komponen penilaian baku, dan bantu isi RPS

- Peran dosen dipecah jadi dosen_mk (nilai & portofolio saja, RPS baca-only)
  dan dosen_rps_mk (bisa edit RPS juga); migrasi otomatis dari skema lama.
- Rekap Bobot Penilaian RPS pakai 6 komponen teknik asesmen baku prodi;
  total wajib tepat 100%, komponen individual boleh dikosongkan.
- Dropdown & daftar Sub-CPMK tak lagi terpotong teksnya.
- Bagian F (Rencana Mingguan): tombol "Isi Otomatis" per baris (termasuk
  usulan Bobot) yang muncul setelah Sub-CPMK dipilih, plus panel contoh
  teks bantu per kolom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Power BI Dev
2026-07-21 08:45:56 +07:00
parent 9be6bd03e3
commit a45b08b00e
7 changed files with 175 additions and 45 deletions
+61 -27
View File
@@ -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)