e5d45cdcdd
Kasus nyata: RPS bagian G bobotnya sudah lengkap (total 100%), tapi kolom "Sub-CPMK yang Dinilai" tak satu pun dicentang di tiap komponen. Komponen begini lolos validasi total bobot, tapi _komponen_daftar() menyaringnya habis (butuh subs DAN bobot) -- dosen jadi bingung kenapa halaman Nilai sama sekali tak punya section "ISI PER KOMPONEN", padahal RPS-nya sudah diisi. - _komponen_tanpa_sub(): fungsi baru, deteksi komponen dengan bobot > 0 tapi subs kosong - Halaman RPS: peringatan di atas tabel bagian G, sebut nama komponen yang bermasalah - Halaman Nilai: peringatan serupa + link "Buka RPS" -- muncul di titik gejalanya, bukan cuma di sumbernya Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1726 lines
72 KiB
Python
1726 lines
72 KiB
Python
# -*- 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("</", "<\\/")
|
||
)
|
||
|
||
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 _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/<tahun>/<no urut MK di kurikulum> — 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"'})
|