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:
+128
-22
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user