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:
+250
-111
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user