Files
OBE-Mapping/app/main.py
T
Power BI Dev 5f615aa7e8 Rapikan tampilan nilai, ganti NPM->NIM, fitur ganti password, kunci RPS view-only
- Tampilan "Isi Per Komponen Penilaian" diganti dari tombol bertumpuk teks
  jadi tabel (Komponen/Sub-CPMK/Bobot/Status/Aksi) dengan status berupa
  stempel, lebih mudah dipindai.
- Filter pencarian MK (ketik kode/nama) di kotak penugasan dosen /admin.
- Istilah NPM diseragamkan jadi NIM di seluruh aplikasi (label, header,
  template Excel, dokumen portofolio), termasuk migrasi kolom database
  mahasiswa.npm -> nim yang aman (ALTER TABLE RENAME COLUMN, otomatis saat
  startup, tanpa kehilangan data; parser Excel tetap terima file lama
  berheader NPM).
- Fitur ganti password mandiri (/akun): verifikasi password lama, sesi lain
  otomatis keluar setelah berhasil, sesi yang dipakai tetap aktif.
- RPS: dosen RPS+MK kini bisa menyesuaikan redaksi kalimat Sub-CPMK (bagian
  D) untuk MK yang diampu -- kode & kaitan CPMK/CPL tetap dari kurikulum,
  hanya teksnya yang bisa disesuaikan. Penyesuaian otomatis konsisten di
  dropdown mingguan (F), checklist komponen (G), serta unduhan RPS &
  portofolio, tanpa menyentuh data kurikulum induk atau halaman nilai/
  dashboard prodi.
- Form RPS dibungkus <fieldset disabled> untuk dosen tanpa hak edit --
  seluruh field benar-benar terkunci (bukan cuma tombol simpan hilang).
2026-07-22 08:37:54 +07:00

1237 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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
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("</", "<\\/")
)
db.init_db()
db.kurikulum()
# ── autentikasi: semua halaman wajib login ───────────────────────────────────
PUBLIK = {"/login", "/setup", "/favicon.ico"}
@app.middleware("http")
async def wajib_login(request: Request, call_next):
path = request.url.path
if path in PUBLIK or path.startswith("/static/"):
return await call_next(request)
user = db.user_dari_sesi(request.cookies.get("sesi", ""))
if user is None:
tujuan = "/setup" if not db.ada_pengguna() else "/login"
return RedirectResponse(tujuan, status_code=303)
request.state.user = user
return await call_next(request)
def render(request: Request, nama: str, ctx: dict):
"""TemplateResponse + user login otomatis tersedia di semua template."""
ctx.setdefault("user", getattr(request.state, "user", None))
return templates.TemplateResponse(request, nama, ctx)
def boleh_rps(user: dict, kode: str) -> 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 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 17")
elif w == 16:
m.update(sub="__uas__", kriteria="Ujian Akhir Semester (UAS)",
metode="Ujian tulis [2 x 50 menit]", materi="Materi minggu 915")
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": {},
}
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
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": ""}
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 [],
"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"
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"]
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 17 (diujikan pada UTS)"
if kode_sub == "__uas__":
return "Sub-CPMK minggu 915 (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 _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
def _komponen_daftar(kode: str) -> list[dict]:
"""Komponen penilaian RPS yang sudah dikaitkan ke Sub-CPMK (bagian G) —
dipakai untuk tombol isi-cepat per komponen di halaman nilai. 'ulang'
menandai komponen yang boleh diisi berkali-kali sepanjang semester (Kuis,
Tugas, Presentasi, Tes Lisan) — rata-rata otomatis antar sesi — versus
komponen sekali-jalan (UTS/UAS)."""
rps = db.ambil_rps(kode)
if not rps:
return []
tersimpan = db.ambil_dok("nilai", kode)
sesi_semua = ((tersimpan["data"].get("sesi") or {}) if tersimpan else {})
out = []
for idx, k in enumerate(_normalisasi_komponen(rps["data"].get("komponen", []))):
if not k["subs"]:
continue
out.append({"i": idx, "nama": k["nama"], "bobot": k["bobot"],
"subs": k["subs"], "n_subs": len(k["subs"]),
"ulang": KOMPONEN_DEFAULT[idx]["ulang"],
"n_sesi": len(sesi_semua.get(str(idx), []))})
return out
def _ambil_komponen(kode: str, i: int) -> dict | None:
rps = db.ambil_rps(kode)
if not rps:
return None
komponen_list = _normalisasi_komponen(rps["data"].get("komponen", []))
if i < 0 or i >= len(komponen_list) or not komponen_list[i]["subs"]:
return None
k = dict(komponen_list[i])
k["ulang"] = KOMPONEN_DEFAULT[i]["ulang"]
return k
def _konteks_nilai(request, kode, pesan="", peringatan=None):
peta = db.peta_mk(kode)
tersimpan = db.ambil_dok("nilai", kode)
pengaturan = db.ambil_pengaturan()
ambang = float(pengaturan["ambang"])
hasil, akhir = None, {}
if tersimpan:
hasil = hitung.rollup_mk(peta, tersimpan["data"], ambang)
if tersimpan["data"].get("mode") == "per_mahasiswa":
rps = db.ambil_rps(kode)
bobot_sub = hitung.bobot_sub_dari_rps(rps["data"] if rps else None)
akhir = {a["nim"]: a["akhir"]
for a in hitung.nilai_akhir_mahasiswa(tersimpan["data"], bobot_sub)}
kelas_dosen = _kelas_dosen(request.state.user, kode)
terdaftar = db.mahasiswa_mk(kode)
grid = _grid_mahasiswa(tersimpan, terdaftar, kelas_dosen)
return render(
request, "form_nilai.html",
{"peta": peta, "tersimpan": tersimpan, "hasil": hasil, "ambang": ambang,
"akhir": akhir, "tahun": db.tahun_aktif(), "pesan": pesan,
"grid": grid, "kelas_dosen": kelas_dosen,
"komponen_daftar": _komponen_daftar(kode),
"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: int, sesi_id: int = -1, pesan: str = ""):
peta = db.peta_mk(kode)
if not peta:
return RedirectResponse("/", status_code=303)
if not boleh_mk(request.state.user, kode):
return RedirectResponse(
f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.",
status_code=303)
komponen = _ambil_komponen(kode, i)
if not komponen:
return RedirectResponse(
f"/nilai/{kode}?pesan=Komponen+belum+dikaitkan+ke+Sub-CPMK+di+RPS+bagian+G.",
status_code=303)
tersimpan = db.ambil_dok("nilai", kode)
daftar_sesi = ((tersimpan["data"].get("sesi") or {}) if tersimpan else {}).get(str(i), [])
edit = None
if not komponen["ulang"] and daftar_sesi:
# komponen sekali-jalan (UTS/UAS) sudah punya sesi — selalu edit sesi itu
edit, edit_id = daftar_sesi[0], 0
elif 0 <= sesi_id < len(daftar_sesi):
edit, edit_id = daftar_sesi[sesi_id], sesi_id
else:
edit_id = len(daftar_sesi) # sesi baru
nilai_awal = (edit or {}).get("nilai", {})
label_awal = (edit or {}).get("label") or (
komponen["nama"] if not komponen["ulang"]
else f"{komponen['nama']} {len(daftar_sesi) + 1}")
kelas_dosen = _kelas_dosen(request.state.user, kode)
grid = _grid_mahasiswa(tersimpan, db.mahasiswa_mk(kode), kelas_dosen)
for m in grid:
m["nilai_awal"] = nilai_awal.get(m["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,
"tahun": db.tahun_aktif(), "pesan": pesan},
)
@app.post("/nilai/{kode}/komponen/{i}")
async def simpan_nilai_komponen(request: Request, kode: str, i: int):
peta = db.peta_mk(kode)
if not peta:
return RedirectResponse("/", status_code=303)
if not boleh_mk(request.state.user, kode):
return RedirectResponse(
f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.",
status_code=303)
komponen = _ambil_komponen(kode, i)
if not komponen:
return RedirectResponse(f"/nilai/{kode}", status_code=303)
form = await request.form()
updated_by = request.state.user["nama"]
label = (form.get("label") or komponen["nama"]).strip()
try:
edit_id = int(form.get("edit_id") or -1)
except ValueError:
edit_id = -1
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(str(i), []))
entri = {"label": label, "at": datetime.now(db.WIB).strftime("%Y-%m-%d %H:%M"),
"by": updated_by, "nilai": nilai}
if not komponen["ulang"] and daftar_sesi:
daftar_sesi[0] = entri # sekali-jalan: selalu menimpa sesi yang sama
elif 0 <= edit_id < len(daftar_sesi):
daftar_sesi[edit_id] = entri # mengedit sesi tertentu
else:
daftar_sesi.append(entri) # sesi baru
sesi_semua[str(i)] = daftar_sesi
komponen_daftar = _komponen_daftar(kode)
skor_terhitung = hitung.skor_dari_sesi(komponen_daftar, sesi_semua)
nama_map = {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 = lama_data.get("sumber") or []
if isinstance(riwayat, str):
riwayat = [riwayat]
riwayat.append(f"komponen {komponen['nama']}{label} ({updated_by})")
data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sesi": sesi_semua,
"sumber": riwayat[-8:]}
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
pesan = f"Nilai+{label.replace(' ', '+')}+tersimpan+untuk+{len(nilai)}+mahasiswa."
return RedirectResponse(f"/nilai/{kode}?pesan={pesan}", status_code=303)
@app.post("/nilai/{kode}")
async def simpan_nilai(request: Request, kode: str):
peta = db.peta_mk(kode)
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."])
db.hapus_dok("nilai", kode)
return _konteks_nilai(
request, kode,
pesan="Seluruh nilai tersimpan MK ini dihapus — silakan mulai dari awal.")
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 = lama_data.get("sumber") or []
if isinstance(riwayat, str):
riwayat = [riwayat]
riwayat.append(f"input langsung ({updated_by})")
if kelas_dosen is None:
# dosen tak dibatasi kelas: tabel yang tampil = seluruh MK,
# jadi menyimpan persis isi tabel memang dimaksudkan sebagai pengganti
data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa, "sumber": riwayat[-8:]}
else:
# dosen dibatasi kelas: tabel yang tampil cuma kelasnya — gabungkan
# ke data lama, jangan sampai menghapus nilai kelas lain
urut = [dict(m, skor=dict(m.get("skor") or {}))
for m in lama_data.get("mahasiswa", [])]
per_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[-8:]}
if lama_data.get("sesi"):
# pertahankan log sesi per-komponen — grid ini hanya koreksi manual
# di atasnya, bukan pengganti sumber datanya
data["sesi"] = lama_data["sesi"]
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
return _konteks_nilai(
request, kode,
pesan=f"Nilai {len(mahasiswa)} mahasiswa tersimpan (input langsung).")
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": "",
}
@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
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()}
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."""
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"'})