From 5d7ab16733bfa96084199c46786ccfcb66f43aad Mon Sep 17 00:00:00 2001 From: Power BI Dev Date: Sat, 8 Aug 2026 00:09:57 +0700 Subject: [PATCH] Tambah Analisis Proses Asesmen, Kelompok Capaian, riwayat nilai terstruktur, dan perbaikan bug CPL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/db.py | 6 ++ app/hitung.py | 140 +++++++++++++++++++++++++++++++ app/main.py | 150 +++++++++++++++++++++++++++++----- app/static/style.css | 6 ++ app/templates/form_nilai.html | 70 ++++++++++++++-- app/templates/form_porto.html | 143 +++++++++++++++++++++++++++++++- app/templates/rekap.html | 6 +- tools/extract_seed.py | 7 ++ 8 files changed, 491 insertions(+), 37 deletions(-) diff --git a/app/db.py b/app/db.py index 0d40528..9a7ff39 100644 --- a/app/db.py +++ b/app/db.py @@ -30,6 +30,12 @@ def kurikulum() -> dict: global _kur if _kur is None: _kur = json.loads(SEED.read_text(encoding="utf-8")) + # seed lama menyimpan 'cpl' CPMK sebagai string tunggal ("CPL-03") + # alih-alih list — semua pemakai (rollup, template |join) mengasumsikan + # list; tanpa ini string ikut ter-iterasi per huruf ("C, P, L, -, 0, 3"). + for v in _kur.get("cpmk", {}).values(): + if isinstance(v.get("cpl"), str): + v["cpl"] = [v["cpl"]] return _kur diff --git a/app/hitung.py b/app/hitung.py index 8fd239c..6025ee4 100644 --- a/app/hitung.py +++ b/app/hitung.py @@ -205,6 +205,146 @@ def rollup_mk(peta: dict, nilai_data: dict, ambang: float) -> dict: "mode": nilai_data.get("mode", "agregat")} +def sub_terurut(peta: dict, rollup_sub: dict) -> list[dict]: + """Sub-CPMK ternilai, terurut naik dari yang terlemah — bahan analisis + proses asesmen di Portofolio (mana yang paling perlu perhatian).""" + out = [] + for s in peta["subs"]: + info = rollup_sub.get(s["kode"]) + if info and info["rata"] is not None: + out.append({"kode": s["kode"], "uraian": s["uraian"], + "rata": info["rata"], "lulus_pct": info["lulus_pct"]}) + return sorted(out, key=lambda s: s["rata"]) + + +def kesenjangan_rencana_realisasi(bobot_sub: dict[str, float], + rollup_sub: dict) -> list[str]: + """Sub-CPMK yang diberi bobot penilaian di RPS tapi belum ada nilai + masuk sama sekali — kesenjangan antara rencana dan realisasi asesmen.""" + return sorted( + s for s in bobot_sub + if bobot_sub[s] and (s not in rollup_sub or rollup_sub[s]["rata"] is None) + ) + + +def mahasiswa_berisiko(nilai_data: dict, akhir: list[dict], ambang: float, + min_sub_dibawah: int = 2) -> list[dict]: + """Mahasiswa dengan >= min_sub_dibawah Sub-CPMK di bawah ambang, atau + nilai akhir tertimbangnya sendiri di bawah ambang (early-warning list).""" + if nilai_data.get("mode") != "per_mahasiswa": + return [] + akhir_by_nim = {a["nim"]: a["akhir"] for a in akhir} + out = [] + for m in nilai_data.get("mahasiswa", []): + skor = {s: v for s, v in (m.get("skor") or {}).items() if v is not None} + if not skor: + continue + n_dibawah = sum(1 for v in skor.values() if v < ambang) + nim = m.get("nim", "") + nilai_akhir = akhir_by_nim.get(nim) + if n_dibawah >= min_sub_dibawah or (nilai_akhir is not None and nilai_akhir < ambang): + out.append({"nim": nim, "nama": m.get("nama", ""), + "n_dibawah": n_dibawah, "total_sub": len(skor), + "akhir": nilai_akhir}) + return sorted(out, key=lambda m: (-m["n_dibawah"], m["akhir"] if m["akhir"] is not None else 999)) + + +def tren_mk(kode: str, daftar_tahun: list[str], ambang: float) -> list[dict]: + """Rata-rata nilai akhir & % kelulusan MK ini per tahun ajaran yang + berdata — untuk melihat apakah capaian membaik atau tidak antar tahun.""" + out = [] + for t in daftar_tahun: + nilai_t = db.ambil_dok("nilai", kode, t) + if not nilai_t: + continue + nilai_data_t = nilai_t["data"] + rps_t = db.ambil_rps(kode, t) + bobot_t = bobot_sub_dari_rps(rps_t["data"] if rps_t else None) + if nilai_data_t.get("mode") == "per_mahasiswa": + akhir_t = nilai_akhir_mahasiswa(nilai_data_t, bobot_t) + if akhir_t: + xs = [a["akhir"] for a in akhir_t] + out.append({"tahun": t, "mean": _rata(xs), + "lulus_pct": 100.0 * sum(1 for x in xs if x >= ambang) / len(xs), + "n": len(xs)}) + continue + sub_t = skor_sub(nilai_data_t, ambang) + nilai_ada = [v["rata"] for v in sub_t.values() if v["rata"] is not None] + if nilai_ada: + out.append({"tahun": t, "mean": _rata(nilai_ada), "lulus_pct": None, "n": None}) + return out + + +def kelompok_capaian(akhir: list[dict], statistik: dict | None) -> dict | None: + """Kelompokkan mahasiswa jadi Atas/Tengah/Bawah berdasarkan mean ± 1 SD + (acuan norma, lazim dipakai dalam evaluasi pendidikan) — pelengkap + 'mahasiswa berisiko' yang acuannya ambang kelulusan tetap, bukan sebaran + kelas ini.""" + if not statistik or not akhir: + return None + batas_bawah = statistik["mean"] - statistik["stdev"] + batas_atas = statistik["mean"] + statistik["stdev"] + atas, tengah, bawah = [], [], [] + for a in akhir: + if a["akhir"] > batas_atas: + atas.append(a) + elif a["akhir"] < batas_bawah: + bawah.append(a) + else: + tengah.append(a) + return { + "batas_bawah": batas_bawah, "batas_atas": batas_atas, + "atas": sorted(atas, key=lambda a: -a["akhir"]), + "tengah": sorted(tengah, key=lambda a: -a["akhir"]), + "bawah": sorted(bawah, key=lambda a: -a["akhir"]), + } + + +def saran_simpulan(akhir: list[dict], statistik: dict | None, sub_urut: list[dict], + kesenjangan: list[str], berisiko: list[dict], + tren: list[dict], ambang: float) -> str: + """Rangkai draf narasi Simpulan Hasil Evaluasi dari hasil Analisis Proses + Asesmen — dipakai sebagai placeholder awal, dosen tetap yang menulis versi + finalnya (termasuk simpulan ketercapaian CPL/CPMK dan tindak lanjut).""" + bagian = [] + if statistik: + lulus_pct = 100.0 * sum(1 for a in akhir if a["akhir"] >= ambang) / len(akhir) + bagian.append( + f"Rata-rata nilai akhir {statistik['mean']:.1f} (median {statistik['median']:.1f}) " + f"dari {statistik['n']} mahasiswa; {lulus_pct:.0f}% mencapai ambang {ambang:.0f}." + ) + if sub_urut: + terlemah, terkuat = sub_urut[0], sub_urut[-1] + if terlemah["kode"] != terkuat["kode"]: + bagian.append( + f"Capaian terendah pada {terlemah['kode']} (skor {terlemah['rata']:.1f}), " + f"tertinggi pada {terkuat['kode']} (skor {terkuat['rata']:.1f})." + ) + else: + bagian.append(f"Satu-satunya Sub-CPMK ternilai: {terlemah['kode']} (skor {terlemah['rata']:.1f}).") + if kesenjangan: + bagian.append( + f"{len(kesenjangan)} Sub-CPMK direncanakan di RPS namun belum ternilai: " + f"{', '.join(kesenjangan)}." + ) + if berisiko: + bagian.append(f"{len(berisiko)} mahasiswa teridentifikasi berisiko (nilai rendah di beberapa Sub-CPMK).") + if len(tren) > 1: + sekarang, sebelum = tren[-1], tren[-2] + if sekarang["mean"] is not None and sebelum["mean"] is not None: + selisih = sekarang["mean"] - sebelum["mean"] + arah = "naik" if selisih > 0 else ("turun" if selisih < 0 else "stabil") + bagian.append( + f"Dibanding {sebelum['tahun']} ({sebelum['mean']:.1f}), rata-rata {arah} " + f"{abs(selisih):.1f} poin di {sekarang['tahun']}." + ) + bagian.append( + "Lengkapi dengan simpulan ketercapaian CPL/CPMK terkait dan rencana tindak lanjut " + "untuk siklus berikutnya." + ) + return " ".join(bagian) + + def rollup_prodi(tahun: str | None = None) -> dict: """Ketercapaian tingkat prodi + kelengkapan bukti per CPL/CPMK.""" tahun = tahun or db.tahun_aktif() diff --git a/app/main.py b/app/main.py index 4644b8e..b1b7488 100644 --- a/app/main.py +++ b/app/main.py @@ -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// — 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}, ) diff --git a/app/static/style.css b/app/static/style.css index bb47685..fe192a2 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -113,9 +113,15 @@ table.grid td.tengah, table.grid th.tengah { text-align: center; } table.grid td.label { background: var(--blue-fill); font-weight: bold; width: 220px; } table.grid tr.uts td { background: var(--green-fill); } table.grid tr.uas td { background: var(--yellow-fill); } +table.grid tr.kelompok-atas td { background: var(--green-fill); } +table.grid tr.kelompok-tengah td { background: var(--yellow-fill); } +table.grid tr.kelompok-bawah td { background: #f6ddd8; } table.grid tr.total td { background: var(--blue-fill); font-weight: bold; } .gulir { overflow-x: auto; } +/* kode CPL/CPMK/Sub-CPMK dengan deskripsi lengkap di tooltip (title) */ +.kode-hover { cursor: help; border-bottom: 1px dotted var(--grey); } + /* header tabel yang bisa diurut (tabel.js) */ table.grid th.bisa-urut { cursor: pointer; user-select: none; } table.grid th.bisa-urut:hover { background: #2e75b6; } diff --git a/app/templates/form_nilai.html b/app/templates/form_nilai.html index 6cb057d..fc455cb 100644 --- a/app/templates/form_nilai.html +++ b/app/templates/form_nilai.html @@ -58,8 +58,8 @@ Data tersimpan: mode {% if tersimpan.data.mode == 'per_mahasiswa' %}PER MAHASISWA ({{ tersimpan.data.mahasiswa|length }} mahasiswa){% else %}BUKTI AGREGAT (rata-rata kelas){% endif %} — terakhir disimpan {{ tersimpan.updated_at }}{% if tersimpan.updated_by %} oleh {{ tersimpan.updated_by }}{% endif %}. - {% if tersimpan.data.sumber %}
Riwayat perubahan: - {{ tersimpan.data.sumber if tersimpan.data.sumber is string else tersimpan.data.sumber|join(' · ') }}.{% endif %} + {% if riwayat_ringkas %}
Ringkasan pengisian — team teaching: + {% for k in riwayat_ringkas %}{{ k.komponen }}: {% for d in k.per_dosen %}{{ d.n }}× {{ d.nama }}{{ ', ' if not loop.last }}{% endfor %}{{ ' · ' if not loop.last }}{% endfor %}.{% endif %}
+ merangkai draf dari Analisis Proses Asesmen, Realisasi Pembelajaran, dan Kendala & Tindak Lanjut di atas — cek & lengkapi sebelum menyimpan.{% endif %}

@@ -80,4 +174,45 @@ {% endif %}
+ + {% endblock %} diff --git a/app/templates/rekap.html b/app/templates/rekap.html index 4078b1e..2d88ced 100644 --- a/app/templates/rekap.html +++ b/app/templates/rekap.html @@ -30,7 +30,7 @@ {% for c in cpl_urut %} - {{ c.kode }} + {{ c.kode }} {{ c.desk[:130] }}{% if c.desk|length > 130 %}…{% endif %} {% if c.skor is not none %}{{ '%.2f'|format(c.skor) }}{% else %}—{% endif %} @@ -66,8 +66,8 @@ {% for c in cpmk_urut %} - {{ c.kode }} - {{ c.cpl|join(', ') }} + {{ c.kode }} + {% for cp in c.cpl %}{{ cp }}{{ ', ' if not loop.last }}{% endfor %} {% if c.skor is not none %}{{ '%.2f'|format(c.skor) }}{% else %}—{% endif %} {{ c.n_setor }}/{{ c.n_pemikul }} {% if c.mk_terlemah %}{{ nama_mk.get(c.mk_terlemah[0], c.mk_terlemah[0]) }} ({{ '%.1f'|format(c.mk_terlemah[1]) }}){% else %}—{% endif %} diff --git a/tools/extract_seed.py b/tools/extract_seed.py index 3cac9da..d734b00 100644 --- a/tools/extract_seed.py +++ b/tools/extract_seed.py @@ -23,6 +23,13 @@ def main() -> None: raise SystemExit(f"Blok JSON kurikulum tidak ditemukan di {src}") data, _ = json.JSONDecoder().raw_decode(html[i:]) + # sumber dashboard menyimpan 'cpl' CPMK sebagai string tunggal ("CPL-03") + # untuk CPMK yang cuma mendukung 1 CPL — normalisasi jadi list di sini + # supaya konsisten dengan pemakai lain (rollup, template |join). + for v in data.get("cpmk", {}).values(): + if isinstance(v.get("cpl"), str): + v["cpl"] = [v["cpl"]] + OUT.parent.mkdir(parents=True, exist_ok=True) OUT.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")