Author SHA1 Message Date
Power BI DevandClaude Sonnet 5 374bdb5565 Perbaiki isi RPS unduhan yang nyasar ke baris salah (Peta CPL, Deskripsi, dst)
build_rps() mengisi blok naratif (Peta CPL-CPMK, Deskripsi Singkat MK,
Bahan Kajian, Pustaka, Media, Dosen Pengampu, MK Prasyarat) lewat
t.cell(18, 2) dst -- indeks baris tetap sesuai template asli (39 baris).
Tapi baris-baris itu dieksekusi SETELAH daftar CPL/CPMK/Sub-CPMK
digandakan & baris contohnya dibuang -- jumlah baris yang disisipkan
beda-beda tiap MK (tergantung berapa CPL/CPMK/Sub-CPMK-nya), jadi
t.cell(18, 2) di tabel yang sudah berubah itu tidak lagi menunjuk baris
"Peta CPL-CPMK" yang dimaksud. Teks jadi nyasar ke sel/baris lain,
termasuk kolom sempit -- terlihat sebagai puluhan halaman teks terpotong
huruf demi huruf.

Pola yang benar sudah dipakai untuk tabel mingguan (trs[31], trs[33],
dst -- referensi elemen XML baris asli yang dibekukan di awal, tak
ikut bergeser). Terapkan pola sama (helper cell()) untuk blok naratif
yang kelewat.

Diverifikasi: label & isi sekarang sejajar benar (baris "Peta CPL - CP
MK" berisi peta CPMK->CPL, bukan lagi nama dosen pengampu), dokumen
turun dari puluhan halaman rusak jadi 2 halaman rapi untuk bagian ini.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 21:42:01 +07:00
Power BI Dev de65ca99e6 Merge branch 'ubah/teks-peringatan-subcpmk-komponen' into main 2026-08-09 20:58:45 +07:00
Power BI DevandClaude Sonnet 5 adf2918e85 Ubah teks peringatan Sub-CPMK-komponen: fokus ke "belum ditautkan"
Framing lama ("RPS sudah punya bobot untuk X, tapi belum ada Sub-CPMK
dicentang") terkesan seperti pengisian sengaja yang cuma kurang
langkah terakhir. Padahal akar masalahnya sering kali bobot bawaan
aplikasi (KOMPONEN_DEFAULT) yang belum pernah disentuh dosen sama
sekali — reframing ke "Sub-CPMK belum ditautkan ke komponen penilaian"
lebih jujur ke kondisi sebenarnya.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:56:18 +07:00
Power BI Dev 3c9482ee23 Merge branch 'fitur/peringatan-komponen-tanpa-subcpmk' into main 2026-08-09 20:33:38 +07:00
Power BI DevandClaude Sonnet 5 e5d45cdcdd Peringatan kalau bobot komponen RPS terisi tapi Sub-CPMK-nya belum dicentang
Kasus nyata: RPS bagian G bobotnya sudah lengkap (total 100%), tapi kolom
"Sub-CPMK yang Dinilai" tak satu pun dicentang di tiap komponen. Komponen
begini lolos validasi total bobot, tapi _komponen_daftar() menyaringnya
habis (butuh subs DAN bobot) -- dosen jadi bingung kenapa halaman Nilai
sama sekali tak punya section "ISI PER KOMPONEN", padahal RPS-nya sudah
diisi.

- _komponen_tanpa_sub(): fungsi baru, deteksi komponen dengan bobot > 0
  tapi subs kosong
- Halaman RPS: peringatan di atas tabel bagian G, sebut nama komponen
  yang bermasalah
- Halaman Nilai: peringatan serupa + link "Buka RPS" -- muncul di titik
  gejalanya, bukan cuma di sumbernya

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:29:15 +07:00
Power BI Dev a3efa9fef3 Merge branch 'fitur/konfirmasi-hapus-nilai' into main 2026-08-09 19:49:12 +07:00
Power BI DevandClaude Sonnet 5 6375bc88f4 Tambah konfirmasi password & auto-arsip sebelum hapus nilai
Tombol "Mulai Ulang (hapus semua nilai)" di halaman Input Nilai
sebelumnya cuma dijaga confirm() browser biasa — gampang ke-klik tanpa
sadar, apalagi datanya sekarang ratusan mahasiswa per MK.

- Dialog konfirmasi kini menyebut jumlah mahasiswa yang akan hilang
- Password akun yang login harus dimasukkan ulang & diverifikasi
  server-side (auth.verifikasi_password) sebelum aksi hapus dieksekusi
- Isi nilai lama diarsipkan otomatis ke tabel nilai_arsip_hapus sebelum
  dihapus (mk_kode, tahun, data JSON, kapan, oleh siapa) — jaring
  pengaman, ikut ke-backup lewat /cadangan.db yang sudah ada; belum ada
  UI pemulihan (di luar cakupan saat ini, cukup query manual bila perlu)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 18:51:09 +07:00
Power BI Dev efc14dc7a7 Merge branch 'fitur/filter-kelas-input-nilai' into main 2026-08-09 18:36:25 +07:00
Power BI DevandClaude Sonnet 5 9c0fe8baa0 Tambah filter kelas di halaman Input Nilai
- /nilai/<kode>: dropdown "Tampilkan kelas" menyaring tabel Nilai per
  Komponen & Nilai per Mahasiswa sekaligus (tanpa reload); baris
  ringkasan (rata-rata/% lulus MK) ikut disembunyikan saat filter aktif
  karena angkanya dihitung dari seluruh MK, bukan per kelas
- /nilai/<kode>/komponen/<i>: filter serupa + kolom Kelas ditambahkan
  di tabel Nilai Mahasiswa (halaman paling sering dipakai dosen isi
  nilai per sesi/tugas)
- Muncul otomatis hanya bila MK punya >1 kelas

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 18:35:43 +07:00
Power BI Dev 38d309e7b1 Merge branch 'fix/rps-docx-corrupt-nested-table' into main 2026-08-09 17:37:22 +07:00
Power BI DevandClaude Sonnet 5 71858d974c Perbaiki dokumen RPS hasil unduh yang rusak/berantakan
Sel "Peta CPL-CPMK" pada template resmi UNTAN memuat tabel kecil bersarang
(contoh isian bawaan SN-Dikti). _set_text() cuma membuang paragraf tambahan
saat menimpa sel dengan teks generate, tak tahu ada tabel bersarang di
dalamnya -- hasilnya sel berakhir dengan w:tbl tanpa w:p setelahnya, yang
melanggar skema OOXML. Word membaca file itu sebagai "rusak" dan otomatis
memperbaikinya sendiri saat dibuka, dan perbaikan-otomatis itulah yang
mengacak tata letak dokumen.

- _set_text() sekarang membuang tabel bersarang tersisa sebelum menulis
  teks baru ke sel manapun (berlaku umum, bukan cuma sel yang saat ini
  kena masalah)
- Ganti templates/rps_untan.docx dengan versi yang sudah dirapikan
  formatnya (font/spacing) -- struktur baris & kolom identik (39x18),
  jadi tetap kompatibel dengan pemetaan baris di rps_untan.py
- Diverifikasi: tak ada lagi sel yang berakhir dengan tabel bersarang
  tanpa paragraf penutup di seluruh dokumen hasil generate

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 16:38:13 +07:00
Power BI Dev b9d5c22f33 Merge branch 'fitur/analisis-asesmen-riwayat-nilai' into main 2026-08-08 00:10:52 +07:00
Power BI DevandClaude Sonnet 5 5d7ab16733 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>
2026-08-08 00:09:57 +07:00
12 changed files with 696 additions and 59 deletions
+31 -2
View File
@@ -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
@@ -179,6 +185,16 @@ def init_db() -> None:
updated_by TEXT NOT NULL
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS nilai_arsip_hapus (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mk_kode TEXT NOT NULL,
tahun TEXT NOT NULL,
data TEXT NOT NULL,
dihapus_at TEXT NOT NULL,
dihapus_oleh TEXT NOT NULL
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS pengaturan (
kunci TEXT PRIMARY KEY,
@@ -286,6 +302,19 @@ def simpan_dok(tabel: str, mk_kode: str, data: dict, status: str, updated_by: st
)
def arsipkan_nilai_hapus(mk_kode: str, tahun: str, data: dict, dihapus_oleh: str) -> None:
"""Simpan salinan nilai sebelum dihapus (jaring pengaman) — tak ada UI
pemulihan otomatis, tapi baris ini tetap ikut ke-backup lewat /cadangan.db
dan bisa diambil manual bila suatu saat perlu dipulihkan."""
now = datetime.now(WIB).strftime("%Y-%m-%d %H:%M")
with get_conn() as conn:
conn.execute(
"""INSERT INTO nilai_arsip_hapus (mk_kode, tahun, data, dihapus_at, dihapus_oleh)
VALUES (?,?,?,?,?)""",
(mk_kode, tahun, json.dumps(data, ensure_ascii=False), now, dihapus_oleh),
)
def hapus_dok(tabel: str, mk_kode: str, tahun: str | None = None) -> None:
assert tabel in _TABEL_DOK
tahun = tahun or tahun_aktif()
@@ -356,11 +385,11 @@ def dump_semua() -> dict:
"""Seluruh isi database sebagai dict (untuk cadangan JSON)."""
out = {}
with get_conn() as conn:
for tabel in (*_TABEL_DOK, "rps_arsip", "pengaturan",
for tabel in (*_TABEL_DOK, "rps_arsip", "nilai_arsip_hapus", "pengaturan",
"pengguna", "penugasan", "mahasiswa"):
rows = conn.execute(f"SELECT * FROM {tabel}").fetchall()
out[tabel] = [dict(r) for r in rows]
for tabel in _TABEL_DOK + ("rps_arsip",):
for tabel in _TABEL_DOK + ("rps_arsip", "nilai_arsip_hapus"):
for r in out.get(tabel, []):
r["data"] = json.loads(r["data"])
return out
+26 -9
View File
@@ -39,7 +39,17 @@ PRODI = os.environ.get("PRODI", "PROGRAM STUDI INFORMATIKA")
def _set_text(cell, text, align=None, bold=None):
"""Ganti isi sel; gaya paragraf/tabel template dipertahankan."""
"""Ganti isi sel; gaya paragraf/tabel template dipertahankan.
Beberapa sel template resmi (mis. contoh isian "peta CPL-CPMK") memuat
tabel kecil bersarang di antara paragraf petunjuknya. w:tc yang berakhir
dengan w:tbl tanpa w:p setelahnya melanggar skema OOXML — Word
menganggap dokumennya rusak dan mem-"perbaiki" (merapikan ulang) sendiri
saat dibuka, yang justru merusak tata letak. Buang tabel bersarang itu
sekalian saat sel ditimpa teks baru.
"""
for tbl in cell._tc.findall(qn("w:tbl")):
tbl.getparent().remove(tbl)
for p in cell.paragraphs[1:]:
p._p.getparent().remove(p._p)
p = cell.paragraphs[0]
@@ -120,6 +130,13 @@ def build_rps(d: dict) -> bytes:
trs = [r._tr for r in t.rows] # indeks baris template asli
def cell(idx, col):
"""Ambil sel dari baris ASLI template lewat trs[idx] (referensi XML
beku), bukan t.cell(idx, col) yang ikut bergeser tiap kali baris
CPL/CPMK/Sub-CPMK digandakan/dibuang di atasnya — pakai t.cell()
langsung untuk baris di bawah blok itu akan salah sasaran."""
return _Row(trs[idx], t).cells[col]
def isi_daftar(proto_idx, hapus_idx, items, kolom_kode=2, kolom_isi=5):
"""Isi blok daftar (CPL/CPMK/Sub-CPMK): prototipe digandakan per item."""
proto = trs[proto_idx]
@@ -143,17 +160,17 @@ def build_rps(d: dict) -> bytes:
f"{c['kode']} → {', '.join(c.get('cpl', [])) or '-'}"
for c in d.get("cpmk", [])
)
_set_text(t.cell(18, 2), peta_txt)
_set_text(t.cell(19, 2), d.get("deskripsi", ""))
_set_text(t.cell(20, 2), d.get("bahan_kajian", ""))
_set_text(cell(18, 2), peta_txt)
_set_text(cell(19, 2), d.get("deskripsi", ""))
_set_text(cell(20, 2), d.get("bahan_kajian", ""))
utama = d.get("referensi", [])
pendukung = d.get("referensi_pendukung", [])
_set_text(t.cell(22, 2), "\n".join(f"{i}. {r}" for i, r in enumerate(utama, 1)))
_set_text(t.cell(24, 2), "\n".join(f"{i}. {r}" for i, r in enumerate(pendukung, 1)))
_set_text(t.cell(25, 2), d.get("media", ""))
_set_text(t.cell(26, 2), ident.get("pengampu", ""))
_set_text(t.cell(27, 2), ident.get("prasyarat") or "—")
_set_text(cell(22, 2), "\n".join(f"{i}. {r}" for i, r in enumerate(utama, 1)))
_set_text(cell(24, 2), "\n".join(f"{i}. {r}" for i, r in enumerate(pendukung, 1)))
_set_text(cell(25, 2), d.get("media", ""))
_set_text(cell(26, 2), ident.get("pengampu", ""))
_set_text(cell(27, 2), ident.get("prasyarat") or "—")
# ── tabel mingguan ──
proto_norm, proto_uts, proto_uas = trs[31], trs[33], trs[37]
Binary file not shown.
+140
View File
@@ -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()
+157 -23
View File
@@ -327,6 +327,19 @@ def _normalisasi_komponen(tersimpan: list[dict]) -> list[dict]:
return out
def _komponen_tanpa_sub(rps_data: dict) -> list[str]:
"""Nama komponen RPS bagian G yang bobotnya sudah diisi tapi belum ada
Sub-CPMK dicentang. Komponen begini masih ikut dihitung ke total 100% di
bagian G (jadi mudah lolos tanpa disadari), tapi disaring habis oleh
_komponen_daftar() — tidak akan pernah muncul di halaman Nilai."""
out = []
for k in _normalisasi_komponen(rps_data.get("komponen", [])):
bobot_terisi = str(k.get("bobot") or "").strip() not in ("", "0", "0.0")
if bobot_terisi and not k.get("subs"):
out.append(k["nama"])
return out
def minggu_default() -> list[dict]:
rows = []
for w in range(1, 17):
@@ -456,6 +469,7 @@ def form_rps(request: Request, kode: str, pesan: str = ""):
f.setdefault("referensi_teks", "\n".join(f.get("referensi", [])))
f["komponen"] = _normalisasi_komponen(f.get("komponen", []))
ctx_status = tersimpan
komponen_tanpa_sub = _komponen_tanpa_sub(f)
else:
f = form_kosong()
# prefill SKS & semester dari data kurikulum (sheet '14. Susunan MK')
@@ -465,6 +479,7 @@ def form_rps(request: Request, kode: str, pesan: str = ""):
if info.get("semester"):
f["identitas"]["semester"] = str(info["semester"])
ctx_status = {"status": "", "updated_at": "", "updated_by": ""}
komponen_tanpa_sub = [] # form belum pernah disimpan — bobot masih bawaan default
peta = _terapkan_override_sub(peta, f)
return render(
request, "form_rps.html",
@@ -473,6 +488,7 @@ def form_rps(request: Request, kode: str, pesan: str = ""):
"tahun": db.tahun_aktif(), "pesan": pesan,
"boleh_edit": boleh_rps(request.state.user, kode),
"tahun_lain": db.tahun_rps_lain(kode) if not tersimpan else [],
"komponen_tanpa_sub": komponen_tanpa_sub,
"sub_uraian": {s["kode"]: s["uraian"] for s in peta["subs"]}},
)
@@ -872,23 +888,82 @@ 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)
komponen_daftar = _komponen_daftar(kode)
rps_cek = db.ambil_rps(kode)
komponen_tanpa_sub = _komponen_tanpa_sub(rps_cek["data"]) if rps_cek else []
sesi_semua = (tersimpan["data"].get("sesi") or {}) if tersimpan else {}
bobot_komponen, bobot_sub_rincian = _rincian_bobot(kode)
# Nilai Akhir dihitung hanya dari Sub-CPMK yang sudah ternilai (lihat
@@ -918,6 +993,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,
@@ -925,9 +1001,12 @@ def _konteks_nilai(request, kode, pesan="", peringatan=None):
"grid": grid, "kelas_dosen": kelas_dosen, "tidak_lengkap": tidak_lengkap,
"dikatrol": dikatrol,
"komponen_daftar": komponen_daftar,
"komponen_tanpa_sub": komponen_tanpa_sub,
"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 +1157,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)
@@ -1107,10 +1184,19 @@ async def simpan_nilai(request: Request, kode: str):
return _konteks_nilai(request, kode, peringatan=[
"Anda hanya diampukan sebagian kelas MK ini — menghapus seluruh nilai "
"hanya bisa dilakukan kaprodi atau dosen yang mengampu seluruh kelas."])
akun = db.pengguna_by_id(request.state.user["id"])
password = (form.get("password") or "").strip()
if not akun or not auth.verifikasi_password(password, akun["pw_hash"]):
return _konteks_nilai(request, kode, peringatan=[
"Password salah — nilai TIDAK jadi dihapus."])
lama = db.ambil_dok("nilai", kode)
if lama:
db.arsipkan_nilai_hapus(kode, db.tahun_aktif(), lama["data"], updated_by)
db.hapus_dok("nilai", kode)
return _konteks_nilai(
request, kode,
pesan="Seluruh nilai tersimpan MK ini dihapus — silakan mulai dari awal.")
pesan="Seluruh nilai tersimpan MK ini dihapus (salinan sebelumnya "
"tersimpan otomatis sebagai arsip) — silakan mulai dari awal.")
if aksi == "komponen":
kelas_dosen = _kelas_dosen(request.state.user, kode)
@@ -1165,12 +1251,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 +1289,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 +1308,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 +1332,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 +1359,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 +1406,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 +1540,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},
)
+6
View File
@@ -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; }
+141 -17
View File
@@ -17,6 +17,13 @@
{% if pesan %}<div class="info">{{ pesan }}</div>{% endif %}
{% for w in peringatan %}<div class="peringatan">{{ w }}</div>{% endfor %}
{% if komponen_tanpa_sub %}
<div class="peringatan"><b>Sub-CPMK belum ditautkan ke komponen penilaian</b> untuk
{{ komponen_tanpa_sub|join(', ') }} — segera isi di RPS bagian G. Selama belum ditautkan,
komponen itu {% if not komponen_daftar %}tidak muncul sama sekali{% else %}belum ikut muncul{% endif %}
di "Isi per Komponen" di bawah. <a href="/rps/{{ peta.kode }}">Buka RPS &rarr;</a> lalu centang Sub-CPMK-nya.</div>
{% endif %}
{% if komponen_daftar %}
<h2 class="bagian">ISI PER KOMPONEN</h2>
<table class="grid" style="max-width:760px">
@@ -54,27 +61,69 @@
{% endif %}
{% if tersimpan %}
{% set n_mhs = tersimpan.data.mahasiswa|length if tersimpan.data.mode == 'per_mahasiswa' else none %}
<div class="info">
Data tersimpan: mode
<b>{% if tersimpan.data.mode == 'per_mahasiswa' %}PER MAHASISWA ({{ tersimpan.data.mahasiswa|length }} mahasiswa){% else %}BUKTI AGREGAT (rata-rata kelas){% endif %}</b>
<b>{% if tersimpan.data.mode == 'per_mahasiswa' %}PER MAHASISWA ({{ n_mhs }} mahasiswa){% else %}BUKTI AGREGAT (rata-rata kelas){% endif %}</b>
— terakhir disimpan {{ tersimpan.updated_at }}{% if tersimpan.updated_by %} oleh {{ tersimpan.updated_by }}{% endif %}.
{% if tersimpan.data.sumber %}<br>Riwayat perubahan:
{{ tersimpan.data.sumber if tersimpan.data.sumber is string else tersimpan.data.sumber|join(' · ') }}.{% endif %}
<form method="post" action="/nilai/{{ peta.kode }}" style="display:inline;margin-left:8px"
onsubmit="return confirm('Hapus SELURUH nilai tersimpan MK ini untuk {{ tahun }}? Tindakan ini tidak bisa dibatalkan.')">
<button class="tombol garis" type="submit" name="aksi" value="hapus"
{% if riwayat_ringkas %}<br>Ringkasan pengisian — team teaching:
{% for k in riwayat_ringkas %}{{ k.komponen }}: {% for d in k.per_dosen %}{{ d.n }}&times; {{ d.nama }}{{ ', ' if not loop.last }}{% endfor %}{{ ' · ' if not loop.last }}{% endfor %}.{% endif %}
<form method="post" action="/nilai/{{ peta.kode }}" style="display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap"
onsubmit="return confirm('Hapus SELURUH nilai{% if n_mhs %} {{ n_mhs }} mahasiswa{% endif %} tersimpan MK ini untuk {{ tahun }}?\n\nSalinan sebelumnya akan diarsipkan otomatis, tapi tidak ada tombol pulihkan di aplikasi — hubungi kaprodi/admin bila perlu dikembalikan.\n\nTindakan ini tidak bisa dibatalkan sendiri. Lanjutkan?')">
<input type="hidden" name="aksi" value="hapus">
<label style="font-size:13px;color:var(--grey)">Konfirmasi password Anda:
<input type="password" name="password" required autocomplete="current-password"
style="display:inline-block;width:auto;padding:3px 6px;font-size:13px;margin-left:4px">
</label>
<button class="tombol garis" type="submit"
style="color:var(--red);border-color:var(--red);padding:2px 10px;font-size:13px">Mulai Ulang (hapus semua nilai)</button>
</form>
</div>
{% endif %}
{% if riwayat %}
<details class="cara-alt" style="margin:16px 0">
<summary style="font-size:13px">Log Lengkap Pengisian Nilai ({{ riwayat|length }} entri)</summary>
<div class="gulir" style="margin-top:8px;max-height:320px;overflow-y:auto">
<table class="grid">
<thead><tr><th style="width:140px">Waktu</th><th>Dosen</th><th>Aksi</th><th style="width:140px">Komponen</th></tr></thead>
<tbody>
{% for r in riwayat %}
<tr>
<td>{{ r.at or '—' }}</td>
<td>{{ r.by or '—' }}</td>
<td>{{ r.aksi }}</td>
<td>{{ r.komponen or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endif %}
{% set ada_kelas = grid and grid|selectattr('kelas')|list|length > 0 %}
{% if grid and tabel_komponen and komponen_daftar %}
<h2 class="bagian">NILAI PER KOMPONEN <span style="font-weight:normal;font-style:italic;color:var(--grey)">— nilai mahasiswa berdasarkan komponen</span></h2>
<p class="keterangan">Angka = rata-rata skor mentah tiap sesi/tugas komponen itu yang diikuti mahasiswa (bukan lewat Sub-CPMK). Ketik untuk menimpa —
ditulis rata ke semua Sub-CPMK komponen itu; rincian per Sub-CPMK tetap presisi di grid bawah.</p>
{% if ada_kelas %}
{% set semua_kelas = grid|map(attribute='kelas')|select|unique|sort|list %}
<div class="filter-baris">
<label for="filter-kelas">Tampilkan kelas:</label>
<select id="filter-kelas" style="width:auto">
<option value="">Semua</option>
{% for k in semua_kelas %}
<option value="{{ k }}">Kelas {{ k }}</option>
{% endfor %}
</select>
<span id="filter-kelas-info" class="keterangan" style="margin:0"></span>
</div>
{% endif %}
<form method="post" action="/nilai/{{ peta.kode }}">
<input type="hidden" name="aksi" value="komponen">
<div class="gulir" style="max-height:480px;overflow-y:auto">
<div class="gulir">
<table class="grid">
<thead>
<tr>
@@ -88,7 +137,7 @@
<tbody>
{% for r in tabel_komponen %}
{% set ri = loop.index0 %}
<tr>
<tr data-kelas="{{ r.kelas or '' }}">
<td class="tengah">{{ loop.index }}
<input type="hidden" name="nim_{{ ri }}" value="{{ r.nim }}">
<input type="hidden" name="nama_{{ ri }}" value="{{ r.nama }}"></td>
@@ -119,9 +168,21 @@
{% if grid %}
{% set per_mhs = tersimpan and tersimpan.data.mode == 'per_mahasiswa' %}
<h2 class="bagian">NILAI PER MAHASISWA <span style="font-weight:normal;font-style:italic;color:var(--grey)">— nilai mahasiswa berdasarkan Sub-CPMK</span></h2>
{% if ada_kelas and not (tabel_komponen and komponen_daftar) %}
{% set semua_kelas = grid|map(attribute='kelas')|select|unique|sort|list %}
<div class="filter-baris">
<label for="filter-kelas">Tampilkan kelas:</label>
<select id="filter-kelas" style="width:auto">
<option value="">Semua</option>
{% for k in semua_kelas %}
<option value="{{ k }}">Kelas {{ k }}</option>
{% endfor %}
</select>
<span id="filter-kelas-info" class="keterangan" style="margin:0"></span>
</div>
{% endif %}
<form method="post" action="/nilai/{{ peta.kode }}">
<div class="gulir" style="max-height:480px;overflow-y:auto">
{% set ada_kelas = grid|selectattr('kelas')|list|length > 0 %}
<div class="gulir">
{% set n_kiri = 4 if ada_kelas else 3 %}
<table class="grid">
<thead>
@@ -137,7 +198,7 @@
<tbody>
{% for m in grid %}
{% set i = loop.index0 %}
<tr>
<tr data-kelas="{{ m.kelas or '' }}">
<td class="tengah">{{ loop.index }}
<input type="hidden" name="nim_{{ i }}" value="{{ m.nim }}">
<input type="hidden" name="nama_{{ i }}" value="{{ m.nama }}"></td>
@@ -153,7 +214,7 @@
</tr>
{% endfor %}
{% if per_mhs and hasil %}
<tr class="total">
<tr class="total" data-kelas="__total__" title="Dihitung dari seluruh mahasiswa MK ini, tidak per kelas">
<td colspan="{{ n_kiri }}" style="text-align:right">Rata-rata kelas (tersimpan)</td>
{% for s in peta.subs %}
{% set info = hasil.sub.get(s.kode) %}
@@ -161,7 +222,7 @@
{% endfor %}
<td class="tengah">{{ '%.1f'|format(akhir.values()|sum / akhir|length) if akhir else '—' }}</td>
</tr>
<tr class="total">
<tr class="total" data-kelas="__total__" title="Dihitung dari seluruh mahasiswa MK ini, tidak per kelas">
<td colspan="{{ n_kiri }}" style="text-align:right">% lulus (≥ {{ ambang|round(0)|int }})</td>
{% for s in peta.subs %}
{% set info = hasil.sub.get(s.kode) %}
@@ -187,11 +248,40 @@
</form>
{% endif %}
{% if kelompok or tabel_poin or bobot_komponen or hasil %}
<details class="cara-alt" style="margin-top:26px;border:1px solid #c9c9c9;padding:10px 14px">
<summary>DETAIL TAMBAHAN — Kelompok Capaian, Skala Poin, Rincian Bobot &amp; Hasil Ketercapaian</summary>
{% if kelompok %}
<h2 class="bagian">KELOMPOK CAPAIAN — ATAS / TENGAH / BAWAH <span style="font-weight:normal;font-style:italic;color:var(--grey)">(acuan norma: mean &plusmn; SD kelas ini, bukan ambang kelulusan)</span></h2>
<p class="keterangan">Batas: Atas &gt; {{ '%.1f'|format(kelompok.batas_atas) }} · Bawah &lt; {{ '%.1f'|format(kelompok.batas_bawah) }} — untuk melihat sebaran capaian relatif dalam kelas ini, pelengkap tabel Hasil Ketercapaian (acuan ambang tetap) di bawah.</p>
<div class="gulir">
<table class="grid" style="max-width:540px">
<thead><tr><th style="width:44px" class="tengah">No.</th><th style="width:112px">NIM</th><th>Nama</th><th class="tengah" style="width:90px">Nilai Akhir</th><th class="tengah" style="width:80px">Kelompok</th></tr></thead>
<tbody>
{% set ns = namespace(no=0) %}
{% for label, kelas, warna, daftar in [('Atas', 'kelompok-atas', '#205020', kelompok.atas), ('Tengah', 'kelompok-tengah', '#7a5c00', kelompok.tengah), ('Bawah', 'kelompok-bawah', 'var(--red)', kelompok.bawah)] %}
{% for m in daftar %}
{% set ns.no = ns.no + 1 %}
<tr class="{{ kelas }}">
<td class="tengah">{{ ns.no }}</td>
<td>{{ m.nim }}</td>
<td>{{ m.nama }}</td>
<td class="tengah">{{ '%.1f'|format(m.akhir) }}</td>
<td class="tengah" style="color:{{ warna }};font-weight:bold">{{ label }}</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if tabel_poin %}
{% set ada_kelas_p = tabel_poin|selectattr('kelas')|list|length > 0 %}
{% set n_kiri_p = 4 if ada_kelas_p else 3 %}
<h2 class="bagian">NILAI PER MAHASISWA — SKALA POIN <span style="font-weight:normal;font-style:italic;color:var(--grey)">— nilai yang sama, ditampilkan sebagai poin yang didapat dari total 100 (lihat rincian bobot di bawah); hanya tampilan, bukan isian</span></h2>
<div class="gulir" style="max-height:480px;overflow-y:auto">
<div class="gulir">
<table class="grid">
<thead>
<tr>
@@ -278,7 +368,7 @@
{% for s in peta.subs %}
{% set info = hasil.sub.get(s.kode) %}
<tr>
<td>{{ s.kode }}</td>
<td><span class="kode-hover" title="{{ s.uraian }}">{{ s.kode }}</span></td>
<td class="tengah">{% if info and info.rata is not none %}<b>{{ '%.1f'|format(info.rata) }}</b>{% else %}—{% endif %}</td>
<td class="tengah">100</td>
<td class="tengah">
@@ -298,8 +388,9 @@
<thead><tr><th>CPMK</th><th class="tengah">Skor</th><th class="tengah">Sub Ternilai</th><th class="tengah">% Ternilai</th><th>CPL Didukung</th></tr></thead>
<tbody>
{% for kode_c, v in hasil.cpmk.items() %}
{% set cm = peta.cpmks|selectattr('kode', 'equalto', kode_c)|first %}
<tr>
<td>{{ kode_c }}</td>
<td><span class="kode-hover" title="{{ cm.desk if cm else '' }}">{{ kode_c }}</span></td>
<td class="tengah">{% if v.skor is not none %}<b>{{ '%.2f'|format(v.skor) }}</b>{% else %}—{% endif %}</td>
<td class="tengah">{{ v.n_ternilai }}/{{ v.n_sub }}</td>
<td class="tengah">{{ '%.0f'|format(100 * v.n_ternilai / v.n_sub) ~ '%' if v.n_sub else '—' }}</td>
@@ -314,8 +405,9 @@
<thead><tr><th>CPL</th><th class="tengah">Skor</th><th class="tengah">% CPMK Ternilai</th><th class="tengah">Status</th></tr></thead>
<tbody>
{% for kode_c, v in hasil.cpl.items() %}
{% set cp = peta.cpls|selectattr('kode', 'equalto', kode_c)|first %}
<tr>
<td>{{ kode_c }}</td>
<td><span class="kode-hover" title="{{ cp.desk if cp else '' }}">{{ kode_c }}</span></td>
<td class="tengah">{% if v.skor is not none %}<b>{{ '%.2f'|format(v.skor) }}</b>{% else %}—{% endif %}</td>
<td class="tengah">{{ '%.0f'|format(100 * v.n_cpmk_ternilai / v.n_cpmk) ~ '%' if v.n_cpmk else '—' }}</td>
<td class="tengah">
@@ -329,4 +421,36 @@
</table>
</div>
{% endif %}
</details>
{% endif %}
{% if ada_kelas %}
<script>
(function () {
var pilih = document.getElementById('filter-kelas');
var info = document.getElementById('filter-kelas-info');
var baris = document.querySelectorAll('table.grid tbody tr[data-kelas]');
function perbarui() {
var v = pilih.value, n = 0, total = 0;
baris.forEach(function (tr) {
var kelas = tr.dataset.kelas;
if (kelas === '__total__') {
// baris rata-rata/% lulus: dihitung dari seluruh MK, cuma masuk akal saat "Semua"
tr.style.display = v === '' ? '' : 'none';
return;
}
total++;
var tampil = v === '' || kelas === v;
tr.style.display = tampil ? '' : 'none';
if (tampil) n++;
});
info.textContent = v === '' ? '' : n + ' dari ' + total + ' mahasiswa ditampilkan';
}
pilih.addEventListener('change', perbarui);
perbarui();
})();
</script>
{% endif %}
{% endblock %}
+40 -1
View File
@@ -96,6 +96,20 @@
</label>
{% endif %}
<h2 class="bagian">NILAI MAHASISWA</h2>
{% set ada_kelas = grid|selectattr('kelas')|list|length > 0 %}
{% if ada_kelas %}
{% set semua_kelas = grid|map(attribute='kelas')|select|unique|sort|list %}
<div class="filter-baris">
<label for="filter-kelas">Tampilkan kelas:</label>
<select id="filter-kelas" style="width:auto">
<option value="">Semua</option>
{% for k in semua_kelas %}
<option value="{{ k }}">Kelas {{ k }}</option>
{% endfor %}
</select>
<span id="filter-kelas-info" class="keterangan" style="margin:0"></span>
</div>
{% endif %}
<div class="gulir" style="max-height:480px;overflow-y:auto">
<table class="grid">
<thead>
@@ -103,17 +117,19 @@
<th style="width:44px" class="tengah">No.</th>
<th style="width:112px">NIM</th>
<th style="min-width:150px">Nama</th>
{% if ada_kelas %}<th style="width:60px" class="tengah">Kelas</th>{% endif %}
<th class="tengah" style="width:140px">Nilai (0–100)</th>
</tr>
</thead>
<tbody>
{% for m in grid %}
<tr>
<tr data-kelas="{{ m.kelas or '' }}">
<td class="tengah">{{ loop.index }}
<input type="hidden" name="nim_{{ loop.index0 }}" value="{{ m.nim }}">
<input type="hidden" name="nama_{{ loop.index0 }}" value="{{ m.nama }}"></td>
<td>{{ m.nim }}</td>
<td>{{ m.nama }}</td>
{% if ada_kelas %}<td class="tengah">{{ m.kelas or '—' }}</td>{% endif %}
<td class="isian"><input type="number" name="nilai_{{ loop.index0 }}"
min="0" max="100" step="any"
value="{{ '%g'|format(m.nilai_awal) if m.nilai_awal is not none else '' }}"></td>
@@ -129,6 +145,29 @@
</div>
</form>
{% if ada_kelas %}
<script>
(function () {
var pilih = document.getElementById('filter-kelas');
var info = document.getElementById('filter-kelas-info');
var baris = document.querySelectorAll('table.grid tbody tr[data-kelas]');
function perbarui() {
var v = pilih.value, n = 0;
baris.forEach(function (tr) {
var tampil = v === '' || tr.dataset.kelas === v;
tr.style.display = tampil ? '' : 'none';
if (tampil) n++;
});
info.textContent = v === '' ? '' : n + ' dari ' + baris.length + ' mahasiswa ditampilkan';
}
pilih.addEventListener('change', perbarui);
perbarui();
})();
</script>
{% endif %}
{% if komponen.tugas %}
<script>
const BENTUK_TEMPLATE = {
+139 -4
View File
@@ -30,6 +30,90 @@
<div class="info">RPS &#10003; dan Nilai &#10003; tersedia — tabel korelasi, penilaian, evaluasi ketercapaian, dan statistik akan diisi otomatis di dokumen. Anda hanya melengkapi bagian di bawah.</div>
{% endif %}
{% if analisis %}
<details class="cara-alt" style="margin-top:26px;border:1px solid #c9c9c9;padding:10px 14px">
<summary>ANALISIS PROSES ASESMEN {% if analisis.berisiko %}<span style="color:var(--red);font-weight:normal;font-size:13px">— {{ analisis.berisiko|length }} mahasiswa berisiko</span>{% endif %}</summary>
<p class="keterangan">Dihitung otomatis dari RPS + Nilai tersimpan — bahan diskusi sebelum menulis Realisasi &amp; Simpulan di bawah. Hanya tampil di layar, tidak ikut ke dokumen unduhan.</p>
<div class="perhatian-kisi">
<div>
<p class="keterangan" style="margin-top:0">Distribusi huruf mutu{% if analisis.statistik %} ({{ analisis.statistik.n }} mahasiswa){% endif %}</p>
{% if analisis.statistik %}
<table class="grid">
<thead><tr><th>Huruf</th><th class="tengah">Jumlah</th><th class="tengah">%</th></tr></thead>
<tbody>
{% for h, n in analisis.statistik.frekuensi.items() %}
<tr><td>{{ h }}</td><td class="tengah">{{ n }}</td>
<td class="tengah">{{ '%.0f'|format(100 * n / analisis.statistik.n) }}%</td></tr>
{% endfor %}
</tbody>
</table>
<p class="keterangan">Rata-rata {{ '%.1f'|format(analisis.statistik.mean) }} · median {{ '%.1f'|format(analisis.statistik.median) }} · rentang {{ '%.1f'|format(analisis.statistik.min) }}&ndash;{{ '%.1f'|format(analisis.statistik.max) }}</p>
{% if analisis.kelompok %}
<p class="keterangan">Kelompok capaian (mean &plusmn; SD, batas {{ '%.1f'|format(analisis.kelompok.batas_bawah) }}&ndash;{{ '%.1f'|format(analisis.kelompok.batas_atas) }}):
<b style="color:#205020">Atas {{ analisis.kelompok.atas|length }}</b> ·
Tengah {{ analisis.kelompok.tengah|length }} ·
<b style="color:var(--red)">Bawah {{ analisis.kelompok.bawah|length }}</b>{% if analisis.kelompok.bawah %} ({{ analisis.kelompok.bawah|map(attribute='nama')|join(', ') }}){% endif %}</p>
{% endif %}
{% else %}
<p class="keterangan">Nilai masih mode agregat (rata-rata kelas) — distribusi huruf per mahasiswa tidak bisa dihitung.</p>
{% endif %}
</div>
<div>
<p class="keterangan" style="margin-top:0">Sub-CPMK terlemah &amp; terkuat</p>
{% if analisis.sub_urut %}
<table class="grid">
<thead><tr><th>Sub-CPMK</th><th class="tengah">Skor</th><th class="tengah">Lulus %</th></tr></thead>
<tbody>
{% for s in analisis.sub_urut %}
<tr><td>{{ s.kode }} — {{ s.uraian }}</td>
<td class="tengah"{% if s.rata < analisis.ambang %} style="color:var(--red)"{% endif %}>{{ '%.1f'|format(s.rata) }}</td>
<td class="tengah">{{ '%.0f'|format(s.lulus_pct) if s.lulus_pct is not none else '—' }}{% if s.lulus_pct is not none %}%{% endif %}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="keterangan">Belum ada Sub-CPMK ternilai.</p>
{% endif %}
</div>
</div>
{% if analisis.kesenjangan %}
<p class="keterangan" style="margin-top:14px"><b>Kesenjangan rencana vs realisasi:</b> {{ analisis.kesenjangan|length }} Sub-CPMK diberi bobot di RPS tapi belum ada nilai masuk — {{ analisis.kesenjangan|join(', ') }}.</p>
{% endif %}
{% if analisis.berisiko %}
<p class="keterangan" style="margin-top:14px">Mahasiswa berisiko — &ge;2 Sub-CPMK di bawah ambang ({{ analisis.ambang|int }}), atau nilai akhir sendiri di bawahnya</p>
<table class="grid">
<thead><tr><th>NIM</th><th>Nama</th><th class="tengah">Sub-CPMK di bawah ambang</th><th class="tengah">Nilai Akhir</th></tr></thead>
<tbody>
{% for m in analisis.berisiko %}
<tr><td>{{ m.nim }}</td><td>{{ m.nama }}</td>
<td class="tengah">{{ m.n_dibawah }}/{{ m.total_sub }}</td>
<td class="tengah" style="color:var(--red)">{{ '%.1f'|format(m.akhir) if m.akhir is not none else '—' }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if analisis.tren %}
<p class="keterangan" style="margin-top:14px">Tren MK ini lintas tahun ajaran — apakah capaian membaik atau tidak</p>
<table class="grid">
<thead><tr><th>Tahun Ajaran</th><th class="tengah">Rata-rata</th><th class="tengah">% Kelulusan</th><th class="tengah">n Mahasiswa</th></tr></thead>
<tbody>
{% for t in analisis.tren %}
<tr{% if t.tahun == tahun %} class="total"{% endif %}>
<td>{{ t.tahun }}</td>
<td class="tengah">{{ '%.1f'|format(t.mean) if t.mean is not none else '—' }}</td>
<td class="tengah">{{ '%.0f'|format(t.lulus_pct) if t.lulus_pct is not none else '—' }}{% if t.lulus_pct is not none %}%{% endif %}</td>
<td class="tengah">{{ t.n if t.n is not none else '—' }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</details>
{% endif %}
<form method="post" action="/portofolio/{{ peta.kode }}">
<h2 class="bagian">IDENTITAS DOKUMEN (LEMBAR PENGESAHAN)</h2>
<table class="grid">
@@ -49,24 +133,34 @@
<h2 class="bagian">REALISASI PEMBELAJARAN</h2>
<p class="keterangan">Ceritakan pelaksanaan nyata semester ini: penyesuaian jadwal/materi/metode terhadap rencana di RPS, kehadiran, dan hal penting lain.</p>
<textarea name="realisasi" rows="6">{{ f.realisasi }}</textarea>
<textarea name="realisasi" rows="6" placeholder="Contoh:&#10;- Materi minggu 5–6 (rekursi) tertunda 1 minggu karena libur nasional, digabung jadi 1 pertemuan&#10;- Praktikum awalnya direncanakan luring, dialihkan ke daring minggu 3–4 karena lab dalam perbaikan&#10;- Rata-rata kehadiran mahasiswa, jumlah pertemuan efektif dari 16 minggu rencana&#10;- Metode yang berubah di lapangan (mis. tugas kelompok jadi individu karena jumlah mahasiswa sedikit)">{{ f.realisasi }}</textarea>
<h2 class="bagian">KENDALA &amp; TINDAK LANJUT</h2>
{% set contoh_kendala = [
"Banyak mahasiswa kesulitan di konsep rekursi (Sub-CPMK X)",
"Partisipasi diskusi daring rendah",
"Jadwal praktikum bentrok dengan MK lain"] %}
{% set contoh_tindak = [
"Ditambah sesi responsi/tutorial tambahan minggu 9",
"Kuis singkat wajib tiap sesi untuk dorong keterlibatan",
"Diusulkan penyesuaian jadwal ke prodi semester depan"] %}
<table class="grid">
<thead><tr><th style="width:50px" class="tengah">No.</th><th>Kendala</th><th>Tindak Lanjut</th></tr></thead>
<tbody>
{% for k in f.kendala[:5] %}
<tr>
<td class="tengah">{{ loop.index }}</td>
<td class="isian"><textarea name="kendala_{{ loop.index }}" rows="2">{{ k.kendala }}</textarea></td>
<td class="isian"><textarea name="tindak_{{ loop.index }}" rows="2">{{ k.tindak }}</textarea></td>
<td class="isian"><textarea name="kendala_{{ loop.index }}" rows="2" placeholder="{{ contoh_kendala[loop.index0] if loop.index0 < contoh_kendala|length else '' }}">{{ k.kendala }}</textarea></td>
<td class="isian"><textarea name="tindak_{{ loop.index }}" rows="2" placeholder="{{ contoh_tindak[loop.index0] if loop.index0 < contoh_tindak|length else '' }}">{{ k.tindak }}</textarea></td>
</tr>
{% endfor %}
</tbody>
</table>
<h2 class="bagian">SIMPULAN HASIL EVALUASI</h2>
<p class="keterangan">Statistik nilai, frekuensi huruf mutu, dan tabel ketercapaian CPL/CPMK dihitung dan dicetak otomatis; tuliskan simpulan naratifnya di sini.</p>
<p class="keterangan">Statistik nilai, frekuensi huruf mutu, dan tabel ketercapaian CPL/CPMK dihitung dan dicetak otomatis; tuliskan simpulan naratifnya di sini.
{% if boleh_edit %}<button type="button" class="tombol garis" style="padding:2px 10px;font-size:12px" onclick="isiSimpulanOtomatis()">Isi Otomatis</button>
merangkai draf dari Analisis Proses Asesmen, Realisasi Pembelajaran, dan Kendala &amp; Tindak Lanjut di atas — cek &amp; lengkapi sebelum menyimpan.{% endif %}</p>
<textarea name="simpulan" rows="4">{{ f.simpulan }}</textarea>
<div class="aksi-baris">
@@ -80,4 +174,45 @@
{% endif %}
</div>
</form>
<script>
const SIMPULAN_SARAN = {% if analisis %}{{ analisis.simpulan_saran | tojson | safe }}{% else %}null{% endif %};
// ambil kalimat pertama (maks `maks` karakter) dari teks bebas — dipakai
// untuk mengutip cuplikan Realisasi/Kendala ke dalam draf Simpulan
function ringkas(teks, maks) {
maks = maks || 160;
const kalimat = teks.match(/^.*?[.!?](?=\s|$)/);
let dasar = kalimat ? kalimat[0].trim() : teks.trim();
if (dasar.length > maks) dasar = teks.trim().slice(0, maks).trim() + '…';
return /[.!?…]$/.test(dasar) ? dasar : dasar + '.';
}
function isiSimpulanOtomatis() {
const bagian = [];
if (SIMPULAN_SARAN) bagian.push(SIMPULAN_SARAN);
const elRealisasi = document.querySelector('[name="realisasi"]');
const realisasi = elRealisasi ? elRealisasi.value.trim() : '';
if (realisasi) bagian.push('Dari sisi pelaksanaan pembelajaran: ' + ringkas(realisasi));
const kendalaTeks = [];
document.querySelectorAll('[name^="kendala_"]').forEach(el => {
const v = el.value.trim();
if (v) kendalaTeks.push(v);
});
if (kendalaTeks.length) {
const label = kendalaTeks.length > 1
? kendalaTeks.length + ' kendala tercatat, utamanya'
: 'Kendala utama yang tercatat';
bagian.push(label + ': ' + ringkas(kendalaTeks[0])
+ ' Tindak lanjutnya sudah direncanakan pada tabel Kendala & Tindak Lanjut di atas.');
}
if (!bagian.length) {
bagian.push('Belum ada data Analisis Proses Asesmen, Realisasi Pembelajaran, atau Kendala untuk dirangkai — isi bagian-bagian tersebut dulu, lalu klik Isi Otomatis lagi.');
}
document.querySelector('textarea[name="simpulan"]').value = bagian.join(' ');
}
</script>
{% endblock %}
+6
View File
@@ -178,6 +178,12 @@
komponen yang tidak dipakai MK ini (opsional), tapi total seluruh komponen yang diisi harus
tepat 100%. Centang Sub-CPMK yang dinilai tiap komponen; setiap Sub-CPMK harus tercakup minimal
satu komponen agar CPMK/CPL-nya terukur.</p>
{% if komponen_tanpa_sub %}
<div class="peringatan"><b>Sub-CPMK belum ditautkan ke komponen penilaian</b> — segera centang
minimal satu Sub-CPMK untuk: {{ komponen_tanpa_sub|join(', ') }}. Tanpa tautan ini, komponen
tersebut <b>tidak akan muncul</b> di halaman Input Nilai meski bobotnya sudah dihitung ke
total 100% di bawah.</div>
{% endif %}
<div class="gulir">
<table class="grid">
<thead>
+3 -3
View File
@@ -30,7 +30,7 @@
<tbody>
{% for c in cpl_urut %}
<tr>
<td class="tengah"><b>{{ c.kode }}</b></td>
<td class="tengah"><b class="kode-hover" title="{{ c.desk }}">{{ c.kode }}</b></td>
<td>{{ c.desk[:130] }}{% if c.desk|length > 130 %}…{% endif %}</td>
<td class="tengah">{% if c.skor is not none %}<b>{{ '%.2f'|format(c.skor) }}</b>{% else %}—{% endif %}</td>
<td class="tengah">
@@ -66,8 +66,8 @@
<tbody>
{% for c in cpmk_urut %}
<tr>
<td class="tengah">{{ c.kode }}</td>
<td class="tengah">{{ c.cpl|join(', ') }}</td>
<td class="tengah"><span class="kode-hover" title="{{ desk_cpmk.get(c.kode, '') }}">{{ c.kode }}</span></td>
<td class="tengah">{% for cp in c.cpl %}<span class="kode-hover" title="{{ desk_cpl.get(cp, '') }}">{{ cp }}</span>{{ ', ' if not loop.last }}{% endfor %}</td>
<td class="tengah">{% if c.skor is not none %}<b>{{ '%.2f'|format(c.skor) }}</b>{% else %}—{% endif %}</td>
<td class="tengah">{{ c.n_setor }}/{{ c.n_pemikul }}</td>
<td>{% if c.mk_terlemah %}{{ nama_mk.get(c.mk_terlemah[0], c.mk_terlemah[0]) }} ({{ '%.1f'|format(c.mk_terlemah[1]) }}){% else %}—{% endif %}</td>
+7
View File
@@ -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")