Tambah ringkasan nilai per komponen, rincian tugas ala RTM, auto-save RPS
- Halaman Nilai: tabel "Nilai per Komponen" (rata-rata skor mentah tiap sesi, bisa diedit langsung) berdampingan dengan grid Sub-CPMK yang tetap presisi; kolom persentase ternilai di Hasil Ketercapaian. - Komponen Tugas: sesi bisa menyasar subset Sub-CPMK (RPS tetap batasannya), lengkap dengan field ala RTM (bentuk tugas, deskripsi, metode, luaran, indikator/bobot, jadwal) dan tombol isi-otomatis per bentuk tugas. - RPS: auto-save berkala sebagai draft, tombol ke atas, validasi total bobot komponen 100% saat ditandai selesai, komponen berbobot kosong disembunyikan dari halaman Nilai. - Admin: tampilkan kelas yang tersedia dari data mahasiswa saat mengatur penugasan dosen per kelas. - Perbaikan step="any" pada input nilai (mencegah validasi HTML5 menolak nilai desimal hasil roll-up). - Tambah METODOLOGI_ASESMEN_KOMPONEN.md: dokumentasi metodologi Sub-CPMK vs komponen, termasuk skenario tugas menyasar sebagian Sub-CPMK. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+171
-16
@@ -525,8 +525,29 @@ async def simpan_rps(request: Request, kode: str):
|
||||
}
|
||||
aksi = form.get("aksi") or "draft"
|
||||
status = "selesai" if aksi == "selesai" else "draft"
|
||||
|
||||
# Komponen boleh kosong (MK tak memakainya, mis. tak ada proyek/Tugas),
|
||||
# tapi total bobot komponen yang DIISI wajib tepat 100% sebelum RPS bisa
|
||||
# ditandai selesai — kalau belum, tetap disimpan (tak menghapus isian)
|
||||
# tapi statusnya dipaksa draft.
|
||||
total_bobot_g = 0.0
|
||||
for k in komponen:
|
||||
if k["bobot"]:
|
||||
try:
|
||||
total_bobot_g += float(k["bobot"])
|
||||
except ValueError:
|
||||
pass
|
||||
pesan_tambahan = ""
|
||||
if status == "selesai" and abs(total_bobot_g - 100) > 0.01:
|
||||
status = "draft"
|
||||
pesan_tambahan = (
|
||||
f"+—+disimpan+sebagai+draft:+total+bobot+komponen+G+harus+tepat+100%25+"
|
||||
f"(saat+ini+{total_bobot_g:g}%25)+sebelum+bisa+ditandai+selesai"
|
||||
)
|
||||
|
||||
db.simpan_rps(kode, data, status, request.state.user["nama"])
|
||||
return RedirectResponse(f"/rps/{kode}?pesan=Tersimpan+sebagai+{status}.", status_code=303)
|
||||
return RedirectResponse(
|
||||
f"/rps/{kode}?pesan=Tersimpan+sebagai+{status}{pesan_tambahan}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/rps/{kode}/unduh")
|
||||
@@ -602,12 +623,23 @@ def _grid_mahasiswa(tersimpan: dict | None, terdaftar: list[dict],
|
||||
return grid
|
||||
|
||||
|
||||
IDX_TUGAS = next(i for i, k in enumerate(KOMPONEN_DEFAULT)
|
||||
if k["nama"] == "Observasi (Praktik/Tugas)")
|
||||
|
||||
|
||||
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)."""
|
||||
komponen sekali-jalan (UTS/UAS). 'tugas' menandai komponen Observasi
|
||||
(Praktik/Tugas) — satu-satunya yang menawarkan rincian ala RTM (deskripsi
|
||||
+ checklist Sub-CPMK per sesi) di form isi nilainya.
|
||||
|
||||
Komponen dengan bobot kosong dianggap TAK DIPAKAI MK ini (mis. tak ada
|
||||
proyek/Tugas) dan disembunyikan sepenuhnya dari halaman nilai — bukan
|
||||
error, itu memang boleh terjadi (lihat validasi 100% saat RPS ditandai
|
||||
selesai di simpan_rps)."""
|
||||
rps = db.ambil_rps(kode)
|
||||
if not rps:
|
||||
return []
|
||||
@@ -615,27 +647,62 @@ def _komponen_daftar(kode: str) -> list[dict]:
|
||||
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"]:
|
||||
if not k["subs"] or not k["bobot"]:
|
||||
continue
|
||||
out.append({"i": idx, "nama": k["nama"], "bobot": k["bobot"],
|
||||
key = str(idx)
|
||||
out.append({"i": key, "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), []))})
|
||||
"tugas": idx == IDX_TUGAS,
|
||||
"n_sesi": len(sesi_semua.get(key, []))})
|
||||
return out
|
||||
|
||||
|
||||
def _ambil_komponen(kode: str, i: int) -> dict | None:
|
||||
def _ambil_komponen(kode: str, i: str) -> 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"]:
|
||||
try:
|
||||
idx = int(i)
|
||||
except ValueError:
|
||||
return None
|
||||
k = dict(komponen_list[i])
|
||||
k["ulang"] = KOMPONEN_DEFAULT[i]["ulang"]
|
||||
if (idx < 0 or idx >= len(komponen_list)
|
||||
or not komponen_list[idx]["subs"] or not komponen_list[idx]["bobot"]):
|
||||
return None
|
||||
k = dict(komponen_list[idx])
|
||||
k["ulang"] = KOMPONEN_DEFAULT[idx]["ulang"]
|
||||
k["tugas"] = idx == IDX_TUGAS
|
||||
return k
|
||||
|
||||
|
||||
def _tabel_komponen(grid: list[dict], komponen_daftar: list[dict],
|
||||
sesi_semua: dict) -> list[dict]:
|
||||
"""Ringkasan nilai per KOMPONEN (mis. Tugas, UTS, UAS) per mahasiswa —
|
||||
rata-rata skor MENTAH tiap sesi/tugas komponen itu yang mahasiswa ybs
|
||||
ikuti (bukan lewat Sub-CPMK), sehingga mis. "Tugas" tetap tampil sebagai
|
||||
satu angka gabungan meski tiap tugas menyasar Sub-CPMK berbeda-beda —
|
||||
berbeda dari grid Sub-CPMK di bawah yang presisi per Sub-CPMK. Bisa
|
||||
diedit langsung dari sini (lihat aksi='komponen' di simpan_nilai); angka
|
||||
yang diketik menimpa SEMUA Sub-CPMK komponen itu untuk mahasiswa tsb
|
||||
(override manual, terpisah dari presisi per-tugas per Sub-CPMK)."""
|
||||
out = []
|
||||
for m in grid:
|
||||
nilai = {}
|
||||
for k in komponen_daftar:
|
||||
daftar_sesi = sesi_semua.get(k["i"]) or []
|
||||
xs = [s["nilai"][m["nim"]] for s in daftar_sesi
|
||||
if m["nim"] in (s.get("nilai") or {})]
|
||||
# tak ada sesi tercatat untuk komponen ini (mis. Sub-CPMK-nya
|
||||
# diisi langsung lewat grid Sub-CPMK, bukan per komponen) —
|
||||
# dikosongkan, bukan ditebak dari skor Sub-CPMK (subnya bisa
|
||||
# dipakai bersama komponen lain, jadi tak bisa dipastikan sumbernya)
|
||||
nilai[k["i"]] = (sum(xs) / len(xs)) if xs else None
|
||||
out.append({"nim": m["nim"], "nama": m["nama"], "kelas": m.get("kelas"),
|
||||
"nilai": nilai})
|
||||
return out
|
||||
|
||||
|
||||
def _konteks_nilai(request, kode, pesan="", peringatan=None):
|
||||
peta = db.peta_mk(kode)
|
||||
tersimpan = db.ambil_dok("nilai", kode)
|
||||
@@ -652,12 +719,15 @@ def _konteks_nilai(request, kode, pesan="", peringatan=None):
|
||||
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)
|
||||
sesi_semua = (tersimpan["data"].get("sesi") or {}) if tersimpan else {}
|
||||
return render(
|
||||
request, "form_nilai.html",
|
||||
{"peta": peta, "tersimpan": tersimpan, "hasil": hasil, "ambang": ambang,
|
||||
"akhir": akhir, "tahun": db.tahun_aktif(), "pesan": pesan,
|
||||
"grid": grid, "kelas_dosen": kelas_dosen,
|
||||
"komponen_daftar": _komponen_daftar(kode),
|
||||
"komponen_daftar": komponen_daftar,
|
||||
"tabel_komponen": _tabel_komponen(grid, komponen_daftar, sesi_semua) if komponen_daftar else [],
|
||||
"peringatan": peringatan or []},
|
||||
)
|
||||
|
||||
@@ -674,7 +744,7 @@ def form_nilai(request: Request, kode: str, pesan: 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 = ""):
|
||||
def form_nilai_komponen(request: Request, kode: str, i: str, sesi_id: int = -1, pesan: str = ""):
|
||||
peta = db.peta_mk(kode)
|
||||
if not peta:
|
||||
return RedirectResponse("/", status_code=303)
|
||||
@@ -685,10 +755,11 @@ def form_nilai_komponen(request: Request, kode: str, i: int, sesi_id: int = -1,
|
||||
komponen = _ambil_komponen(kode, i)
|
||||
if not komponen:
|
||||
return RedirectResponse(
|
||||
f"/nilai/{kode}?pesan=Komponen+belum+dikaitkan+ke+Sub-CPMK+di+RPS+bagian+G.",
|
||||
f"/nilai/{kode}?pesan=Komponen+ini+belum+dipakai+MK+ini+"
|
||||
"(Sub-CPMK+dan/atau+bobotnya+belum+diisi+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), [])
|
||||
daftar_sesi = ((tersimpan["data"].get("sesi") or {}) if tersimpan else {}).get(i, [])
|
||||
|
||||
edit = None
|
||||
if not komponen["ulang"] and daftar_sesi:
|
||||
@@ -703,6 +774,13 @@ def form_nilai_komponen(request: Request, kode: str, i: int, sesi_id: int = -1,
|
||||
label_awal = (edit or {}).get("label") or (
|
||||
komponen["nama"] if not komponen["ulang"]
|
||||
else f"{komponen['nama']} {len(daftar_sesi) + 1}")
|
||||
subs_terpilih = set((edit or {}).get("subs") or komponen["subs"])
|
||||
uraian = {s["kode"]: s["uraian"] for s in peta["subs"]}
|
||||
subs_pilihan = [{"kode": s, "uraian": uraian.get(s, s)} for s in komponen["subs"]]
|
||||
# Rincian ala RTM (khusus komponen Tugas) — semua opsional, per sesi/tugas
|
||||
rtm = {kunci: (edit or {}).get(kunci, "") for kunci in
|
||||
("bentuk_tugas", "deskripsi", "metode_pengerjaan", "luaran",
|
||||
"indikator_bobot", "jadwal", "lain_lain", "rujukan")}
|
||||
|
||||
kelas_dosen = _kelas_dosen(request.state.user, kode)
|
||||
grid = _grid_mahasiswa(tersimpan, db.mahasiswa_mk(kode), kelas_dosen)
|
||||
@@ -713,12 +791,13 @@ def form_nilai_komponen(request: Request, kode: str, i: int, sesi_id: int = -1,
|
||||
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,
|
||||
"rtm": rtm, "subs_pilihan": subs_pilihan, "subs_terpilih": subs_terpilih,
|
||||
"tahun": db.tahun_aktif(), "pesan": pesan},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/nilai/{kode}/komponen/{i}")
|
||||
async def simpan_nilai_komponen(request: Request, kode: str, i: int):
|
||||
async def simpan_nilai_komponen(request: Request, kode: str, i: str):
|
||||
peta = db.peta_mk(kode)
|
||||
if not peta:
|
||||
return RedirectResponse("/", status_code=303)
|
||||
@@ -761,17 +840,29 @@ async def simpan_nilai_komponen(request: Request, kode: str, i: int):
|
||||
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), []))
|
||||
daftar_sesi = list(sesi_semua.get(i, []))
|
||||
|
||||
subs_dipilih = form.getlist("subs")
|
||||
|
||||
entri = {"label": label, "at": datetime.now(db.WIB).strftime("%Y-%m-%d %H:%M"),
|
||||
"by": updated_by, "nilai": nilai}
|
||||
# rincian ala RTM (khusus komponen Tugas) — semua opsional, dokumentasi saja
|
||||
for kunci in ("bentuk_tugas", "deskripsi", "metode_pengerjaan", "luaran",
|
||||
"indikator_bobot", "jadwal", "lain_lain", "rujukan"):
|
||||
nilai_kunci = (form.get(kunci) or "").strip()
|
||||
if nilai_kunci:
|
||||
entri[kunci] = nilai_kunci
|
||||
if subs_dipilih and set(subs_dipilih) != set(komponen["subs"]):
|
||||
# subset Sub-CPMK khusus sesi ini (ala RTM) — dibiarkan kosong (berarti
|
||||
# menyasar semua Sub-CPMK komponen, perilaku lama) bila semua tercentang
|
||||
entri["subs"] = [s for s in subs_dipilih if s in komponen["subs"]]
|
||||
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
|
||||
sesi_semua[i] = daftar_sesi
|
||||
|
||||
komponen_daftar = _komponen_daftar(kode)
|
||||
skor_terhitung = hitung.skor_dari_sesi(komponen_daftar, sesi_semua)
|
||||
@@ -821,6 +912,70 @@ async def simpan_nilai(request: Request, kode: str):
|
||||
request, kode,
|
||||
pesan="Seluruh nilai tersimpan MK ini dihapus — silakan mulai dari awal.")
|
||||
|
||||
if aksi == "komponen":
|
||||
kelas_dosen = _kelas_dosen(request.state.user, kode)
|
||||
nim_diizinkan = _nim_diizinkan(kode, kelas_dosen)
|
||||
komponen_daftar = _komponen_daftar(kode)
|
||||
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()}
|
||||
nama_map = {mm["nim"]: mm["nama"] for mm in db.mahasiswa_mk(kode)}
|
||||
n_diubah = 0
|
||||
i = 0
|
||||
while f"nim_{i}" in form:
|
||||
nim = (form.get(f"nim_{i}") or "").strip()
|
||||
if nim and (nim_diizinkan is None or nim in nim_diizinkan):
|
||||
for k in komponen_daftar:
|
||||
v = (form.get(f"komp_{i}_{k['i']}") or "").strip()
|
||||
if not v:
|
||||
continue
|
||||
try:
|
||||
x = float(v)
|
||||
except ValueError:
|
||||
continue
|
||||
if not (0 <= x <= 100):
|
||||
continue
|
||||
# Simpan sebagai sesi "Input Cepat" (satu per komponen,
|
||||
# ditimpa/diperbarui tiap kali) — supaya sumbernya sama
|
||||
# dengan alur "Isi Nilai" dan bisa dihitung ulang seragam
|
||||
# lewat skor_dari_sesi (lihat _tabel_komponen).
|
||||
daftar_sesi = sesi_semua.setdefault(k["i"], [])
|
||||
cepat = next((s for s in daftar_sesi if s.get("cepat")), None)
|
||||
if cepat is None:
|
||||
cepat = {"label": "Input Cepat", "cepat": True, "nilai": {}}
|
||||
daftar_sesi.append(cepat)
|
||||
cepat["nilai"][nim] = x
|
||||
cepat["at"] = datetime.now(db.WIB).strftime("%Y-%m-%d %H:%M")
|
||||
cepat["by"] = updated_by
|
||||
n_diubah += 1
|
||||
i += 1
|
||||
if not n_diubah:
|
||||
return _konteks_nilai(request, kode,
|
||||
peringatan=["Tidak ada nilai komponen yang diubah."])
|
||||
|
||||
skor_terhitung = hitung.skor_dari_sesi(komponen_daftar, sesi_semua)
|
||||
urut = [dict(mm, skor=dict(mm.get("skor") or {})) for mm in lama_data.get("mahasiswa", [])]
|
||||
per_nim = {mm["nim"]: mm for mm in urut if mm.get("nim")}
|
||||
for nim, subskor in skor_terhitung.items():
|
||||
mm = per_nim.get(nim)
|
||||
if mm is None:
|
||||
mm = {"nim": nim, "nama": nama_map.get(nim, ""), "skor": {}}
|
||||
urut.append(mm)
|
||||
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})")
|
||||
data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sesi": sesi_semua,
|
||||
"sumber": riwayat[-8:]}
|
||||
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
|
||||
return _konteks_nilai(
|
||||
request, kode,
|
||||
pesan=f"{n_diubah} nilai komponen tersimpan.")
|
||||
|
||||
if aksi == "grid":
|
||||
kelas_dosen = _kelas_dosen(request.state.user, kode)
|
||||
nim_diizinkan = _nim_diizinkan(kode, kelas_dosen)
|
||||
|
||||
Reference in New Issue
Block a user