# -*- coding: utf-8 -*- """Aplikasi Penyusunan RPS & Portofolio — Prodi Informatika UNTAN.""" import json import pathlib import re from datetime import datetime from fastapi import FastAPI, Request from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from . import auth, db, hitung, nilai_xlsx from .generator.portofolio_docx import build_portofolio from .generator.rps_untan import build_rps from .generator.rtm_docx import build_rtm BASE = pathlib.Path(__file__).resolve().parent app = FastAPI(title="RPS & Portofolio — Informatika UNTAN") app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static") templates = Jinja2Templates(directory=str(BASE / "templates")) templates.env.filters["tojson"] = ( lambda v: json.dumps(v, ensure_ascii=False).replace(" bool: """Kaprodi, atau dosen RPS+MK yang ditugaskan ke MK ini — syarat edit RPS.""" if user["role"] == "kaprodi": return True return user["role"] == "dosen_rps_mk" and kode in db.mk_diampu(user["id"]) def boleh_mk(user: dict, kode: str) -> bool: """Kaprodi, atau dosen (MK / RPS+MK) yang ditugaskan ke MK ini — syarat edit nilai & portofolio.""" if user["role"] == "kaprodi": return True 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 _nim_diizinkan(kode: str, kelas_dosen: set[str] | None) -> set[str] | None: """NIM yang boleh ditulis dosen ini. None = tak dibatasi (semua NIM MK ini).""" if kelas_dosen is None: return None return {m["nim"] for m in db.mahasiswa_mk(kode) if m.get("kelas") in kelas_dosen} def _masuk(user_id: int, tujuan: str = "/") -> RedirectResponse: token = auth.token_baru() db.buat_sesi(user_id, token) r = RedirectResponse(tujuan, status_code=303) r.set_cookie("sesi", token, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30) return r @app.get("/setup", response_class=HTMLResponse) def setup_form(request: Request): if db.ada_pengguna(): return RedirectResponse("/login", status_code=303) return render(request, "login.html", {"mode": "setup", "pesan": "", "tahun": db.tahun_aktif()}) def _rapikan_nip(mentah: str) -> str: """NIP sebagai username: buang spasi/titik agar seragam.""" return "".join(ch for ch in (mentah or "") if ch.isalnum()) @app.post("/setup") async def setup_simpan(request: Request): if db.ada_pengguna(): return RedirectResponse("/login", status_code=303) form = await request.form() nip = _rapikan_nip(form.get("nip") or "") nama = (form.get("nama") or "").strip() if not nip or not nama: return render(request, "login.html", {"mode": "setup", "tahun": db.tahun_aktif(), "pesan": "Nama dan NIP wajib diisi."}) # password awal = NIP (kesepakatan prodi: kredensial cukup NIP) uid = db.buat_pengguna(nip, nama, "kaprodi", auth.hash_password(nip)) return _masuk(uid) @app.get("/login", response_class=HTMLResponse) def login_form(request: Request): if not db.ada_pengguna(): return RedirectResponse("/setup", status_code=303) return render(request, "login.html", {"mode": "login", "pesan": "", "tahun": db.tahun_aktif()}) @app.post("/login") async def login_proses(request: Request): form = await request.form() nip = _rapikan_nip(form.get("nip") or "") pw = form.get("password") or "" p = db.pengguna_by_username(nip) if not p or not auth.verifikasi_password(pw, p["pw_hash"]): return render(request, "login.html", {"mode": "login", "tahun": db.tahun_aktif(), "pesan": "NIP atau password salah."}) return _masuk(p["id"]) @app.get("/keluar") def keluar(request: Request): db.hapus_sesi(request.cookies.get("sesi", "")) r = RedirectResponse("/login", status_code=303) r.delete_cookie("sesi") return r @app.get("/akun", response_class=HTMLResponse) def ganti_password_form(request: Request, pesan: str = "", error: str = ""): return render(request, "ganti_password.html", {"pesan": pesan, "error": error}) @app.post("/akun") async def ganti_password_simpan(request: Request): form = await request.form() lama = form.get("lama") or "" baru = form.get("baru") or "" ulang = form.get("ulang") or "" user = db.pengguna_by_id(request.state.user["id"]) if not user or not auth.verifikasi_password(lama, user["pw_hash"]): return render(request, "ganti_password.html", {"error": "Password lama salah."}) if len(baru) < 4: return render(request, "ganti_password.html", {"error": "Password baru minimal 4 karakter."}) if baru != ulang: return render(request, "ganti_password.html", {"error": "Konfirmasi password baru tidak cocok dengan password baru."}) db.ubah_password(user["id"], auth.hash_password(baru)) return _masuk(user["id"], "/akun?pesan=Password+berhasil+diubah.") # ── admin kaprodi: akun, penugasan MK, daftar mahasiswa ───────────────────── def _bukan_kaprodi(request: Request): if request.state.user["role"] != "kaprodi": return RedirectResponse("/", status_code=303) return None @app.get("/admin", response_class=HTMLResponse) def admin(request: Request, pesan: str = ""): tolak = _bukan_kaprodi(request) if tolak: return tolak mhs = db.ringkasan_mahasiswa() return render(request, "admin.html", {"pengguna": db.daftar_pengguna(), "daftar": db.daftar_mk(), "mhs": mhs, "n_mhs_mk": len(mhs), "tahun": db.tahun_aktif(), "pesan": pesan}) @app.post("/admin/pengguna") async def admin_buat_pengguna(request: Request): tolak = _bukan_kaprodi(request) if tolak: return tolak form = await request.form() nip = _rapikan_nip(form.get("nip") or "") nama = (form.get("nama") or "").strip() role_in = form.get("role") role = role_in if role_in in ("kaprodi", "dosen_mk", "dosen_rps_mk") else "dosen_mk" if not nip or not nama: return RedirectResponse( "/admin?pesan=Nama+dan+NIP+wajib+diisi.", status_code=303) uid = db.buat_pengguna(nip, nama, role, auth.hash_password(nip)) if uid is None: return RedirectResponse( f"/admin?pesan=NIP+{nip}+sudah+terdaftar.", status_code=303) return RedirectResponse( f"/admin?pesan=Akun+{nama.split(',')[0].replace(' ', '+')}+({role})+dibuat+-+login+dengan+NIP+{nip},+password+=+NIP.", status_code=303) @app.post("/admin/pengguna/{uid}/hapus") async def admin_hapus_pengguna(request: Request, uid: int): tolak = _bukan_kaprodi(request) if tolak: return tolak if uid == request.state.user["id"]: return RedirectResponse( "/admin?pesan=Tidak+bisa+menghapus+akun+sendiri.", status_code=303) db.hapus_pengguna(uid) return RedirectResponse("/admin?pesan=Akun+dihapus.", status_code=303) @app.post("/admin/pengguna/{uid}/reset") async def admin_reset_password(request: Request, uid: int): tolak = _bukan_kaprodi(request) if tolak: return tolak p = db.pengguna_by_id(uid) if not p: return RedirectResponse("/admin?pesan=Akun+tidak+ditemukan.", status_code=303) db.ubah_password(uid, auth.hash_password(p["username"])) return RedirectResponse( "/admin?pesan=Password+dikembalikan+ke+NIP+-+sesi+lama+pengguna+tsb+ditutup.", status_code=303) @app.post("/admin/pengguna/{uid}/tugas") async def admin_penugasan(request: Request, uid: int): tolak = _bukan_kaprodi(request) if tolak: return tolak form = await request.form() kode_valid = {m["kode"] for m in db.daftar_mk()} 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( f"/admin?pesan={len(pilihan)}+MK+ditugaskan+ke+{p['username'] if p else uid}.", status_code=303) @app.get("/admin/mahasiswa/template.xlsx") def admin_template_mahasiswa(request: Request): tolak = _bukan_kaprodi(request) if tolak: return tolak isi = nilai_xlsx.template_mahasiswa(db.daftar_mk(), db.tahun_aktif()) return Response( content=isi, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": 'attachment; filename="Template_Daftar_Mahasiswa.xlsx"'}, ) @app.post("/admin/mahasiswa") async def admin_impor_mahasiswa(request: Request): tolak = _bukan_kaprodi(request) if tolak: return tolak form = await request.form() berkas = form.get("berkas") if berkas is None or not getattr(berkas, "filename", ""): return RedirectResponse( "/admin?pesan=Pilih+file+.xlsx+terlebih+dahulu.", status_code=303) isi = await berkas.read() kode_valid = {m["kode"] for m in db.daftar_mk()} try: baris, peringatan = nilai_xlsx.parse_mahasiswa(isi, kode_valid) except Exception: return RedirectResponse( "/admin?pesan=File+tidak+terbaca+sebagai+.xlsx.", status_code=303) if not baris: pesan = "+".join((peringatan or ["Tidak ada baris yang terbaca."])[0].split()) return RedirectResponse(f"/admin?pesan={pesan}", status_code=303) db.ganti_mahasiswa(baris) n_mk = len({b["mk_kode"] for b in baris}) pesan = f"{len(baris)}+baris+kontrak+({n_mk}+MK)+diimpor+-+menggantikan+daftar+lama+tahun+ini." if peringatan: pesan += "+Catatan:+" + "+".join(" ".join(peringatan[:3]).split()) return RedirectResponse(f"/admin?pesan={pesan}", status_code=303) # 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, "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}, ] def _normalisasi_komponen(tersimpan: list[dict]) -> list[dict]: """Selaraskan komponen tersimpan ke struktur baku KOMPONEN_DEFAULT (cocokkan berdasar nama; RPS lama dengan nama komponen berbeda kehilangan isian itu).""" by_nama = {k.get("nama"): k for k in tersimpan} out = [] for default in KOMPONEN_DEFAULT: ada = by_nama.get(default["nama"]) out.append({ "nama": default["nama"], "subs": ada.get("subs", []) if ada else [], "bobot": ada.get("bobot", "") if ada else "", }) return out def _komponen_tanpa_sub(rps_data: dict) -> list[str]: """Nama komponen RPS bagian G yang bobotnya sudah diisi tapi belum ada Sub-CPMK dicentang. Komponen begini masih ikut dihitung ke total 100% di bagian G (jadi mudah lolos tanpa disadari), tapi disaring habis oleh _komponen_daftar() — tidak akan pernah muncul di halaman Nilai.""" out = [] for k in _normalisasi_komponen(rps_data.get("komponen", [])): bobot_terisi = str(k.get("bobot") or "").strip() not in ("", "0", "0.0") if bobot_terisi and not k.get("subs"): out.append(k["nama"]) return out def minggu_default() -> list[dict]: rows = [] for w in range(1, 17): m = {"ke": w, "sub": "", "indikator": "", "kriteria": "", "metode": "", "daring": "", "kegiatan": "", "materi": "", "bobot": ""} if w == 8: m.update(sub="__uts__", kriteria="Ujian Tengah Semester (UTS)", metode="Ujian tulis [2 x 50 menit]", materi="Materi minggu 1–7") elif w == 16: m.update(sub="__uas__", kriteria="Ujian Akhir Semester (UAS)", metode="Ujian tulis [2 x 50 menit]", materi="Materi minggu 9–15") rows.append(m) return rows def form_kosong() -> dict: return { "identitas": {"sks": "", "sks_teori": "", "sks_praktikum": "", "semester": "", "prasyarat": "", "pengampu": "", "koordinator": "", "kaprodi": "", "rumpun": "", "tgl_penyusunan": "", "pengembang": "", "kode_dokumen": ""}, "deskripsi": "", "bahan_kajian": "", "media": "", "minggu": minggu_default(), "komponen": [dict(k) for k in KOMPONEN_DEFAULT], "referensi_teks": "", "referensi_pendukung_teks": "", "sub_uraian_custom": {}, } # ── RTM (Rancangan Tugas Mahasiswa) — dokumen sendiri, terpisah dari Nilai ── RTM_TUGAS_KOSONG = { "judul": "", "subs": [], "bentuk_tugas": "", "deskripsi": "", "metode_pengerjaan": "", "luaran": "", "indikator_bobot": "", "alokasi_waktu": "", "tanggal": "", "lain_lain": "", "rujukan": "", } def _ambil_rtm(kode: str) -> tuple[list[dict], dict]: """Daftar entri Tugas RTM yang sudah tersimpan (bisa kosong — halaman RTM menambah blok baru secara dinamis lewat JS, bukan slot tetap) — murni dokumentasi, tak menyentuh data Nilai.""" tersimpan = db.ambil_dok("rtm", kode) tugas = [dict(RTM_TUGAS_KOSONG, **t) for t in ((tersimpan or {}).get("data", {}).get("tugas") or [])] meta = tersimpan or {"status": "", "updated_at": "", "updated_by": ""} return tugas, meta def _bahan_rtm(kode: str) -> dict | None: peta = db.peta_mk(kode) if not peta: return None rps = db.ambil_rps(kode) ident = ((rps or {}).get("data") or {}).get("identitas") or {} tersimpan = db.ambil_dok("rtm", kode) tugas = [t for t in ((tersimpan or {}).get("data", {}).get("tugas") or []) if t.get("judul") or t.get("bentuk_tugas") or t.get("deskripsi")] return { "tahun": db.tahun_aktif(), "nama": peta["nama"], "kode": peta["kode"], "sks": ident.get("sks", ""), "semester": ident.get("semester", ""), "pengampu": ident.get("pengampu", ""), "tugas": tugas, } def _terapkan_override_sub(peta: dict, rps_data: dict | None) -> dict: """Salinan `peta` dengan uraian Sub-CPMK ditimpa override tersimpan di RPS MK ini (bila ada) — dipakai konsisten di form RPS, unduhan RPS, dan portofolio. Kode, CPMK/CPL yang didukung, dan struktur pemetaan kurikulum TIDAK ikut berubah — dosen hanya boleh menyesuaikan redaksi kalimatnya, bukan struktur/kaitannya (itu tetap kewenangan kurikulum prodi).""" override = (rps_data or {}).get("sub_uraian_custom") or {} if not override: return peta peta = dict(peta) peta["subs"] = [ {**s, "uraian": override[s["kode"]]} if override.get(s["kode"]) else s for s in peta["subs"] ] return peta @app.get("/", response_class=HTMLResponse) def beranda(request: Request, semua: int = 0): daftar = db.daftar_mk() n_total = len(daftar) mk_saya = db.mk_diampu(request.state.user["id"]) # dosen: bawaan hanya MK yang diampu; ?semua=1 menampilkan seluruh MK hanya_saya = (request.state.user["role"] != "kaprodi" and bool(mk_saya) and not semua) if hanya_saya: daftar = [m for m in daftar if m["kode"] in mk_saya] status = db.status_semua("rps") st_porto = db.status_semua("portofolio") st_nilai = db.status_semua("nilai") n_selesai = sum(1 for s in status.values() if s["status"] == "selesai") n_draft = sum(1 for s in status.values() if s["status"] == "draft") return render( request, "beranda.html", {"daftar": daftar, "status": status, "st_porto": st_porto, "st_nilai": st_nilai, "n_selesai": n_selesai, "n_draft": n_draft, "tahun": db.tahun_aktif(), "pengampu": db.pengampu_per_mk(), "smt": {k: v.get("semester") for k, v in db.sks_mk().items()}, "mk_saya": mk_saya, "hanya_saya": hanya_saya, "n_total": n_total}, ) @app.get("/dashboard") def dashboard(): """Dashboard Pemetaan CPMK (buatan user; self-contained, data ter-embed).""" return FileResponse(str(BASE / "static" / "dashboard.html"), media_type="text/html") @app.get("/rps/{kode}", response_class=HTMLResponse) def form_rps(request: Request, kode: str, pesan: str = ""): peta = db.peta_mk(kode) if not peta: return RedirectResponse("/", status_code=303) tersimpan = db.ambil_rps(kode) if tersimpan: f = tersimpan["data"] f.setdefault("referensi_teks", "\n".join(f.get("referensi", []))) f["komponen"] = _normalisasi_komponen(f.get("komponen", [])) ctx_status = tersimpan komponen_tanpa_sub = _komponen_tanpa_sub(f) else: f = form_kosong() # prefill SKS & semester dari data kurikulum (sheet '14. Susunan MK') info = db.sks_mk().get(kode) if info: f["identitas"]["sks"] = f"{info['sks']:g}" if info.get("semester"): f["identitas"]["semester"] = str(info["semester"]) ctx_status = {"status": "", "updated_at": "", "updated_by": ""} komponen_tanpa_sub = [] # form belum pernah disimpan — bobot masih bawaan default peta = _terapkan_override_sub(peta, f) return render( request, "form_rps.html", {"peta": peta, "f": f, "status": ctx_status["status"], "updated_at": ctx_status["updated_at"], "updated_by": ctx_status["updated_by"], "tahun": db.tahun_aktif(), "pesan": pesan, "boleh_edit": boleh_rps(request.state.user, kode), "tahun_lain": db.tahun_rps_lain(kode) if not tersimpan else [], "komponen_tanpa_sub": komponen_tanpa_sub, "sub_uraian": {s["kode"]: s["uraian"] for s in peta["subs"]}}, ) @app.post("/rps/{kode}/salin") async def salin_rps(request: Request, kode: str): """Duplikasi RPS dari tahun ajaran lain ke tahun aktif (status: draft).""" if not boleh_rps(request.state.user, kode): return RedirectResponse( f"/rps/{kode}?pesan=Anda+tidak+memiliki+hak+edit+RPS+untuk+MK+ini.", status_code=303) form = await request.form() dari = form.get("dari") or "" sumber = db.ambil_rps(kode, tahun=dari) if dari else None if not sumber: return RedirectResponse(f"/rps/{kode}?pesan=RPS+sumber+tidak+ditemukan.", status_code=303) if db.ambil_rps(kode): return RedirectResponse( f"/rps/{kode}?pesan=RPS+tahun+aktif+sudah+ada+-+tidak+ditimpa.", status_code=303) db.simpan_rps(kode, sumber["data"], "draft", request.state.user["nama"]) return RedirectResponse( f"/rps/{kode}?pesan=RPS+disalin+dari+{dari.replace(' ', '+')}+sebagai+draft.", status_code=303) @app.post("/rps/{kode}") async def simpan_rps(request: Request, kode: str): peta = db.peta_mk(kode) if not peta: return RedirectResponse("/", status_code=303) if not boleh_rps(request.state.user, kode): return RedirectResponse( f"/rps/{kode}?pesan=Anda+tidak+memiliki+hak+edit+RPS+untuk+MK+ini.", status_code=303) form = await request.form() identitas = { k: (form.get(k) or "").strip() for k in ("sks", "sks_teori", "sks_praktikum", "semester", "prasyarat", "pengampu", "koordinator", "kaprodi", "rumpun", "tgl_penyusunan", "pengembang", "kode_dokumen") } minggu = [] for w in range(1, 17): minggu.append({ "ke": w, "sub": (form.get(f"m{w}_sub") or "").strip(), "indikator": (form.get(f"m{w}_indikator") or "").strip(), "kriteria": (form.get(f"m{w}_kriteria") or "").strip(), "metode": (form.get(f"m{w}_metode") or "").strip(), "daring": (form.get(f"m{w}_daring") or "").strip(), "kegiatan": (form.get(f"m{w}_kegiatan") or "").strip(), "materi": (form.get(f"m{w}_materi") or "").strip(), "bobot": (form.get(f"m{w}_bobot") or "").strip(), }) komponen = [] for i, default in enumerate(KOMPONEN_DEFAULT, 1): komponen.append({ "nama": default["nama"], "subs": [s for s in form.getlist(f"k{i}_subs")], "bobot": (form.get(f"k{i}_bobot") or "").strip(), }) referensi_teks = (form.get("referensi") or "").strip() referensi = [r.strip() for r in referensi_teks.splitlines() if r.strip()] pendukung_teks = (form.get("referensi_pendukung") or "").strip() pendukung = [r.strip() for r in pendukung_teks.splitlines() if r.strip()] sub_uraian_custom = {} for s in peta["subs"]: teks = (form.get(f"sub_uraian_{s['kode']}") or "").strip() if teks and teks != s["uraian"]: sub_uraian_custom[s["kode"]] = teks data = { "identitas": identitas, "deskripsi": (form.get("deskripsi") or "").strip(), "bahan_kajian": (form.get("bahan_kajian") or "").strip(), "media": (form.get("media") or "").strip(), "minggu": minggu, "komponen": komponen, "referensi": referensi, "referensi_teks": referensi_teks, "referensi_pendukung": pendukung, "referensi_pendukung_teks": pendukung_teks, "sub_uraian_custom": sub_uraian_custom, } 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}{pesan_tambahan}", status_code=303) @app.get("/rps/{kode}/unduh") def unduh_rps(kode: str): peta = db.peta_mk(kode) tersimpan = db.ambil_rps(kode) if not peta or not tersimpan: return RedirectResponse(f"/rps/{kode}", status_code=303) f = tersimpan["data"] peta = _terapkan_override_sub(peta, f) # resolusi kode Sub-CPMK → teks uraian untuk dokumen uraian = {s["kode"]: s["uraian"] for s in peta["subs"]} def sub_teks(kode_sub: str) -> str: if kode_sub == "__uts__": return "Sub-CPMK minggu 1–7 (diujikan pada UTS)" if kode_sub == "__uas__": return "Sub-CPMK minggu 9–15 (diujikan pada UAS)" if kode_sub in uraian: return f"{kode_sub}: {uraian[kode_sub]}" return kode_sub d = { "tahun": db.tahun_aktif(), "identitas": {**f.get("identitas", {}), "nama": peta["nama"], "kode": peta["kode"]}, "deskripsi": f.get("deskripsi", ""), "bahan_kajian": f.get("bahan_kajian", ""), "media": f.get("media", ""), "cpl": peta["cpls"], "cpmk": [{"kode": c["kode"], "desk": c["desk"], "cpl": c["cpl"]} for c in peta["cpmks"]], "sub": [{"kode": s["kode"], "uraian": s["uraian"], "cpmk": s["cpmk"]} for s in peta["subs"]], "minggu": [{**m, "sub": sub_teks(m.get("sub", ""))} for m in f.get("minggu", [])], "komponen": f.get("komponen", []), "referensi": f.get("referensi", []), "referensi_pendukung": f.get("referensi_pendukung", []), } isi = build_rps(d) nama_file = re.sub(r"[^A-Za-z0-9_-]+", "_", f"RPS_{peta['kode']}_{peta['nama']}") + ".docx" return Response( content=isi, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", headers={"Content-Disposition": f'attachment; filename="{nama_file}"'}, ) @app.post("/rps/{kode}/rtm") async def simpan_rtm(request: Request, kode: str): """RTM (Rancangan Tugas Mahasiswa) — dokumen sendiri, sekadar dokumentasi rencana tugas per MK; tak ada kaitan dengan data Nilai/skor sama sekali.""" peta = db.peta_mk(kode) if not peta: return RedirectResponse("/", status_code=303) if not boleh_rps(request.state.user, kode): return RedirectResponse( f"/rps/{kode}/rtm?pesan=Anda+tidak+memiliki+hak+edit+RPS+untuk+MK+ini.", status_code=303) form = await request.form() # blok bisa ditambah/dihapus bebas di JS (lihat form_rtm.html) sehingga # indeksnya boleh tidak berurutan — cari semua "judul_N" yang ada, bukan # mengandalkan hitungan 0,1,2,... berurutan tanpa celah. idxs = sorted({int(m.group(1)) for k in form.keys() for m in [re.fullmatch(r"judul_(\d+)", k)] if m}) tugas = [] for i in idxs: entri = { "judul": (form.get(f"judul_{i}") or "").strip(), "subs": form.getlist(f"subs_{i}"), "bentuk_tugas": (form.get(f"bentuk_tugas_{i}") or "").strip(), "deskripsi": (form.get(f"deskripsi_{i}") or "").strip(), "metode_pengerjaan": (form.get(f"metode_pengerjaan_{i}") or "").strip(), "luaran": (form.get(f"luaran_{i}") or "").strip(), "indikator_bobot": (form.get(f"indikator_bobot_{i}") or "").strip(), "alokasi_waktu": (form.get(f"alokasi_waktu_{i}") or "").strip(), "tanggal": (form.get(f"tanggal_{i}") or "").strip(), "lain_lain": (form.get(f"lain_lain_{i}") or "").strip(), "rujukan": (form.get(f"rujukan_{i}") or "").strip(), } if entri["judul"] or entri["bentuk_tugas"] or entri["deskripsi"]: tugas.append(entri) db.simpan_dok("rtm", kode, {"tugas": tugas}, "selesai", request.state.user["nama"]) return RedirectResponse( f"/rps/{kode}/rtm?pesan=RTM+tersimpan+-+{len(tugas)}+tugas+terisi.", status_code=303) @app.get("/rps/{kode}/rtm", response_class=HTMLResponse) def form_rtm(request: Request, kode: str, pesan: str = ""): peta = db.peta_mk(kode) if not peta: return RedirectResponse("/", status_code=303) rps = db.ambil_rps(kode) if not rps or rps["status"] != "selesai": return RedirectResponse( f"/rps/{kode}?pesan=RTM+hanya+bisa+diisi+setelah+RPS+ditandai+selesai.", status_code=303) rtm_tugas, rtm_meta = _ambil_rtm(kode) return render( request, "form_rtm.html", {"peta": peta, "tahun": db.tahun_aktif(), "pesan": pesan, "boleh_edit": boleh_rps(request.state.user, kode), "rtm_tugas": rtm_tugas, "rtm_updated_at": rtm_meta.get("updated_at", ""), "rtm_updated_by": rtm_meta.get("updated_by", "")}, ) @app.get("/rps/{kode}/rtm/unduh") def unduh_rtm(kode: str): bahan = _bahan_rtm(kode) if not bahan or not bahan["tugas"]: return RedirectResponse( f"/rps/{kode}/rtm?pesan=Belum+ada+Tugas+RTM+yang+diisi+untuk+diunduh.", status_code=303) isi = build_rtm(bahan) nama_file = re.sub(r"[^A-Za-z0-9_-]+", "_", f"RTM_{kode}_{bahan['nama']}") + ".docx" return Response( content=isi, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", headers={"Content-Disposition": f'attachment; filename="{nama_file}"'}, ) # ═════════════════════════ TAHAP 2: NILAI & PENGUKURAN ═════════════════════════ 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"]: skor_map[m.get("nim", "")] = m.get("skor") or {} nama_map[m.get("nim", "")] = m.get("nama", "") grid, ada = [], set() for m in terdaftar: grid.append({"nim": m["nim"], "nama": m["nama"], "kelas": m.get("kelas") or "", "skor": skor_map.get(m["nim"], {})}) ada.add(m["nim"]) for nim, sk in skor_map.items(): if nim not in ada: grid.append({"nim": nim, "nama": nama_map.get(nim, ""), "kelas": "", "skor": sk}) if kelas_dosen is not None: grid = [g for g in grid if g["kelas"] in kelas_dosen] 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). '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 [] 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"] or not k["bobot"]: continue 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"], "tugas": idx == IDX_TUGAS, "n_sesi": len(sesi_semua.get(key, []))}) return out 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", [])) try: idx = int(i) except ValueError: return None 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 _rincian_bobot(kode: str) -> tuple[list[dict], dict[str, list[dict]]]: """Rincian pembagian bobot komponen (RPS bagian G) ke tiap Sub-CPMK — bobot_komponen ÷ jumlah Sub-CPMK komponen itu, dijumlah per Sub-CPMK bila diukur >1 komponen. Murni dari konfigurasi RPS (tak bergantung nilai mahasiswa) — dipakai untuk menjelaskan metodologi bobot di halaman nilai.""" rps = db.ambil_rps(kode) per_komponen: list[dict] = [] per_sub: dict[str, list[dict]] = {} if not rps: return per_komponen, per_sub for k in rps["data"].get("komponen", []): subs = k.get("subs") or [] if not subs: continue try: bobot = float(k.get("bobot") or 0) except (TypeError, ValueError): bobot = 0 if not bobot: continue poin = bobot / len(subs) per_komponen.append({"nama": k["nama"], "bobot": bobot, "n_subs": len(subs), "poin": poin}) for s in subs: per_sub.setdefault(s, []).append({"nama": k["nama"], "poin": poin}) return per_komponen, per_sub def _tabel_poin(grid: list[dict], bobot_sub_rincian: dict[str, list[dict]]) -> list[dict]: """Nilai per mahasiswa dalam skala POIN (bukan 0-100) — tiap Sub-CPMK ditimbang sesuai kontribusi poinnya ke total 100 (lihat _rincian_bobot). Poin diperoleh = skor/100 × poin maks Sub-CPMK itu. Kolom Total adalah jumlah poin yang SUDAH didapat mahasiswa dari Sub-CPMK yang sudah ternilai — bukan nilai akhir yang direskalakan ke bobot yang ternilai saja, jadi Total ini bisa lebih kecil dari Nilai Akhir bila masih ada Sub-CPMK yang belum dinilai.""" poin_maks = {s: sum(c["poin"] for c in kontribusi) for s, kontribusi in bobot_sub_rincian.items()} out = [] for m in grid: skor = m.get("skor") or {} poin, total = {}, 0.0 for s, maks in poin_maks.items(): v = skor.get(s) if v is None: poin[s] = None else: dapat = v / 100 * maks poin[s] = {"dapat": dapat, "maks": maks} total += dapat out.append({"nim": m["nim"], "nama": m["nama"], "kelas": m.get("kelas"), "poin": poin, "total": total}) return out def _catat_riwayat(riwayat_lama, updated_by: str, aksi: str, komponen: str | None = None) -> list: """Tambah satu entri riwayat pengisian nilai (siapa, kapan, apa) — dipakai di tiap jalur simpan nilai (grid, input cepat, isi per sesi). Disimpan penuh (tak dipangkas) untuk akuntabilitas team teaching.""" riwayat = list(riwayat_lama or []) riwayat.append({ "at": datetime.now(db.WIB).strftime("%Y-%m-%d %H:%M"), "by": updated_by, "aksi": aksi, "komponen": komponen, }) return riwayat _POLA_RIWAYAT_LAMA = re.compile(r"^(.*)\s\(([^()]+)\)$") def _riwayat_terstruktur(sumber) -> list[dict]: """Normalisasi 'sumber' tersimpan jadi list dict seragam, terbaru duluan — entri lama (sebelum fitur log terstruktur ini) berupa string polos 'aksi (Nama Dosen)', nama tetap diambil lewat pola itu; tanpa jam/komponen karena data lama tidak menyimpannya.""" if not sumber: return [] if isinstance(sumber, str): sumber = [sumber] out = [] for r in sumber: if isinstance(r, dict): out.append(r) continue teks = str(r) m = _POLA_RIWAYAT_LAMA.match(teks) if m: out.append({"at": None, "by": m.group(2), "aksi": m.group(1), "komponen": None}) else: out.append({"at": None, "by": "", "aksi": teks, "komponen": None}) return list(reversed(out)) def _ringkas_riwayat(riwayat: list[dict]) -> list[dict]: """Rekap 'siapa mengisi apa berapa kali' per komponen, dari log lengkap — ringkasan cepat sebelum buka Log Lengkap.""" per_komponen: dict[str, dict[str, int]] = {} for r in riwayat: k = r.get("komponen") or "Umum" by = r.get("by") or "?" per_komponen.setdefault(k, {}) per_komponen[k][by] = per_komponen[k].get(by, 0) + 1 return [ {"komponen": k, "per_dosen": sorted( ({"nama": n, "n": c} for n, c in v.items()), key=lambda x: -x["n"])} for k, v in per_komponen.items() ] def _konteks_nilai(request, kode, pesan="", peringatan=None): peta = db.peta_mk(kode) tersimpan = db.ambil_dok("nilai", kode) pengaturan = db.ambil_pengaturan() ambang = float(pengaturan["ambang"]) hasil, akhir, kelompok = None, {}, None if tersimpan: hasil = hitung.rollup_mk(peta, tersimpan["data"], ambang) if tersimpan["data"].get("mode") == "per_mahasiswa": rps = db.ambil_rps(kode) bobot_sub = hitung.bobot_sub_dari_rps(rps["data"] if rps else None) akhir_list = hitung.nilai_akhir_mahasiswa(tersimpan["data"], bobot_sub) akhir = {a["nim"]: a["akhir"] for a in akhir_list} skala = hitung.parse_skala(pengaturan["skala_huruf"]) statistik = hitung.statistik_akhir(akhir_list, skala) kelompok = hitung.kelompok_capaian(akhir_list, statistik) kelas_dosen = _kelas_dosen(request.state.user, kode) terdaftar = db.mahasiswa_mk(kode) grid = _grid_mahasiswa(tersimpan, terdaftar, kelas_dosen) komponen_daftar = _komponen_daftar(kode) rps_cek = db.ambil_rps(kode) komponen_tanpa_sub = _komponen_tanpa_sub(rps_cek["data"]) if rps_cek else [] sesi_semua = (tersimpan["data"].get("sesi") or {}) if tersimpan else {} bobot_komponen, bobot_sub_rincian = _rincian_bobot(kode) # Nilai Akhir dihitung hanya dari Sub-CPMK yang sudah ternilai (lihat # nilai_akhir_mahasiswa) — kalau belum semua Sub-CPMK MK ini ternilai, # angkanya cuma perkiraan sementara dan bisa berubah; ditandai bintang # di template supaya tak disalahsangka sudah final. total_subs = len(peta.get("subs") or []) tidak_lengkap = set() if total_subs: for m in grid: skor = m.get("skor") or {} n_terisi = sum(1 for s in peta["subs"] if skor.get(s["kode"]) is not None) if n_terisi < total_subs: tidak_lengkap.add(m["nim"]) # Sub-CPMK yang skor tersimpannya beda dari hasil hitung ulang sesi — # berarti pernah "dikatrol" manual lewat grid Nilai per Mahasiswa. Nilai # itu akan tertimpa balik kalau sesi/komponen yang mengukur Sub-CPMK # tsb disimpan lagi (lihat aksi="komponen" di simpan_nilai), jadi # ditandai di template supaya dosen tak kaget kalau koreksinya hilang. dikatrol = set() if komponen_daftar: skor_hitung_sesi = hitung.skor_dari_sesi(komponen_daftar, sesi_semua) for m in grid: terhitung = skor_hitung_sesi.get(m["nim"]) or {} tersimpan_skor = m.get("skor") or {} for sub_kode, v_terhitung in terhitung.items(): v_tersimpan = tersimpan_skor.get(sub_kode) if v_tersimpan is not None and abs(v_tersimpan - v_terhitung) > 0.05: dikatrol.add((m["nim"], sub_kode)) riwayat = _riwayat_terstruktur(tersimpan["data"].get("sumber")) if tersimpan else [] return render( request, "form_nilai.html", {"peta": peta, "tersimpan": tersimpan, "hasil": hasil, "ambang": ambang, "akhir": akhir, "tahun": db.tahun_aktif(), "pesan": pesan, "grid": grid, "kelas_dosen": kelas_dosen, "tidak_lengkap": tidak_lengkap, "dikatrol": dikatrol, "komponen_daftar": komponen_daftar, "komponen_tanpa_sub": komponen_tanpa_sub, "tabel_komponen": _tabel_komponen(grid, komponen_daftar, sesi_semua) if komponen_daftar else [], "bobot_komponen": bobot_komponen, "bobot_sub_rincian": bobot_sub_rincian, "tabel_poin": _tabel_poin(grid, bobot_sub_rincian) if bobot_sub_rincian else [], "kelompok": kelompok, "riwayat": riwayat, "riwayat_ringkas": _ringkas_riwayat(riwayat), "peringatan": peringatan or []}, ) @app.get("/nilai/{kode}", response_class=HTMLResponse) def form_nilai(request: Request, kode: str, pesan: str = ""): if not db.peta_mk(kode): 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) return _konteks_nilai(request, kode, pesan) @app.get("/nilai/{kode}/komponen/{i}", response_class=HTMLResponse) 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) 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}?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(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}") 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) for m in grid: m["nilai_awal"] = nilai_awal.get(m["nim"]) 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, "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: 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}?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 nim_diizinkan = _nim_diizinkan(kode, _kelas_dosen(request.state.user, kode)) nilai = {} j = 0 while f"nim_{j}" in form: nim = (form.get(f"nim_{j}") or "").strip() v = (form.get(f"nilai_{j}") or "").strip() if nim and v and (nim_diizinkan is None or nim in nim_diizinkan): try: x = float(v) except ValueError: x = None if x is not None and 0 <= x <= 100: nilai[nim] = 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(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[i] = daftar_sesi komponen_daftar = _komponen_daftar(kode) skor_terhitung = hitung.skor_dari_sesi(komponen_daftar, sesi_semua) nama_map = {nim: m["nama"] for m in db.mahasiswa_mk(kode) for nim in [m["nim"]]} urut = [dict(m, skor=dict(m.get("skor") or {})) for m in lama_data.get("mahasiswa", [])] per_nim = {m["nim"]: m for m in urut if m.get("nim")} for nim, subskor in skor_terhitung.items(): m = per_nim.get(nim) if m is None: m = {"nim": nim, "nama": nama_map.get(nim, ""), "skor": {}} urut.append(m) per_nim[nim] = m m["skor"].update(subskor) riwayat = _catat_riwayat(lama_data.get("sumber"), updated_by, f"Isi Sesi — {label}", komponen["nama"]) data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sesi": sesi_semua, "sumber": riwayat} 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) 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) form = await request.form() aksi = form.get("aksi") updated_by = request.state.user["nama"] 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."]) akun = db.pengguna_by_id(request.state.user["id"]) password = (form.get("password") or "").strip() if not akun or not auth.verifikasi_password(password, akun["pw_hash"]): return _konteks_nilai(request, kode, peringatan=[ "Password salah — nilai TIDAK jadi dihapus."]) lama = db.ambil_dok("nilai", kode) if lama: db.arsipkan_nilai_hapus(kode, db.tahun_aktif(), lama["data"], updated_by) db.hapus_dok("nilai", kode) return _konteks_nilai( request, kode, pesan="Seluruh nilai tersimpan MK ini dihapus (salinan sebelumnya " "tersimpan otomatis sebagai arsip) — 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 = _catat_riwayat(lama_data.get("sumber"), updated_by, "Input Cepat per Komponen") data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sesi": sesi_semua, "sumber": riwayat} 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) mahasiswa = [] i = 0 while f"nim_{i}" in form: nim = (form.get(f"nim_{i}") or "").strip() nama = (form.get(f"nama_{i}") or "").strip() if nim_diizinkan is not None and nim not in nim_diizinkan: i += 1 continue skor = {} for s in peta["subs"]: v = (form.get(f"skor_{i}_{s['kode']}") or "").strip() if not v: continue try: x = float(v) except ValueError: continue if 0 <= x <= 100: skor[s["kode"]] = x if skor and (nim or nama): mahasiswa.append({"nim": nim, "nama": nama, "skor": skor}) i += 1 if not mahasiswa: return _konteks_nilai(request, kode, peringatan=["Tidak ada nilai yang diisi."]) lama = db.ambil_dok("nilai", kode) lama_data = lama["data"] if lama else {} riwayat = _catat_riwayat(lama_data.get("sumber"), updated_by, "Input Langsung (grid Sub-CPMK)") if kelas_dosen is None: # dosen tak dibatasi kelas: tabel yang tampil = seluruh MK, # jadi menyimpan persis isi tabel memang dimaksudkan sebagai pengganti data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa, "sumber": riwayat} 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_nim = {m["nim"]: m for m in urut if m.get("nim")} for m in mahasiswa: ada = per_nim.get(m["nim"]) if ada is None: urut.append(m) per_nim[m["nim"]] = m else: ada["nama"] = m["nama"] or ada.get("nama", "") ada["skor"] = m["skor"] data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sumber": riwayat} if lama_data.get("sesi"): # pertahankan log sesi per-komponen — grid ini hanya koreksi manual # di atasnya, bukan pengganti sumber datanya 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).") return _konteks_nilai(request, kode, peringatan=["Aksi tidak dikenali."]) # ═════════════════════════ TAHAP 2: PORTOFOLIO ═════════════════════════ PORTO_KOSONG = { "dokumen": {"nomor": "", "revisi": "0", "tgl_terbit": "", "kurikulum_tahun": "2025", "dekan": "", "status_dokumen": "Master"}, "realisasi": "", "kendala": [{"kendala": "", "tindak": ""} for _ in range(5)], "simpulan": "", } _BULAN_IND = ("", "Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember") def _tanggal_indonesia(d: datetime) -> str: return f"{d.day} {_BULAN_IND[d.month]} {d.year}" def _nomor_porto_otomatis(kode: str) -> str: """PORTO/INF// — bisa diedit dosen.""" try: no_mk = int(db.kurikulum()["mk"].get(kode, {}).get("no", 0)) except (TypeError, ValueError): no_mk = 0 tahun_awal = db.tahun_aktif().split("/")[0] return f"PORTO/INF/{tahun_awal}/{no_mk:03d}" @app.get("/portofolio/{kode}", response_class=HTMLResponse) def form_porto(request: Request, kode: str, pesan: str = ""): peta = db.peta_mk(kode) if not peta: return RedirectResponse("/", status_code=303) tersimpan = db.ambil_dok("portofolio", kode) if tersimpan: f = tersimpan["data"] else: f = {"dokumen": {**PORTO_KOSONG["dokumen"], "nomor": _nomor_porto_otomatis(kode), "tgl_terbit": _tanggal_indonesia(datetime.now())}, "realisasi": "", "simpulan": "", "kendala": [{"kendala": "", "tindak": ""} for _ in range(5)]} while len(f.get("kendala", [])) < 5: f.setdefault("kendala", []).append({"kendala": "", "tindak": ""}) rps = db.ambil_rps(kode) nilai = db.ambil_dok("nilai", kode) analisis = None if nilai: pengaturan = db.ambil_pengaturan() ambang = float(pengaturan["ambang"]) skala = hitung.parse_skala(pengaturan["skala_huruf"]) rps_data = rps["data"] if rps else None peta_analisis = _terapkan_override_sub(peta, rps_data) nilai_data = nilai["data"] bobot_sub = hitung.bobot_sub_dari_rps(rps_data) rollup = hitung.rollup_mk(peta_analisis, nilai_data, ambang) akhir = (hitung.nilai_akhir_mahasiswa(nilai_data, bobot_sub) if nilai_data.get("mode") == "per_mahasiswa" else []) statistik = hitung.statistik_akhir(akhir, skala) if akhir else None tren = hitung.tren_mk(kode, db.daftar_tahun(), ambang) sub_urut = hitung.sub_terurut(peta_analisis, rollup["sub"]) kesenjangan = hitung.kesenjangan_rencana_realisasi(bobot_sub, rollup["sub"]) berisiko = hitung.mahasiswa_berisiko(nilai_data, akhir, ambang) tren = tren if len(tren) > 1 else [] analisis = { "ambang": ambang, "statistik": statistik, "sub_urut": sub_urut, "kesenjangan": kesenjangan, "berisiko": berisiko, "tren": tren, "kelompok": hitung.kelompok_capaian(akhir, statistik), "simpulan_saran": hitung.saran_simpulan( akhir, statistik, sub_urut, kesenjangan, berisiko, tren, ambang), } return render( request, "form_porto.html", {"peta": peta, "f": f, "tahun": db.tahun_aktif(), "pesan": pesan, "status": tersimpan["status"] if tersimpan else "", "updated_at": tersimpan["updated_at"] if tersimpan else "", "updated_by": tersimpan["updated_by"] if tersimpan else "", "boleh_edit": boleh_mk(request.state.user, kode), "ada_rps": bool(rps), "ada_nilai": bool(nilai), "analisis": analisis}, ) @app.post("/portofolio/{kode}") async def simpan_porto(request: Request, kode: str): if not db.peta_mk(kode): return RedirectResponse("/", status_code=303) if not boleh_mk(request.state.user, kode): return RedirectResponse( f"/portofolio/{kode}?pesan=Anda+bukan+pengampu+MK+ini.", status_code=303) form = await request.form() data = { "dokumen": { k: (form.get(k) or "").strip() for k in ("nomor", "revisi", "tgl_terbit", "kurikulum_tahun", "dekan", "status_dokumen") }, "realisasi": (form.get("realisasi") or "").strip(), "kendala": [ {"kendala": (form.get(f"kendala_{i}") or "").strip(), "tindak": (form.get(f"tindak_{i}") or "").strip()} for i in range(1, 6) ], "simpulan": (form.get("simpulan") or "").strip(), } status = "selesai" if form.get("aksi") == "selesai" else "draft" db.simpan_dok("portofolio", kode, data, status, request.state.user["nama"]) return RedirectResponse(f"/portofolio/{kode}?pesan=Tersimpan+sebagai+{status}.", status_code=303) def _bahan_porto(kode: str) -> dict | None: """Kumpulkan semua bahan dokumen portofolio satu MK.""" peta = db.peta_mk(kode) if not peta: return None porto = db.ambil_dok("portofolio", kode) rps = db.ambil_rps(kode) nilai = db.ambil_dok("nilai", kode) pengaturan = db.ambil_pengaturan() ambang = float(pengaturan["ambang"]) skala = hitung.parse_skala(pengaturan["skala_huruf"]) f = porto["data"] if porto else PORTO_KOSONG rps_data = rps["data"] if rps else None peta = _terapkan_override_sub(peta, rps_data) nilai_data = nilai["data"] if nilai else {"mode": "agregat", "agregat": {}} bobot_sub = hitung.bobot_sub_dari_rps(rps_data) # korelasi sub -> CPL: bobot sub dibagi rata ke CPL yang didukung CPMK-nya cpl_per_cpmk = {c["kode"]: c["cpl"] for c in peta["cpmks"]} korelasi = {} for s in peta["subs"]: cpls = cpl_per_cpmk.get(s["cpmk"], []) b = bobot_sub.get(s["kode"]) if b and cpls: korelasi[s["kode"]] = {cp: b / len(cpls) for cp in cpls} # jumlah minggu per sub & rencana penilaian dari RPS minggu_sub, rencana = {}, [] if rps_data: uraian = {s["kode"]: s for s in peta["subs"]} for m in rps_data.get("minggu", []): sk = m.get("sub", "") if sk in uraian: minggu_sub[sk] = minggu_sub.get(sk, 0) + 1 asesmen = "; ".join(x for x in (m.get("kriteria"), m.get("metode")) if x) if sk in uraian: label = sk elif sk == "__uts__": label = "UTS" elif sk == "__uas__": label = "UAS" else: label = sk rencana.append({"minggu": m.get("ke"), "sub": label, "asesmen": asesmen, "bobot": m.get("bobot", "")}) skor_sub = hitung.skor_sub(nilai_data, ambang) rollup = hitung.rollup_mk(peta, nilai_data, ambang) akhir = (hitung.nilai_akhir_mahasiswa(nilai_data, bobot_sub) if nilai_data.get("mode") == "per_mahasiswa" else []) statistik = hitung.statistik_akhir(akhir, skala) if akhir else None return { "tahun": db.tahun_aktif(), "identitas": {**(rps_data or {}).get("identitas", {}), "nama": peta["nama"], "kode": peta["kode"]}, "dokumen": f.get("dokumen", {}), "cpl": peta["cpls"], "cpmk": peta["cpmks"], "sub": [{"kode": s["kode"], "uraian": s["uraian"], "cpmk": s["cpmk"]} for s in peta["subs"]], "korelasi": korelasi, "bobot_sub": bobot_sub, "minggu_sub": minggu_sub, "rencana": rencana, "realisasi": f.get("realisasi", ""), "nilai": nilai_data, "skor_sub": skor_sub, "rollup": rollup, "akhir": akhir, "statistik": statistik, "skala": skala, "ambang": ambang, "kendala": f.get("kendala", []), "simpulan": f.get("simpulan", ""), } @app.get("/portofolio/{kode}/unduh") def unduh_porto(request: Request, kode: str): # dokumen memuat matriks nilai per mahasiswa — batasi pengampu + kaprodi if not boleh_mk(request.state.user, kode): return RedirectResponse( f"/portofolio/{kode}?pesan=Unduhan+portofolio+memuat+nilai+mahasiswa+-+hanya+pengampu+dan+kaprodi.", status_code=303) bahan = _bahan_porto(kode) if not bahan: return RedirectResponse("/", status_code=303) isi = build_portofolio(bahan) nama = re.sub(r"[^A-Za-z0-9_-]+", "_", f"Portofolio_{kode}_{bahan['identitas']['nama']}") + ".docx" return Response( content=isi, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", headers={"Content-Disposition": f'attachment; filename="{nama}"'}, ) # ═════════════════════════ TAHAP 2: REKAP & METODOLOGI ═════════════════════════ @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"]) nama_mk = {m["kode"]: m["nama"] for m in db.daftar_mk()} k = db.kurikulum() desk_cpmk = {info["kode"]: info["desk"] for info in k["cpmk"].values()} desk_cpl = {info["kode"]: info["desk"] for info in k["cpl"].values()} return render( request, "rekap.html", {"hasil": hasil, "cpl_urut": cpl_urut, "cpmk_urut": cpmk_urut, "nama_mk": nama_mk, "tahun": db.tahun_aktif(), "desk_cpmk": desk_cpmk, "desk_cpl": desk_cpl}, ) @app.get("/pantau", response_class=HTMLResponse) 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} cpl_urut = sorted(hasil["cpl"].values(), key=lambda c: c["kode"]) cpmk_urut = sorted(hasil["cpmk"].values(), key=lambda c: c["kode"]) terukur = [c for c in cpl_urut if c["skor"] is not None] rata_cpl = sum(c["skor"] for c in terukur) / len(terukur) if terukur else None # kelengkapan proses dokumen per tahap (dari status 49 MK) proses = [] for label, tabel in (("RPS", "rps"), ("Nilai", "nilai"), ("Portofolio", "portofolio")): s = db.status_semua(tabel) n_sel = sum(1 for v in s.values() if v["status"] == "selesai") n_draft = sum(1 for v in s.values() if v["status"] == "draft") proses.append({"label": label, "selesai": n_sel, "draft": n_draft, "belum": len(daftar) - n_sel - n_draft}) # MK belum setor nilai + pengampunya — bahan tindak lanjut st_nilai = db.status_semua("nilai") pengampu = db.pengampu_per_mk() belum_nilai = [{"kode": m["kode"], "nama": m["nama"], "pengampu": pengampu.get(m["kode"], [])} for m in daftar if m["kode"] not in st_nilai] # tren skor CPL lintas tahun ajaran (sparkline; hanya bila >1 tahun) daftar_tahun = db.daftar_tahun() tren = {} if len(daftar_tahun) > 1: per_tahun = {t: hitung.rollup_prodi(t) for t in daftar_tahun} for c in cpl_urut: titik = [(t, per_tahun[t]["cpl"][c["kode"]]["skor"]) for t in daftar_tahun] if sum(1 for _, s in titik if s is not None) > 1: tren[c["kode"]] = titik return render( request, "pantau.html", {"hasil": hasil, "cpl_urut": cpl_urut, "cpmk_urut": cpmk_urut, "rata_cpl": rata_cpl, "proses": proses, "n_mk": len(daftar), "belum_nilai": belum_nilai, "tren": tren, "nama_mk": nama_mk, "tahun": db.tahun_aktif()}, ) @app.get("/metodologi", response_class=HTMLResponse) def metodologi(request: Request, pesan: str = ""): pengaturan = db.ambil_pengaturan() skala = hitung.parse_skala(pengaturan["skala_huruf"]) return render( request, "metodologi.html", {"p": pengaturan, "skala": skala, "tahun": db.tahun_aktif(), "boleh_ubah": request.state.user["role"] == "kaprodi", "pesan": pesan}, ) @app.post("/metodologi") async def simpan_metodologi(request: Request): tolak = _bukan_kaprodi(request) if tolak: return tolak form = await request.form() db.simpan_pengaturan({ "tahun_aktif": (form.get("tahun_aktif") or "").strip() or db.PENGATURAN_DEFAULT["tahun_aktif"], "ambang": (form.get("ambang") or "70").strip(), "metode_antar_mk": form.get("metode_antar_mk") or "rata", "skala_huruf": (form.get("skala_huruf") or "").strip() or db.PENGATURAN_DEFAULT["skala_huruf"], }) return RedirectResponse("/metodologi?pesan=Pengaturan+tersimpan.", status_code=303) # ═════════════════════════ TAHAP 3: RIWAYAT, CADANGAN, PDF ═════════════════════════ @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()) return render( request, "riwayat.html", {"daftar_tahun": daftar_tahun, "per_tahun": per_tahun, "kode_cpl": kode_cpl, "tahun": db.tahun_aktif()}, ) @app.get("/cadangan.json") def cadangan_json(request: Request): tolak = _bukan_kaprodi(request) if tolak: return tolak import json as _json isi = _json.dumps(db.dump_semua(), ensure_ascii=False, indent=1) stempel = db.tahun_aktif().replace("/", "-").replace(" ", "_") return Response( content=isi.encode("utf-8"), media_type="application/json", headers={"Content-Disposition": f'attachment; filename="cadangan_rps_porto_{stempel}.json"'}, ) @app.get("/cadangan.db") def cadangan_db(request: Request): tolak = _bukan_kaprodi(request) if tolak: return tolak return FileResponse(db.DB_PATH, media_type="application/octet-stream", filename="app.db") def _docx_ke_pdf(docx_bytes: bytes) -> bytes | None: """Konversi via LibreOffice (soffice) bila tersedia; None bila tidak.""" import shutil, subprocess, tempfile, pathlib as _pl soffice = shutil.which("soffice") or shutil.which("soffice.exe") if not soffice: return None with tempfile.TemporaryDirectory() as tmp: src = _pl.Path(tmp) / "dokumen.docx" src.write_bytes(docx_bytes) try: subprocess.run([soffice, "--headless", "--convert-to", "pdf", "--outdir", tmp, str(src)], check=True, capture_output=True, timeout=120) except (subprocess.SubprocessError, OSError): return None pdf = src.with_suffix(".pdf") return pdf.read_bytes() if pdf.exists() else None @app.get("/rps/{kode}/unduh.pdf") def unduh_rps_pdf(kode: str): r = unduh_rps(kode) if not isinstance(r, Response) or r.media_type is None or "wordprocessingml" not in r.media_type: return r pdf = _docx_ke_pdf(r.body) if pdf is None: return RedirectResponse( f"/rps/{kode}?pesan=Konverter+PDF+(LibreOffice)+tidak+tersedia+di+server+-+unduh+.docx+lalu+Save+as+PDF+di+Word.", status_code=303) return Response(content=pdf, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="RPS_{kode}.pdf"'}) @app.get("/portofolio/{kode}/unduh.pdf") def unduh_porto_pdf(request: Request, kode: str): r = unduh_porto(request, kode) if not isinstance(r, Response) or r.media_type is None or "wordprocessingml" not in r.media_type: return r pdf = _docx_ke_pdf(r.body) if pdf is None: return RedirectResponse( f"/portofolio/{kode}?pesan=Konverter+PDF+(LibreOffice)+tidak+tersedia+di+server+-+unduh+.docx+lalu+Save+as+PDF+di+Word.", status_code=303) return Response(content=pdf, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="Portofolio_{kode}.pdf"'})