diff --git a/app/db.py b/app/db.py index a2e0626..12b11a8 100644 --- a/app/db.py +++ b/app/db.py @@ -138,6 +138,15 @@ def _migrasi_role_pengguna(conn: sqlite3.Connection) -> None: conn.execute("DROP TABLE pengguna_lama") +def _migrasi_kelas_penugasan(conn: sqlite3.Connection) -> None: + """Skema lama penugasan hanya (user_id, mk_kode) — tambah kolom kelas + (kosong = seluruh kelas MK ini, dipakai dosen_rps_mk; terisi = dosen_mk + yang hanya mengampu kelas tertentu).""" + kolom = {r["name"] for r in conn.execute("PRAGMA table_info(penugasan)")} + if "kelas" not in kolom: + conn.execute("ALTER TABLE penugasan ADD COLUMN kelas TEXT NOT NULL DEFAULT ''") + + def init_db() -> None: with get_conn() as conn: for tabel in ("rps", "portofolio", "nilai"): @@ -195,6 +204,7 @@ def init_db() -> None: PRIMARY KEY (user_id, mk_kode) )""" ) + _migrasi_kelas_penugasan(conn) conn.execute( """CREATE TABLE IF NOT EXISTS mahasiswa ( npm TEXT NOT NULL, @@ -391,12 +401,15 @@ def daftar_pengguna() -> list[dict]: "ORDER BY role, nama" ).fetchall() tugas = conn.execute( - "SELECT user_id, mk_kode FROM penugasan ORDER BY mk_kode" + "SELECT user_id, mk_kode, kelas FROM penugasan ORDER BY mk_kode" ).fetchall() - per_user: dict[int, list[str]] = {} + per_user_kode: dict[int, list[str]] = {} + per_user_kelas: dict[int, dict[str, str]] = {} for t in tugas: - per_user.setdefault(t["user_id"], []).append(t["mk_kode"]) - return [{**dict(r), "mk": per_user.get(r["id"], [])} for r in rows] + per_user_kode.setdefault(t["user_id"], []).append(t["mk_kode"]) + per_user_kelas.setdefault(t["user_id"], {})[t["mk_kode"]] = t["kelas"] or "" + return [{**dict(r), "mk": per_user_kode.get(r["id"], []), + "kelas_map": per_user_kelas.get(r["id"], {})} for r in rows] def hapus_pengguna(user_id: int) -> None: @@ -413,12 +426,14 @@ def ubah_password(user_id: int, pw_hash: str) -> None: conn.execute("DELETE FROM sesi WHERE user_id=?", (user_id,)) -def set_penugasan(user_id: int, kode_list: list[str]) -> None: +def set_penugasan(user_id: int, penugasan: list[tuple[str, str]]) -> None: + """penugasan = [(mk_kode, kelas), ...] — kelas '' berarti seluruh kelas MK + ini; boleh CSV ('A,B') bila dosen mengampu beberapa kelas MK yang sama.""" with get_conn() as conn: conn.execute("DELETE FROM penugasan WHERE user_id=?", (user_id,)) conn.executemany( - "INSERT OR IGNORE INTO penugasan (user_id, mk_kode) VALUES (?,?)", - [(user_id, k) for k in kode_list], + "INSERT OR IGNORE INTO penugasan (user_id, mk_kode, kelas) VALUES (?,?,?)", + [(user_id, k, kelas) for k, kelas in penugasan], ) @@ -430,6 +445,22 @@ def mk_diampu(user_id: int) -> set[str]: return {r["mk_kode"] for r in rows} +def kelas_diampu(user_id: int, mk_kode: str) -> set[str] | None: + """Kelas yang diizinkan dosen ini untuk MK tsb. None = seluruh kelas + (tak dibatasi); set kosong = tidak ditugaskan sama sekali ke MK ini.""" + with get_conn() as conn: + row = conn.execute( + "SELECT kelas FROM penugasan WHERE user_id=? AND mk_kode=?", + (user_id, mk_kode), + ).fetchone() + if not row: + return set() + kelas = (row["kelas"] or "").strip() + if not kelas: + return None + return {k.strip() for k in kelas.split(",") if k.strip()} + + def pengampu_per_mk() -> dict[str, list[str]]: """{mk_kode: [nama dosen, ...]} — untuk kolom pengampu di beranda.""" with get_conn() as conn: diff --git a/app/hitung.py b/app/hitung.py index 49eefd7..bf5f92b 100644 --- a/app/hitung.py +++ b/app/hitung.py @@ -91,6 +91,56 @@ def bobot_sub_dari_rps(rps_data: dict | None) -> dict[str, float]: return bobot +def skor_dari_sesi(komponen_daftar: list[dict], sesi: dict) -> dict[str, dict[str, float]]: + """npm -> {sub: skor}, dihitung dari log sesi input per-komponen (tombol + 'isi per komponen' di halaman nilai): rata sederhana antar sesi dalam + komponen yang sama (mis. beberapa kali Kuis), lalu rata tertimbang bobot + antar-komponen bila satu Sub-CPMK diukur oleh >1 komponen (mis. Kuis dan + Presentasi sama-sama menilai satu Sub-CPMK) — bobot mengikuti alokasi + yang sama dengan bobot_sub_dari_rps (bobot komponen dibagi rata ke subnya). + """ + komponen_by_i = {k["i"]: k for k in komponen_daftar} + per_komponen_avg: dict[int, dict[str, float]] = {} + for i_str, daftar_sesi in (sesi or {}).items(): + try: + i = int(i_str) + except (TypeError, ValueError): + continue + if i not in komponen_by_i or not daftar_sesi: + continue + per_npm: dict[str, list[float]] = {} + for s in daftar_sesi: + for npm, v in (s.get("nilai") or {}).items(): + if v is not None: + per_npm.setdefault(npm, []).append(float(v)) + per_komponen_avg[i] = {npm: _rata(xs) for npm, xs in per_npm.items()} + + kontribusi_per_sub: dict[str, list[tuple[float, dict]]] = {} + for i, avg_map in per_komponen_avg.items(): + k = komponen_by_i[i] + try: + bobot = float(k.get("bobot") or 0) + except (TypeError, ValueError): + bobot = 0 + n_subs = len(k["subs"]) or 1 + w = bobot / n_subs if bobot else 1.0 + for sub in k["subs"]: + kontribusi_per_sub.setdefault(sub, []).append((w, avg_map)) + + out: dict[str, dict[str, float]] = {} + for sub, kontribusi in kontribusi_per_sub.items(): + npms: set[str] = set() + for _, avg_map in kontribusi: + npms |= avg_map.keys() + for npm in npms: + pasangan = [(w, avg_map[npm]) for w, avg_map in kontribusi if npm in avg_map] + total_w = sum(w for w, _ in pasangan) + nilai = (sum(w * v for w, v in pasangan) / total_w) if total_w \ + else _rata([v for _, v in pasangan]) + out.setdefault(npm, {})[sub] = nilai + return out + + def nilai_akhir_mahasiswa(nilai_data: dict, bobot_sub: dict[str, float]) -> list[dict]: """Nilai akhir tertimbang bobot Sub-CPMK per mahasiswa (mode per_mahasiswa).""" out = [] diff --git a/app/main.py b/app/main.py index 75d6f4b..b4b7255 100644 --- a/app/main.py +++ b/app/main.py @@ -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()) diff --git a/app/nilai_xlsx.py b/app/nilai_xlsx.py index 43b9025..a89c3c1 100644 --- a/app/nilai_xlsx.py +++ b/app/nilai_xlsx.py @@ -10,45 +10,6 @@ BIRU = "1F4E79" BIRU_MUDA = "D6E4F0" -def buat_template(peta: dict, tahun: str, mahasiswa: list[dict] | None = None) -> bytes: - """Template xlsx: kolom NPM, Nama, Kelas, lalu satu kolom per Sub-CPMK. - - Bila daftar mahasiswa (impor kaprodi) tersedia, baris NPM/Nama/Kelas - sudah terisi — dosen tinggal mengisi skornya. - """ - wb = openpyxl.Workbook() - ws = wb.active - ws.title = "Nilai" - - subs = peta["subs"] - ws.append([f"NILAI PER SUB-CPMK — {peta['nama']} ({peta['kode']}) — {tahun}"]) - ws.append(["Petunjuk: isi skor 0–100 per mahasiswa per Sub-CPMK. " - "Jangan mengubah baris kode Sub-CPMK."]) - ws.append([]) - header = ["NPM", "Nama Mahasiswa", "Kelas"] + [s["kode"] for s in subs] - ws.append(header) - uraian = ["", "", ""] + [s["uraian"][:80] for s in subs] - ws.append(uraian) - for m in mahasiswa or []: - ws.append([m["npm"], m["nama"], m.get("kelas", "")]) - - for ci, _ in enumerate(header, 1): - c = ws.cell(row=4, column=ci) - c.font = Font(bold=True, color="FFFFFF") - c.fill = PatternFill("solid", fgColor=BIRU) - c.alignment = Alignment(horizontal="center") - u = ws.cell(row=5, column=ci) - u.font = Font(italic=True, size=8) - u.alignment = Alignment(wrap_text=True, vertical="top") - ws.column_dimensions[get_column_letter(ci)].width = ( - 14 if ci == 1 else 28 if ci == 2 else 9 if ci == 3 else 14) - ws.freeze_panes = "D6" - - buf = io.BytesIO() - wb.save(buf) - return buf.getvalue() - - def template_mahasiswa(daftar_mk: list[dict], tahun: str) -> bytes: """Template impor daftar mahasiswa: NPM | Nama | Kode MK | Kelas.""" wb = openpyxl.Workbook() @@ -148,60 +109,3 @@ def parse_mahasiswa(isi: bytes, kode_mk_valid: set[str]) -> tuple[list[dict], li if not baris: peringatan.append("Tidak ada baris kontrak yang terbaca.") return baris, peringatan - - -def parse_unggahan(isi: bytes, kode_sub_valid: set[str]) -> tuple[list[dict], list[str]]: - """Baca xlsx nilai → (mahasiswa, peringatan). - - Baris header dicari otomatis: baris yang memuat 'NPM'. Kolom Sub-CPMK - dicocokkan dengan kode yang valid untuk MK tsb. - """ - wb = openpyxl.load_workbook(io.BytesIO(isi), read_only=True, data_only=True) - ws = wb.active - rows = list(ws.iter_rows(values_only=True)) - - header_i, kolom_sub = None, {} - for i, row in enumerate(rows): - vals = [str(v).strip() if v is not None else "" for v in row] - if "NPM" in vals: - header_i = i - for ci, v in enumerate(vals): - if v in kode_sub_valid: - kolom_sub[ci] = v - npm_i = vals.index("NPM") - nama_i = npm_i + 1 - break - if header_i is None or not kolom_sub: - return [], ["Header tidak dikenali: pastikan memakai template dari aplikasi " - "(ada kolom NPM dan kode Sub-CPMK)."] - - mahasiswa, peringatan = [], [] - for row in rows[header_i + 1:]: - vals = list(row) - npm = str(vals[npm_i]).strip() if npm_i < len(vals) and vals[npm_i] is not None else "" - nama = str(vals[nama_i]).strip() if nama_i < len(vals) and vals[nama_i] is not None else "" - skor = {} - for ci, sub in kolom_sub.items(): - v = vals[ci] if ci < len(vals) else None - if v is None or v == "": - continue - try: - x = float(v) - except (TypeError, ValueError): - continue - if 0 <= x <= 100: - skor[sub] = x - else: - peringatan.append(f"{npm or nama}: nilai {sub} = {x} di luar 0–100, dilewati.") - if skor and (npm or nama): - mahasiswa.append({"npm": npm, "nama": nama, "skor": skor}) - - if not mahasiswa: - peringatan.append("Tidak ada baris nilai yang terbaca.") - hilang = kode_sub_valid - {s for m in mahasiswa for s in m["skor"]} - if mahasiswa and hilang: - peringatan.append( - f"{len(hilang)} Sub-CPMK tanpa nilai sama sekali: " - + ", ".join(sorted(hilang)[:8]) + ("…" if len(hilang) > 8 else "") - ) - return mahasiswa, peringatan diff --git a/app/templates/admin.html b/app/templates/admin.html index a819390..3a9fca0 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -8,7 +8,10 @@

Dosen RPS + MK dapat mengedit RPS, nilai, dan portofolio pada MK yang ditugaskan. Dosen MK hanya dapat mengedit nilai & portofolio pada MK yang ditugaskan — halaman RPS terbuka hanya untuk dibaca. Kaprodi dapat - mengakses semuanya.

+ mengakses semuanya. Kosongkan kolom kelas bila dosen mengampu seluruh kelas + MK itu; isi (mis. "A" atau "A,B") bila dosen hanya boleh melihat & menilai + mahasiswa kelas tertentu — cocok untuk MK dengan beberapa kelas paralel yang + diampu dosen berbeda.

@@ -30,7 +33,9 @@ {% if p.role == 'kaprodi' %} semua MK {% else %} - {{ p.mk|join(', ') if p.mk else '—' }} + {% if p.mk %} + {% for kode in p.mk %}{{ kode }}{% if p.kelas_map.get(kode) %} (kelas {{ p.kelas_map[kode] }}){% endif %}{{ ', ' if not loop.last }}{% endfor %} + {% else %}—{% endif %}
Ubah penugasan…
@@ -38,6 +43,8 @@ {% for mk in daftar %} + {% endfor %} diff --git a/app/templates/base.html b/app/templates/base.html index 8d20fe2..9c119ed 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -28,9 +28,11 @@
+ + + + + + + + + + + {% for k in komponen_daftar %} + + + + + + + + {% endfor %} + +
KomponenSub-CPMKBobotStatus
{{ k.nama }}
{{ 'boleh diisi berkali-kali' if k.ulang else 'sekali-jalan' }}
{{ k.n_subs }}{% if k.bobot %}{{ k.bobot }}%{% else %}—{% endif %} + {% if k.ulang %} + {% if k.n_sesi %}{{ k.n_sesi }} SESI{% else %}BELUM ADA SESI{% endif %} + {% else %} + {% if k.n_sesi %}SUDAH DIISI{% else %}BELUM DIISI{% endif %} + {% endif %} + + + {{ 'Sesi Baru' if k.ulang else ('Edit' if k.n_sesi else 'Isi') }} + +
+ {% endif %} + {% if tersimpan %}
Data tersimpan: mode {% if tersimpan.data.mode == 'per_mahasiswa' %}PER MAHASISWA ({{ tersimpan.data.mahasiswa|length }} mahasiswa){% else %}BUKTI AGREGAT (rata-rata kelas){% endif %} — terakhir disimpan {{ tersimpan.updated_at }}{% if tersimpan.updated_by %} oleh {{ tersimpan.updated_by }}{% endif %}. - {% if tersimpan.data.sumber %}
Riwayat unggahan: + {% if tersimpan.data.sumber %}
Riwayat perubahan: {{ tersimpan.data.sumber if tersimpan.data.sumber is string else tersimpan.data.sumber|join(' · ') }}.{% endif %} @@ -100,56 +146,6 @@ {% endif %} -
- {{ 'ALTERNATIF — ' if grid }}UNGGAH EXCEL PER MAHASISWA (cocok bila nilai sudah direkap di Excel, atau team teaching mengisi terpisah) -

- Unduh template berkolom Sub-CPMK MK ini, isi nilai 0–100 per mahasiswa, lalu unggah kembali. - {% if terdaftar %}Template sudah berisi {{ terdaftar|length }} mahasiswa yang - mengontrak MK ini (dari daftar impor kaprodi). - {% else %}Daftar mahasiswa MK ini belum diimpor kaprodi — template kosong, - isi NPM & nama secara manual.{% endif %} -
Team teaching: tiap dosen cukup mengunggah bagiannya sendiri — kolom - Sub-CPMK yang diampunya (mis. sebelum/sesudah UTS) atau baris mahasiswa kelasnya. - Unggahan digabung: sel terisi memperbarui nilai, sel kosong tidak menghapus - nilai yang sudah diunggah dosen lain. -

- -
-
- - -
-
-
- -
- ALTERNATIF — KETIK RATA-RATA KELAS PER SUB-CPMK (hanya bila nilai per mahasiswa tidak tersedia; ditandai "bukti agregat" pada rekap) -
-
- - - - {% for s in peta.subs %} - - - - - - {% endfor %} - -
Sub-CPMKUraianRata-rata (0–100)
{{ s.kode }}{{ s.uraian[:110] }}{% if s.uraian|length > 110 %}…{% endif %}
-
-
- Disimpan atas nama {{ user.nama }} - - -
-
-
- {% if hasil %}

HASIL KETERCAPAIAN MK INI (ambang {{ ambang|round(0)|int }}; roll-up bagi rata)

diff --git a/app/templates/form_nilai_komponen.html b/app/templates/form_nilai_komponen.html new file mode 100644 index 0000000..2619a05 --- /dev/null +++ b/app/templates/form_nilai_komponen.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block judul %}Nilai {{ komponen.nama }} — {{ peta.nama }} — Informatika UNTAN{% endblock %} +{% block isi %} + + +

Isi Nilai — {{ komponen.nama }} ({{ peta.nama }})

+ +

+ Nilai di sini digabung ke Sub-CPMK yang diukur komponen ini: + {% for s in komponen.subs %}{{ s }}{{ ', ' if not loop.last }}{% endfor %} + {% if komponen.bobot %}(bobot komponen {{ komponen.bobot }}%){% endif %}. + {% if komponen.ulang %} + Komponen ini boleh diisi berkali-kali (mis. beberapa kali kuis sepanjang + semester) — nilai tiap sesi dirata-rata otomatis per Sub-CPMK. Bila satu Sub-CPMK + juga diukur komponen lain, hasil akhirnya dirata tertimbang bobot antar-komponen. + {% else %} + Komponen ini hanya satu sesi per semester — menyimpan lagi memperbarui + (menimpa) sesi yang sudah ada, bukan menambah sesi baru. + {% endif %} + Jika mahasiswa perlu nilai berbeda antar Sub-CPMK dalam komponen yang sama + (mis. UTS dengan beberapa kelompok soal), isi lewat + grid per Sub-CPMK di halaman utama nilai, bukan di sini. +

+ + {% if pesan %}
{{ pesan }}
{% endif %} + + {% if komponen.ulang and daftar_sesi %} +

SESI TERSIMPAN

+ + + + {% for s in daftar_sesi %} + + + + + + {% endfor %} + +
SesiMahasiswa Terisi
{{ s.label }}{{ (s.nilai or {})|length }}Edit
+ {% endif %} + +

{{ 'EDIT SESI' if edit_id < daftar_sesi|length else 'SESI BARU' }} — {{ label_awal }}

+
+ + {% if komponen.ulang %} + + {% else %} + + {% endif %} +
+ + + + + + + + + + + {% for m in grid %} + + + + + + + {% endfor %} + +
No.NPMNamaNilai (0–100)
{{ loop.index }} + + {{ m.npm }}{{ m.nama }}
+
+
+ Disimpan atas nama {{ user.nama }} + + +
+
+{% endblock %}