Tambah asesmen per komponen, batasi Dasbor/Rekap ke kaprodi, penugasan per kelas

- Halaman nilai: tombol isi-cepat per komponen penilaian (bagian G RPS) yang
  menyalin nilai ke seluruh Sub-CPMK terkait; komponen berulang (Kuis, Tugas,
  Presentasi, Tes Lisan) mendukung banyak sesi dengan rata-rata otomatis,
  komponen sekali-jalan (UTS/UAS) mengedit sesi yang sama. Sub-CPMK yang
  diukur >1 komponen digulung rata tertimbang bobot (hitung.skor_dari_sesi).
- Hapus jalur unggah Excel & mode "ketik rata-rata kelas" (agregat) dari
  input nilai — proses diseragamkan: hanya grid manual + tombol komponen,
  keduanya bersumber dari daftar mahasiswa impor kaprodi.
- Dasbor Monitoring, Rekap CPL/CPMK, dan Riwayat kini khusus kaprodi (dosen
  tidak lagi melihat rollup lintas-MK yang bukan miliknya).
- Penugasan dosen ke MK bisa dibatasi per kelas (kolom kelas baru di tabel
  penugasan, migrasi otomatis & aman untuk DB yang sudah berjalan) — dosen
  MK dengan banyak kelas paralel hanya melihat & menilai kelasnya sendiri;
  hapus nilai MK penuh dikunci untuk dosen yang dibatasi kelas.
This commit is contained in:
Power BI Dev
2026-07-21 12:44:57 +07:00
parent a45b08b00e
commit afb9e72ba2
8 changed files with 490 additions and 274 deletions
+250 -111
View File
@@ -3,6 +3,7 @@
import json
import pathlib
import re
from datetime import datetime
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
@@ -65,6 +66,21 @@ def boleh_mk(user: dict, kode: str) -> bool:
return user["role"] in ("dosen_mk", "dosen_rps_mk") and kode in db.mk_diampu(user["id"])
def _kelas_dosen(user: dict, kode: str) -> set[str] | None:
"""Kelas yang dibatasi untuk dosen ini pada MK ini. None = tak dibatasi
(kaprodi, atau penugasan tanpa kelas spesifik — seluruh kelas MK)."""
if user["role"] == "kaprodi":
return None
return db.kelas_diampu(user["id"], kode)
def _npm_diizinkan(kode: str, kelas_dosen: set[str] | None) -> set[str] | None:
"""NPM yang boleh ditulis dosen ini. None = tak dibatasi (semua NPM MK ini)."""
if kelas_dosen is None:
return None
return {m["npm"] for m in db.mahasiswa_mk(kode) if m.get("kelas") in kelas_dosen}
def _masuk(user_id: int) -> RedirectResponse:
token = auth.token_baru()
db.buat_sesi(user_id, token)
@@ -208,7 +224,8 @@ async def admin_penugasan(request: Request, uid: int):
return tolak
form = await request.form()
kode_valid = {m["kode"] for m in db.daftar_mk()}
pilihan = [k for k in form.getlist("mk") if k in kode_valid]
dipilih = [k for k in form.getlist("mk") if k in kode_valid]
pilihan = [(k, (form.get(f"kelas_{k}") or "").strip()) for k in dipilih]
db.set_penugasan(uid, pilihan)
p = db.pengguna_by_id(uid)
return RedirectResponse(
@@ -261,12 +278,12 @@ async def admin_impor_mahasiswa(request: Request):
# komponen penilaian tetap — teknik asesmen baku prodi; bobot boleh 0 (opsional,
# tak dipakai MK ybs) tapi total seluruh komponen tak boleh melebihi 100%.
KOMPONEN_DEFAULT = [
{"nama": "Partisipasi (Kuis)", "subs": [], "bobot": 10},
{"nama": "Observasi (Praktik/Tugas)", "subs": [], "bobot": 20},
{"nama": "Unjuk Kerja (Presentasi)", "subs": [], "bobot": 10},
{"nama": "Tes Tulis (UTS)", "subs": [], "bobot": 25},
{"nama": "Tes Tulis (UAS)", "subs": [], "bobot": 25},
{"nama": "Tes Lisan (Tugas Kelompok)", "subs": [], "bobot": 10},
{"nama": "Partisipasi (Kuis)", "subs": [], "bobot": 10, "ulang": True},
{"nama": "Observasi (Praktik/Tugas)", "subs": [], "bobot": 20, "ulang": True},
{"nama": "Unjuk Kerja (Presentasi)", "subs": [], "bobot": 10, "ulang": True},
{"nama": "Tes Tulis (UTS)", "subs": [], "bobot": 25, "ulang": False},
{"nama": "Tes Tulis (UAS)", "subs": [], "bobot": 25, "ulang": False},
{"nama": "Tes Lisan (Tugas Kelompok)", "subs": [], "bobot": 10, "ulang": True},
]
@@ -508,21 +525,13 @@ def unduh_rps(kode: str):
# ═════════════════════════ TAHAP 2: NILAI & PENGUKURAN ═════════════════════════
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, {}
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["npm"]: a["akhir"]
for a in hitung.nilai_akhir_mahasiswa(tersimpan["data"], bobot_sub)}
# grid input langsung: gabungan daftar impor kaprodi + nilai tersimpan
terdaftar = db.mahasiswa_mk(kode)
def _grid_mahasiswa(tersimpan: dict | None, terdaftar: list[dict],
kelas_dosen: set[str] | None = None) -> list[dict]:
"""Gabungan daftar impor kaprodi + nilai tersimpan (mahasiswa yang sudah
punya nilai tapi tak lagi terdaftar tetap ditampilkan, tak hilang).
kelas_dosen membatasi hasil ke kelas yang diampu dosen ini (None = semua
kelas); mahasiswa dari kelas lain, termasuk entri tak-terdaftar yang
kelasnya tak diketahui, disaring keluar demi privasi nilai kelas lain."""
skor_map, nama_map = {}, {}
if tersimpan and tersimpan["data"].get("mode") == "per_mahasiswa":
for m in tersimpan["data"]["mahasiswa"]:
@@ -537,39 +546,71 @@ def _konteks_nilai(request, kode, pesan="", peringatan=None):
if npm not in ada:
grid.append({"npm": npm, "nama": nama_map.get(npm, ""), "kelas": "",
"skor": sk})
if kelas_dosen is not None:
grid = [g for g in grid if g["kelas"] in kelas_dosen]
return grid
def _komponen_daftar(kode: str) -> list[dict]:
"""Komponen penilaian RPS yang sudah dikaitkan ke Sub-CPMK (bagian G) —
dipakai untuk tombol isi-cepat per komponen di halaman nilai. 'ulang'
menandai komponen yang boleh diisi berkali-kali sepanjang semester (Kuis,
Tugas, Presentasi, Tes Lisan) — rata-rata otomatis antar sesi — versus
komponen sekali-jalan (UTS/UAS)."""
rps = db.ambil_rps(kode)
if not rps:
return []
tersimpan = db.ambil_dok("nilai", kode)
sesi_semua = ((tersimpan["data"].get("sesi") or {}) if tersimpan else {})
out = []
for idx, k in enumerate(_normalisasi_komponen(rps["data"].get("komponen", []))):
if not k["subs"]:
continue
out.append({"i": idx, "nama": k["nama"], "bobot": k["bobot"],
"subs": k["subs"], "n_subs": len(k["subs"]),
"ulang": KOMPONEN_DEFAULT[idx]["ulang"],
"n_sesi": len(sesi_semua.get(str(idx), []))})
return out
def _ambil_komponen(kode: str, i: int) -> dict | None:
rps = db.ambil_rps(kode)
if not rps:
return None
komponen_list = _normalisasi_komponen(rps["data"].get("komponen", []))
if i < 0 or i >= len(komponen_list) or not komponen_list[i]["subs"]:
return None
k = dict(komponen_list[i])
k["ulang"] = KOMPONEN_DEFAULT[i]["ulang"]
return k
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, {}
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["npm"]: a["akhir"]
for a in hitung.nilai_akhir_mahasiswa(tersimpan["data"], bobot_sub)}
kelas_dosen = _kelas_dosen(request.state.user, kode)
terdaftar = db.mahasiswa_mk(kode)
grid = _grid_mahasiswa(tersimpan, terdaftar, kelas_dosen)
return render(
request, "form_nilai.html",
{"peta": peta, "tersimpan": tersimpan, "hasil": hasil, "ambang": ambang,
"akhir": akhir, "tahun": db.tahun_aktif(), "pesan": pesan,
"terdaftar": terdaftar, "grid": grid,
"grid": grid, "kelas_dosen": kelas_dosen,
"komponen_daftar": _komponen_daftar(kode),
"peringatan": peringatan or []},
)
def _gabung_nilai(lama: dict, baru: list[dict], sumber: str, oleh: str) -> dict:
"""Gabungkan unggahan ke data per-mahasiswa tersimpan (team teaching):
sel terisi memperbarui, sel kosong tidak menghapus nilai dosen lain,
mahasiswa yang tidak ada di file dibiarkan utuh."""
urut = [dict(m, skor=dict(m.get("skor") or {}))
for m in lama.get("mahasiswa", [])]
per_npm = {m["npm"]: m for m in urut if m.get("npm")}
for m in baru:
ada = per_npm.get(m.get("npm"))
if ada is None:
urut.append(m)
if m.get("npm"):
per_npm[m["npm"]] = m
else:
if m.get("nama"):
ada["nama"] = m["nama"]
ada["skor"].update(m.get("skor") or {})
riwayat = lama.get("sumber") or []
if isinstance(riwayat, str):
riwayat = [riwayat]
riwayat.append(f"{sumber} ({oleh})")
return {"mode": "per_mahasiswa", "mahasiswa": urut, "sumber": riwayat[-8:]}
@app.get("/nilai/{kode}", response_class=HTMLResponse)
def form_nilai(request: Request, kode: str, pesan: str = ""):
if not db.peta_mk(kode):
@@ -581,22 +622,131 @@ def form_nilai(request: Request, kode: str, pesan: str = ""):
return _konteks_nilai(request, kode, pesan)
@app.get("/nilai/{kode}/template.xlsx")
def template_nilai(request: Request, kode: str):
@app.get("/nilai/{kode}/komponen/{i}", response_class=HTMLResponse)
def form_nilai_komponen(request: Request, kode: str, i: int, sesi_id: int = -1, pesan: str = ""):
peta = db.peta_mk(kode)
if not peta:
return RedirectResponse("/", status_code=303)
if not boleh_mk(request.state.user, kode):
return RedirectResponse(f"/rps/{kode}", status_code=303)
isi = nilai_xlsx.buat_template(peta, db.tahun_aktif(), db.mahasiswa_mk(kode))
return Response(
content=isi,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition":
f'attachment; filename="Template_Nilai_{kode}.xlsx"'},
return RedirectResponse(
f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.",
status_code=303)
komponen = _ambil_komponen(kode, i)
if not komponen:
return RedirectResponse(
f"/nilai/{kode}?pesan=Komponen+belum+dikaitkan+ke+Sub-CPMK+di+RPS+bagian+G.",
status_code=303)
tersimpan = db.ambil_dok("nilai", kode)
daftar_sesi = ((tersimpan["data"].get("sesi") or {}) if tersimpan else {}).get(str(i), [])
edit = None
if not komponen["ulang"] and daftar_sesi:
# komponen sekali-jalan (UTS/UAS) sudah punya sesi — selalu edit sesi itu
edit, edit_id = daftar_sesi[0], 0
elif 0 <= sesi_id < len(daftar_sesi):
edit, edit_id = daftar_sesi[sesi_id], sesi_id
else:
edit_id = len(daftar_sesi) # sesi baru
nilai_awal = (edit or {}).get("nilai", {})
label_awal = (edit or {}).get("label") or (
komponen["nama"] if not komponen["ulang"]
else f"{komponen['nama']} {len(daftar_sesi) + 1}")
kelas_dosen = _kelas_dosen(request.state.user, kode)
grid = _grid_mahasiswa(tersimpan, db.mahasiswa_mk(kode), kelas_dosen)
for m in grid:
m["nilai_awal"] = nilai_awal.get(m["npm"])
return render(
request, "form_nilai_komponen.html",
{"peta": peta, "komponen": komponen, "i": i, "grid": grid,
"daftar_sesi": daftar_sesi, "edit_id": edit_id, "label_awal": label_awal,
"tahun": db.tahun_aktif(), "pesan": pesan},
)
@app.post("/nilai/{kode}/komponen/{i}")
async def simpan_nilai_komponen(request: Request, kode: str, i: int):
peta = db.peta_mk(kode)
if not peta:
return RedirectResponse("/", status_code=303)
if not boleh_mk(request.state.user, kode):
return RedirectResponse(
f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.",
status_code=303)
komponen = _ambil_komponen(kode, i)
if not komponen:
return RedirectResponse(f"/nilai/{kode}", status_code=303)
form = await request.form()
updated_by = request.state.user["nama"]
label = (form.get("label") or komponen["nama"]).strip()
try:
edit_id = int(form.get("edit_id") or -1)
except ValueError:
edit_id = -1
npm_diizinkan = _npm_diizinkan(kode, _kelas_dosen(request.state.user, kode))
nilai = {}
j = 0
while f"npm_{j}" in form:
npm = (form.get(f"npm_{j}") or "").strip()
v = (form.get(f"nilai_{j}") or "").strip()
if npm and v and (npm_diizinkan is None or npm in npm_diizinkan):
try:
x = float(v)
except ValueError:
x = None
if x is not None and 0 <= x <= 100:
nilai[npm] = x
j += 1
if not nilai:
return RedirectResponse(
f"/nilai/{kode}/komponen/{i}?pesan=Tidak+ada+nilai+yang+diisi.",
status_code=303)
lama = db.ambil_dok("nilai", kode)
lama_data = (dict(lama["data"]) if lama and lama["data"].get("mode") == "per_mahasiswa"
else {"mode": "per_mahasiswa", "mahasiswa": []})
sesi_semua = {k: list(v) for k, v in (lama_data.get("sesi") or {}).items()}
daftar_sesi = list(sesi_semua.get(str(i), []))
entri = {"label": label, "at": datetime.now(db.WIB).strftime("%Y-%m-%d %H:%M"),
"by": updated_by, "nilai": nilai}
if not komponen["ulang"] and daftar_sesi:
daftar_sesi[0] = entri # sekali-jalan: selalu menimpa sesi yang sama
elif 0 <= edit_id < len(daftar_sesi):
daftar_sesi[edit_id] = entri # mengedit sesi tertentu
else:
daftar_sesi.append(entri) # sesi baru
sesi_semua[str(i)] = daftar_sesi
komponen_daftar = _komponen_daftar(kode)
skor_terhitung = hitung.skor_dari_sesi(komponen_daftar, sesi_semua)
nama_map = {npm: m["nama"] for m in db.mahasiswa_mk(kode) for npm in [m["npm"]]}
urut = [dict(m, skor=dict(m.get("skor") or {})) for m in lama_data.get("mahasiswa", [])]
per_npm = {m["npm"]: m for m in urut if m.get("npm")}
for npm, subskor in skor_terhitung.items():
m = per_npm.get(npm)
if m is None:
m = {"npm": npm, "nama": nama_map.get(npm, ""), "skor": {}}
urut.append(m)
per_npm[npm] = 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})")
data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sesi": sesi_semua,
"sumber": riwayat[-8:]}
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)
@app.post("/nilai/{kode}")
async def simpan_nilai(request: Request, kode: str):
peta = db.peta_mk(kode)
@@ -609,21 +759,28 @@ async def simpan_nilai(request: Request, kode: str):
form = await request.form()
aksi = form.get("aksi")
updated_by = request.state.user["nama"]
kode_sub = {s["kode"] for s in peta["subs"]}
if aksi == "hapus":
if _kelas_dosen(request.state.user, kode) is not None:
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."])
db.hapus_dok("nilai", kode)
return _konteks_nilai(
request, kode,
pesan="Seluruh nilai tersimpan MK ini dihapus — silakan mulai dari awal.")
if aksi == "grid":
# input langsung: menyimpan persis isi tabel yang tampil
kelas_dosen = _kelas_dosen(request.state.user, kode)
npm_diizinkan = _npm_diizinkan(kode, kelas_dosen)
mahasiswa = []
i = 0
while f"npm_{i}" in form:
npm = (form.get(f"npm_{i}") or "").strip()
nama = (form.get(f"nama_{i}") or "").strip()
if npm_diizinkan is not None and npm not in npm_diizinkan:
i += 1
continue
skor = {}
for s in peta["subs"]:
v = (form.get(f"skor_{i}_{s['kode']}") or "").strip()
@@ -642,67 +799,40 @@ async def simpan_nilai(request: Request, kode: str):
return _konteks_nilai(request, kode,
peringatan=["Tidak ada nilai yang diisi."])
lama = db.ambil_dok("nilai", kode)
riwayat = (lama["data"].get("sumber") if lama else None) or []
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})")
data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa,
"sumber": riwayat[-8:]}
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:]}
else:
# dosen dibatasi kelas: tabel yang tampil cuma kelasnya — gabungkan
# ke data lama, jangan sampai menghapus nilai kelas lain
urut = [dict(m, skor=dict(m.get("skor") or {}))
for m in lama_data.get("mahasiswa", [])]
per_npm = {m["npm"]: m for m in urut if m.get("npm")}
for m in mahasiswa:
ada = per_npm.get(m["npm"])
if ada is None:
urut.append(m)
per_npm[m["npm"]] = m
else:
ada["nama"] = m["nama"] or ada.get("nama", "")
ada["skor"] = m["skor"]
data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sumber": riwayat[-8:]}
if lama_data.get("sesi"):
# pertahankan log sesi per-komponen — grid ini hanya koreksi manual
# di atasnya, bukan pengganti sumber datanya
data["sesi"] = lama_data["sesi"]
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
return _konteks_nilai(
request, kode,
pesan=f"Nilai {len(mahasiswa)} mahasiswa tersimpan (input langsung).")
if aksi == "unggah":
berkas = form.get("berkas")
if berkas is None or not getattr(berkas, "filename", ""):
return _konteks_nilai(request, kode,
peringatan=["Pilih file .xlsx terlebih dahulu."])
isi = await berkas.read()
try:
mahasiswa, peringatan = nilai_xlsx.parse_unggahan(isi, kode_sub)
except Exception:
return _konteks_nilai(request, kode,
peringatan=["File tidak terbaca sebagai .xlsx."])
if not mahasiswa:
return _konteks_nilai(request, kode, peringatan=peringatan)
lama = db.ambil_dok("nilai", kode)
if lama and lama["data"].get("mode") == "per_mahasiswa":
data = _gabung_nilai(lama["data"], mahasiswa, berkas.filename, updated_by)
pesan = (f"Unggahan {berkas.filename} digabung: {len(mahasiswa)} mahasiswa "
f"diperbarui, total {len(data['mahasiswa'])} mahasiswa tersimpan.")
else:
data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa,
"sumber": [f"{berkas.filename} ({updated_by})"]}
pesan = f"Nilai {len(mahasiswa)} mahasiswa tersimpan dari {berkas.filename}."
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
return _konteks_nilai(request, kode, pesan=pesan, peringatan=peringatan)
# aksi == 'agregat' — ketik rata-rata per Sub-CPMK
lama = db.ambil_dok("nilai", kode)
if lama and lama["data"].get("mode") == "per_mahasiswa":
return _konteks_nilai(request, kode, peringatan=[
"Data per-mahasiswa sudah tersimpan — menyimpan rata-rata akan membuang "
'rinciannya. Bila memang ingin beralih, hapus dulu lewat tombol "Mulai Ulang".'])
agregat = {}
for s in peta["subs"]:
v = (form.get(f"rata_{s['kode']}") or "").strip()
if v:
try:
x = float(v)
except ValueError:
continue
if 0 <= x <= 100:
agregat[s["kode"]] = x
if not agregat:
return _konteks_nilai(request, kode,
peringatan=["Tidak ada rata-rata yang diisi."])
data = {"mode": "agregat", "agregat": agregat}
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
return _konteks_nilai(
request, kode,
pesan=f"Rata-rata {len(agregat)} Sub-CPMK tersimpan (mode BUKTI AGREGAT).",
)
return _konteks_nilai(request, kode, peringatan=["Aksi tidak dikenali."])
# ═════════════════════════ TAHAP 2: PORTOFOLIO ═════════════════════════
@@ -866,6 +996,9 @@ def unduh_porto(request: Request, kode: str):
@app.get("/rekap", response_class=HTMLResponse)
def rekap(request: Request):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
hasil = hitung.rollup_prodi()
cpl_urut = sorted(hasil["cpl"].values(), key=lambda c: c["kode"])
cpmk_urut = sorted(hasil["cpmk"].values(), key=lambda c: c["kode"])
@@ -881,6 +1014,9 @@ def rekap(request: Request):
def pantau(request: Request):
"""Dasbor monitoring prodi: ketercapaian CPL/CPMK semester berjalan,
kelengkapan proses dokumen, dan daftar tindak lanjut untuk kaprodi."""
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
hasil = hitung.rollup_prodi()
daftar = db.daftar_mk()
nama_mk = {m["kode"]: m["nama"] for m in daftar}
@@ -962,6 +1098,9 @@ async def simpan_metodologi(request: Request):
@app.get("/rekap/riwayat", response_class=HTMLResponse)
def rekap_riwayat(request: Request):
"""Tren ketercapaian CPL lintas tahun ajaran."""
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
daftar_tahun = db.daftar_tahun()
per_tahun = {t: hitung.rollup_prodi(t) for t in daftar_tahun}
kode_cpl = sorted(db.kurikulum()["cpl"].keys())