Tambah Analisis Proses Asesmen, Kelompok Capaian, riwayat nilai terstruktur, dan perbaikan bug CPL

- Portofolio: section "Analisis Proses Asesmen" (dropdown tertutup default) —
  distribusi huruf mutu, kelompok capaian (mean ± SD), Sub-CPMK terlemah/terkuat,
  kesenjangan rencana vs realisasi, mahasiswa berisiko, tren lintas tahun ajaran
- Portofolio: Nomor Dokumen & Tanggal Terbit terisi otomatis; placeholder contoh
  di Realisasi Pembelajaran dan Kendala & Tindak Lanjut; tombol "Isi Otomatis"
  merangkai draf Simpulan dari analisis + realisasi + kendala
- Nilai: tabel Kelompok Capaian (Atas/Tengah/Bawah, shading warna) dipindah ke
  dropdown "Detail Tambahan" bersama Skala Poin/Rincian Bobot/Hasil Ketercapaian;
  tabel utama tak lagi ada scroll vertikal internal
- Nilai: riwayat pengisian dicatat terstruktur (waktu, dosen, aksi, komponen),
  tanpa batas 8 entri, dengan ringkasan per komponen + dropdown Log Lengkap —
  mendukung akuntabilitas team teaching
- Fix bug data: seed kurikulum menyimpan 'cpl' CPMK sebagai string tunggal,
  bukan list, sehingga pecah per huruf saat di-join() di tabel Hasil
  Ketercapaian/Rekap (mis. "CPL-03" -> "C, P, L, -, 0, 3"); dinormalisasi di
  db.py (runtime) dan tools/extract_seed.py (agar re-seed berikutnya benar)
- Hover tooltip deskripsi lengkap di kode Sub-CPMK/CPMK/CPL (halaman Nilai
  dan Rekap CPL/CPMK)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Power BI Dev
2026-08-08 00:09:57 +07:00
parent af369883f3
commit 5d7ab16733
8 changed files with 491 additions and 37 deletions
+128 -22
View File
@@ -872,19 +872,76 @@ def _tabel_poin(grid: list[dict], bobot_sub_rincian: dict[str, list[dict]]) -> l
return out
def _catat_riwayat(riwayat_lama, updated_by: str, aksi: str, komponen: str | None = None) -> list:
"""Tambah satu entri riwayat pengisian nilai (siapa, kapan, apa) — dipakai
di tiap jalur simpan nilai (grid, input cepat, isi per sesi). Disimpan
penuh (tak dipangkas) untuk akuntabilitas team teaching."""
riwayat = list(riwayat_lama or [])
riwayat.append({
"at": datetime.now(db.WIB).strftime("%Y-%m-%d %H:%M"),
"by": updated_by, "aksi": aksi, "komponen": komponen,
})
return riwayat
_POLA_RIWAYAT_LAMA = re.compile(r"^(.*)\s\(([^()]+)\)$")
def _riwayat_terstruktur(sumber) -> list[dict]:
"""Normalisasi 'sumber' tersimpan jadi list dict seragam, terbaru duluan
— entri lama (sebelum fitur log terstruktur ini) berupa string polos
'aksi (Nama Dosen)', nama tetap diambil lewat pola itu; tanpa jam/komponen
karena data lama tidak menyimpannya."""
if not sumber:
return []
if isinstance(sumber, str):
sumber = [sumber]
out = []
for r in sumber:
if isinstance(r, dict):
out.append(r)
continue
teks = str(r)
m = _POLA_RIWAYAT_LAMA.match(teks)
if m:
out.append({"at": None, "by": m.group(2), "aksi": m.group(1), "komponen": None})
else:
out.append({"at": None, "by": "", "aksi": teks, "komponen": None})
return list(reversed(out))
def _ringkas_riwayat(riwayat: list[dict]) -> list[dict]:
"""Rekap 'siapa mengisi apa berapa kali' per komponen, dari log lengkap —
ringkasan cepat sebelum buka Log Lengkap."""
per_komponen: dict[str, dict[str, int]] = {}
for r in riwayat:
k = r.get("komponen") or "Umum"
by = r.get("by") or "?"
per_komponen.setdefault(k, {})
per_komponen[k][by] = per_komponen[k].get(by, 0) + 1
return [
{"komponen": k, "per_dosen": sorted(
({"nama": n, "n": c} for n, c in v.items()), key=lambda x: -x["n"])}
for k, v in per_komponen.items()
]
def _konteks_nilai(request, kode, pesan="", peringatan=None):
peta = db.peta_mk(kode)
tersimpan = db.ambil_dok("nilai", kode)
pengaturan = db.ambil_pengaturan()
ambang = float(pengaturan["ambang"])
hasil, akhir = None, {}
hasil, akhir, kelompok = None, {}, None
if tersimpan:
hasil = hitung.rollup_mk(peta, tersimpan["data"], ambang)
if tersimpan["data"].get("mode") == "per_mahasiswa":
rps = db.ambil_rps(kode)
bobot_sub = hitung.bobot_sub_dari_rps(rps["data"] if rps else None)
akhir = {a["nim"]: a["akhir"]
for a in hitung.nilai_akhir_mahasiswa(tersimpan["data"], bobot_sub)}
akhir_list = hitung.nilai_akhir_mahasiswa(tersimpan["data"], bobot_sub)
akhir = {a["nim"]: a["akhir"] for a in akhir_list}
skala = hitung.parse_skala(pengaturan["skala_huruf"])
statistik = hitung.statistik_akhir(akhir_list, skala)
kelompok = hitung.kelompok_capaian(akhir_list, statistik)
kelas_dosen = _kelas_dosen(request.state.user, kode)
terdaftar = db.mahasiswa_mk(kode)
grid = _grid_mahasiswa(tersimpan, terdaftar, kelas_dosen)
@@ -918,6 +975,7 @@ def _konteks_nilai(request, kode, pesan="", peringatan=None):
v_tersimpan = tersimpan_skor.get(sub_kode)
if v_tersimpan is not None and abs(v_tersimpan - v_terhitung) > 0.05:
dikatrol.add((m["nim"], sub_kode))
riwayat = _riwayat_terstruktur(tersimpan["data"].get("sumber")) if tersimpan else []
return render(
request, "form_nilai.html",
{"peta": peta, "tersimpan": tersimpan, "hasil": hasil, "ambang": ambang,
@@ -928,6 +986,8 @@ def _konteks_nilai(request, kode, pesan="", peringatan=None):
"tabel_komponen": _tabel_komponen(grid, komponen_daftar, sesi_semua) if komponen_daftar else [],
"bobot_komponen": bobot_komponen, "bobot_sub_rincian": bobot_sub_rincian,
"tabel_poin": _tabel_poin(grid, bobot_sub_rincian) if bobot_sub_rincian else [],
"kelompok": kelompok,
"riwayat": riwayat, "riwayat_ringkas": _ringkas_riwayat(riwayat),
"peringatan": peringatan or []},
)
@@ -1078,12 +1138,10 @@ async def simpan_nilai_komponen(request: Request, kode: str, i: str):
per_nim[nim] = m
m["skor"].update(subskor)
riwayat = lama_data.get("sumber") or []
if isinstance(riwayat, str):
riwayat = [riwayat]
riwayat.append(f"komponen {komponen['nama']}{label} ({updated_by})")
riwayat = _catat_riwayat(lama_data.get("sumber"), updated_by,
f"Isi Sesi — {label}", komponen["nama"])
data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sesi": sesi_semua,
"sumber": riwayat[-8:]}
"sumber": riwayat}
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
pesan = f"Nilai+{label.replace(' ', '+')}+tersimpan+untuk+{len(nilai)}+mahasiswa."
return RedirectResponse(f"/nilai/{kode}?pesan={pesan}", status_code=303)
@@ -1165,12 +1223,9 @@ async def simpan_nilai(request: Request, kode: str):
per_nim[nim] = mm
mm["skor"].update(subskor)
riwayat = lama_data.get("sumber") or []
if isinstance(riwayat, str):
riwayat = [riwayat]
riwayat.append(f"input per komponen ({updated_by})")
riwayat = _catat_riwayat(lama_data.get("sumber"), updated_by, "Input Cepat per Komponen")
data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sesi": sesi_semua,
"sumber": riwayat[-8:]}
"sumber": riwayat}
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
return _konteks_nilai(
request, kode,
@@ -1206,14 +1261,11 @@ async def simpan_nilai(request: Request, kode: str):
peringatan=["Tidak ada nilai yang diisi."])
lama = db.ambil_dok("nilai", kode)
lama_data = lama["data"] if lama else {}
riwayat = lama_data.get("sumber") or []
if isinstance(riwayat, str):
riwayat = [riwayat]
riwayat.append(f"input langsung ({updated_by})")
riwayat = _catat_riwayat(lama_data.get("sumber"), updated_by, "Input Langsung (grid Sub-CPMK)")
if kelas_dosen is None:
# dosen tak dibatasi kelas: tabel yang tampil = seluruh MK,
# jadi menyimpan persis isi tabel memang dimaksudkan sebagai pengganti
data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa, "sumber": riwayat[-8:]}
data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa, "sumber": riwayat}
else:
# dosen dibatasi kelas: tabel yang tampil cuma kelasnya — gabungkan
# ke data lama, jangan sampai menghapus nilai kelas lain
@@ -1228,7 +1280,7 @@ async def simpan_nilai(request: Request, kode: str):
else:
ada["nama"] = m["nama"] or ada.get("nama", "")
ada["skor"] = m["skor"]
data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sumber": riwayat[-8:]}
data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sumber": riwayat}
if lama_data.get("sesi"):
# pertahankan log sesi per-komponen — grid ini hanya koreksi manual
# di atasnya, bukan pengganti sumber datanya
@@ -1252,6 +1304,23 @@ PORTO_KOSONG = {
"simpulan": "",
}
_BULAN_IND = ("", "Januari", "Februari", "Maret", "April", "Mei", "Juni",
"Juli", "Agustus", "September", "Oktober", "November", "Desember")
def _tanggal_indonesia(d: datetime) -> str:
return f"{d.day} {_BULAN_IND[d.month]} {d.year}"
def _nomor_porto_otomatis(kode: str) -> str:
"""PORTO/INF/<tahun>/<no urut MK di kurikulum> — bisa diedit dosen."""
try:
no_mk = int(db.kurikulum()["mk"].get(kode, {}).get("no", 0))
except (TypeError, ValueError):
no_mk = 0
tahun_awal = db.tahun_aktif().split("/")[0]
return f"PORTO/INF/{tahun_awal}/{no_mk:03d}"
@app.get("/portofolio/{kode}", response_class=HTMLResponse)
def form_porto(request: Request, kode: str, pesan: str = ""):
@@ -1262,13 +1331,46 @@ def form_porto(request: Request, kode: str, pesan: str = ""):
if tersimpan:
f = tersimpan["data"]
else:
f = {"dokumen": dict(PORTO_KOSONG["dokumen"]),
f = {"dokumen": {**PORTO_KOSONG["dokumen"],
"nomor": _nomor_porto_otomatis(kode),
"tgl_terbit": _tanggal_indonesia(datetime.now())},
"realisasi": "", "simpulan": "",
"kendala": [{"kendala": "", "tindak": ""} for _ in range(5)]}
while len(f.get("kendala", [])) < 5:
f.setdefault("kendala", []).append({"kendala": "", "tindak": ""})
rps = db.ambil_rps(kode)
nilai = db.ambil_dok("nilai", kode)
analisis = None
if nilai:
pengaturan = db.ambil_pengaturan()
ambang = float(pengaturan["ambang"])
skala = hitung.parse_skala(pengaturan["skala_huruf"])
rps_data = rps["data"] if rps else None
peta_analisis = _terapkan_override_sub(peta, rps_data)
nilai_data = nilai["data"]
bobot_sub = hitung.bobot_sub_dari_rps(rps_data)
rollup = hitung.rollup_mk(peta_analisis, nilai_data, ambang)
akhir = (hitung.nilai_akhir_mahasiswa(nilai_data, bobot_sub)
if nilai_data.get("mode") == "per_mahasiswa" else [])
statistik = hitung.statistik_akhir(akhir, skala) if akhir else None
tren = hitung.tren_mk(kode, db.daftar_tahun(), ambang)
sub_urut = hitung.sub_terurut(peta_analisis, rollup["sub"])
kesenjangan = hitung.kesenjangan_rencana_realisasi(bobot_sub, rollup["sub"])
berisiko = hitung.mahasiswa_berisiko(nilai_data, akhir, ambang)
tren = tren if len(tren) > 1 else []
analisis = {
"ambang": ambang,
"statistik": statistik,
"sub_urut": sub_urut,
"kesenjangan": kesenjangan,
"berisiko": berisiko,
"tren": tren,
"kelompok": hitung.kelompok_capaian(akhir, statistik),
"simpulan_saran": hitung.saran_simpulan(
akhir, statistik, sub_urut, kesenjangan, berisiko, tren, ambang),
}
return render(
request, "form_porto.html",
{"peta": peta, "f": f, "tahun": db.tahun_aktif(), "pesan": pesan,
@@ -1276,7 +1378,7 @@ def form_porto(request: Request, kode: str, pesan: str = ""):
"updated_at": tersimpan["updated_at"] if tersimpan else "",
"updated_by": tersimpan["updated_by"] if tersimpan else "",
"boleh_edit": boleh_mk(request.state.user, kode),
"ada_rps": bool(rps), "ada_nilai": bool(nilai)},
"ada_rps": bool(rps), "ada_nilai": bool(nilai), "analisis": analisis},
)
@@ -1410,10 +1512,14 @@ def rekap(request: Request):
cpl_urut = sorted(hasil["cpl"].values(), key=lambda c: c["kode"])
cpmk_urut = sorted(hasil["cpmk"].values(), key=lambda c: c["kode"])
nama_mk = {m["kode"]: m["nama"] for m in db.daftar_mk()}
k = db.kurikulum()
desk_cpmk = {info["kode"]: info["desk"] for info in k["cpmk"].values()}
desk_cpl = {info["kode"]: info["desk"] for info in k["cpl"].values()}
return render(
request, "rekap.html",
{"hasil": hasil, "cpl_urut": cpl_urut, "cpmk_urut": cpmk_urut,
"nama_mk": nama_mk, "tahun": db.tahun_aktif()},
"nama_mk": nama_mk, "tahun": db.tahun_aktif(),
"desk_cpmk": desk_cpmk, "desk_cpl": desk_cpl},
)