# -*- coding: utf-8 -*- """Aplikasi Penyusunan RPS & Portofolio — Prodi Informatika UNTAN.""" import json import pathlib import re 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 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 _masuk(user_id: int) -> RedirectResponse: token = auth.token_baru() db.buat_sesi(user_id, token) r = RedirectResponse("/", 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 # ── 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()} pilihan = [k for k in form.getlist("mk") if k in kode_valid] 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}, {"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}, ] 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 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": "", } @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 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": ""} 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 [], "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()] 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, } aksi = form.get("aksi") or "draft" status = "selesai" if aksi == "selesai" else "draft" db.simpan_rps(kode, data, status, request.state.user["nama"]) return RedirectResponse(f"/rps/{kode}?pesan=Tersimpan+sebagai+{status}.", 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"] # 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}"'}, ) # ═════════════════════════ 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) skor_map, nama_map = {}, {} if tersimpan and tersimpan["data"].get("mode") == "per_mahasiswa": for m in tersimpan["data"]["mahasiswa"]: skor_map[m.get("npm", "")] = m.get("skor") or {} nama_map[m.get("npm", "")] = m.get("nama", "") grid, ada = [], set() for m in terdaftar: grid.append({"npm": m["npm"], "nama": m["nama"], "kelas": m.get("kelas") or "", "skor": skor_map.get(m["npm"], {})}) ada.add(m["npm"]) for npm, sk in skor_map.items(): if npm not in ada: grid.append({"npm": npm, "nama": nama_map.get(npm, ""), "kelas": "", "skor": sk}) 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, "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): 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}/template.xlsx") def template_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}", 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"'}, ) @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"] kode_sub = {s["kode"] for s in peta["subs"]} if aksi == "hapus": 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 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() 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 (npm or nama): mahasiswa.append({"npm": npm, "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) riwayat = (lama["data"].get("sumber") if lama else None) or [] if isinstance(riwayat, str): riwayat = [riwayat] riwayat.append(f"input langsung ({updated_by})") data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa, "sumber": riwayat[-8:]} 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).", ) # ═════════════════════════ 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": "", } @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": dict(PORTO_KOSONG["dokumen"]), "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) 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)}, ) @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 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): 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()} return render( request, "rekap.html", {"hasil": hasil, "cpl_urut": cpl_urut, "cpmk_urut": cpmk_urut, "nama_mk": nama_mk, "tahun": db.tahun_aktif()}, ) @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.""" 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.""" 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"'})