Files
OBE-Mapping/app/main.py
T
Power BI Dev a45b08b00e Tambah peran Dosen MK/RPS+MK, komponen penilaian baku, dan bantu isi RPS
- Peran dosen dipecah jadi dosen_mk (nilai & portofolio saja, RPS baca-only)
  dan dosen_rps_mk (bisa edit RPS juga); migrasi otomatis dari skema lama.
- Rekap Bobot Penilaian RPS pakai 6 komponen teknik asesmen baku prodi;
  total wajib tepat 100%, komponen individual boleh dikosongkan.
- Dropdown & daftar Sub-CPMK tak lagi terpotong teksnya.
- Bagian F (Rencana Mingguan): tombol "Isi Otomatis" per baris (termasuk
  usulan Bobot) yang muncul setelah Sub-CPMK dipilih, plus panel contoh
  teks bantu per kolom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 08:45:56 +07:00

1046 lines
42 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 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 _masuk(user_id: int) -> RedirectResponse:
token = auth.token_baru()
db.buat_sesi(user_id, token)
r = RedirectResponse("/", status_code=303)
r.set_cookie("sesi", token, httponly=True, samesite="lax",
max_age=60 * 60 * 24 * 30)
return r
@app.get("/setup", response_class=HTMLResponse)
def setup_form(request: Request):
if db.ada_pengguna():
return RedirectResponse("/login", status_code=303)
return render(request, "login.html",
{"mode": "setup", "pesan": "", "tahun": db.tahun_aktif()})
def _rapikan_nip(mentah: str) -> str:
"""NIP sebagai username: buang spasi/titik agar seragam."""
return "".join(ch for ch in (mentah or "") if ch.isalnum())
@app.post("/setup")
async def setup_simpan(request: Request):
if db.ada_pengguna():
return RedirectResponse("/login", status_code=303)
form = await request.form()
nip = _rapikan_nip(form.get("nip") or "")
nama = (form.get("nama") or "").strip()
if not nip or not nama:
return render(request, "login.html",
{"mode": "setup", "tahun": db.tahun_aktif(),
"pesan": "Nama dan NIP wajib diisi."})
# password awal = NIP (kesepakatan prodi: kredensial cukup NIP)
uid = db.buat_pengguna(nip, nama, "kaprodi", auth.hash_password(nip))
return _masuk(uid)
@app.get("/login", response_class=HTMLResponse)
def login_form(request: Request):
if not db.ada_pengguna():
return RedirectResponse("/setup", status_code=303)
return render(request, "login.html",
{"mode": "login", "pesan": "", "tahun": db.tahun_aktif()})
@app.post("/login")
async def login_proses(request: Request):
form = await request.form()
nip = _rapikan_nip(form.get("nip") or "")
pw = form.get("password") or ""
p = db.pengguna_by_username(nip)
if not p or not auth.verifikasi_password(pw, p["pw_hash"]):
return render(request, "login.html",
{"mode": "login", "tahun": db.tahun_aktif(),
"pesan": "NIP atau password salah."})
return _masuk(p["id"])
@app.get("/keluar")
def keluar(request: Request):
db.hapus_sesi(request.cookies.get("sesi", ""))
r = RedirectResponse("/login", status_code=303)
r.delete_cookie("sesi")
return r
# ── admin kaprodi: akun, penugasan MK, daftar mahasiswa ─────────────────────
def _bukan_kaprodi(request: Request):
if request.state.user["role"] != "kaprodi":
return RedirectResponse("/", status_code=303)
return None
@app.get("/admin", response_class=HTMLResponse)
def admin(request: Request, pesan: str = ""):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
mhs = db.ringkasan_mahasiswa()
return render(request, "admin.html",
{"pengguna": db.daftar_pengguna(), "daftar": db.daftar_mk(),
"mhs": mhs, "n_mhs_mk": len(mhs),
"tahun": db.tahun_aktif(), "pesan": pesan})
@app.post("/admin/pengguna")
async def admin_buat_pengguna(request: Request):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
form = await request.form()
nip = _rapikan_nip(form.get("nip") or "")
nama = (form.get("nama") or "").strip()
role_in = form.get("role")
role = role_in if role_in in ("kaprodi", "dosen_mk", "dosen_rps_mk") else "dosen_mk"
if not nip or not nama:
return RedirectResponse(
"/admin?pesan=Nama+dan+NIP+wajib+diisi.", status_code=303)
uid = db.buat_pengguna(nip, nama, role, auth.hash_password(nip))
if uid is None:
return RedirectResponse(
f"/admin?pesan=NIP+{nip}+sudah+terdaftar.", status_code=303)
return RedirectResponse(
f"/admin?pesan=Akun+{nama.split(',')[0].replace(' ', '+')}+({role})+dibuat+-+login+dengan+NIP+{nip},+password+=+NIP.",
status_code=303)
@app.post("/admin/pengguna/{uid}/hapus")
async def admin_hapus_pengguna(request: Request, uid: int):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
if uid == request.state.user["id"]:
return RedirectResponse(
"/admin?pesan=Tidak+bisa+menghapus+akun+sendiri.", status_code=303)
db.hapus_pengguna(uid)
return RedirectResponse("/admin?pesan=Akun+dihapus.", status_code=303)
@app.post("/admin/pengguna/{uid}/reset")
async def admin_reset_password(request: Request, uid: int):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
p = db.pengguna_by_id(uid)
if not p:
return RedirectResponse("/admin?pesan=Akun+tidak+ditemukan.", status_code=303)
db.ubah_password(uid, auth.hash_password(p["username"]))
return RedirectResponse(
"/admin?pesan=Password+dikembalikan+ke+NIP+-+sesi+lama+pengguna+tsb+ditutup.",
status_code=303)
@app.post("/admin/pengguna/{uid}/tugas")
async def admin_penugasan(request: Request, uid: int):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
form = await request.form()
kode_valid = {m["kode"] for m in db.daftar_mk()}
pilihan = [k for k in form.getlist("mk") if k in kode_valid]
db.set_penugasan(uid, pilihan)
p = db.pengguna_by_id(uid)
return RedirectResponse(
f"/admin?pesan={len(pilihan)}+MK+ditugaskan+ke+{p['username'] if p else uid}.",
status_code=303)
@app.get("/admin/mahasiswa/template.xlsx")
def admin_template_mahasiswa(request: Request):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
isi = nilai_xlsx.template_mahasiswa(db.daftar_mk(), db.tahun_aktif())
return Response(
content=isi,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition":
'attachment; filename="Template_Daftar_Mahasiswa.xlsx"'},
)
@app.post("/admin/mahasiswa")
async def admin_impor_mahasiswa(request: Request):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
form = await request.form()
berkas = form.get("berkas")
if berkas is None or not getattr(berkas, "filename", ""):
return RedirectResponse(
"/admin?pesan=Pilih+file+.xlsx+terlebih+dahulu.", status_code=303)
isi = await berkas.read()
kode_valid = {m["kode"] for m in db.daftar_mk()}
try:
baris, peringatan = nilai_xlsx.parse_mahasiswa(isi, kode_valid)
except Exception:
return RedirectResponse(
"/admin?pesan=File+tidak+terbaca+sebagai+.xlsx.", status_code=303)
if not baris:
pesan = "+".join((peringatan or ["Tidak ada baris yang terbaca."])[0].split())
return RedirectResponse(f"/admin?pesan={pesan}", status_code=303)
db.ganti_mahasiswa(baris)
n_mk = len({b["mk_kode"] for b in baris})
pesan = f"{len(baris)}+baris+kontrak+({n_mk}+MK)+diimpor+-+menggantikan+daftar+lama+tahun+ini."
if peringatan:
pesan += "+Catatan:+" + "+".join(" ".join(peringatan[:3]).split())
return RedirectResponse(f"/admin?pesan={pesan}", status_code=303)
# komponen penilaian tetap — teknik asesmen baku prodi; bobot boleh 0 (opsional,
# tak dipakai MK ybs) tapi total seluruh komponen tak boleh melebihi 100%.
KOMPONEN_DEFAULT = [
{"nama": "Partisipasi (Kuis)", "subs": [], "bobot": 10},
{"nama": "Observasi (Praktik/Tugas)", "subs": [], "bobot": 20},
{"nama": "Unjuk Kerja (Presentasi)", "subs": [], "bobot": 10},
{"nama": "Tes Tulis (UTS)", "subs": [], "bobot": 25},
{"nama": "Tes Tulis (UAS)", "subs": [], "bobot": 25},
{"nama": "Tes Lisan (Tugas Kelompok)", "subs": [], "bobot": 10},
]
def _normalisasi_komponen(tersimpan: list[dict]) -> list[dict]:
"""Selaraskan komponen tersimpan ke struktur baku KOMPONEN_DEFAULT (cocokkan
berdasar nama; RPS lama dengan nama komponen berbeda kehilangan isian itu)."""
by_nama = {k.get("nama"): k for k in tersimpan}
out = []
for default in KOMPONEN_DEFAULT:
ada = by_nama.get(default["nama"])
out.append({
"nama": default["nama"],
"subs": ada.get("subs", []) if ada else [],
"bobot": ada.get("bobot", "") if ada else "",
})
return out
def minggu_default() -> list[dict]:
rows = []
for w in range(1, 17):
m = {"ke": w, "sub": "", "indikator": "", "kriteria": "", "metode": "",
"daring": "", "kegiatan": "", "materi": "", "bobot": ""}
if w == 8:
m.update(sub="__uts__", kriteria="Ujian Tengah Semester (UTS)",
metode="Ujian tulis [2 x 50 menit]", materi="Materi minggu 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": "",
}
@app.get("/", response_class=HTMLResponse)
def beranda(request: Request, semua: int = 0):
daftar = db.daftar_mk()
n_total = len(daftar)
mk_saya = db.mk_diampu(request.state.user["id"])
# dosen: bawaan hanya MK yang diampu; ?semua=1 menampilkan seluruh MK
hanya_saya = (request.state.user["role"] != "kaprodi"
and bool(mk_saya) and not semua)
if hanya_saya:
daftar = [m for m in daftar if m["kode"] in mk_saya]
status = db.status_semua("rps")
st_porto = db.status_semua("portofolio")
st_nilai = db.status_semua("nilai")
n_selesai = sum(1 for s in status.values() if s["status"] == "selesai")
n_draft = sum(1 for s in status.values() if s["status"] == "draft")
return render(
request, "beranda.html",
{"daftar": daftar, "status": status, "st_porto": st_porto,
"st_nilai": st_nilai, "n_selesai": n_selesai,
"n_draft": n_draft, "tahun": db.tahun_aktif(),
"pengampu": db.pengampu_per_mk(),
"smt": {k: v.get("semester") for k, v in db.sks_mk().items()},
"mk_saya": mk_saya, "hanya_saya": hanya_saya, "n_total": n_total},
)
@app.get("/dashboard")
def dashboard():
"""Dashboard Pemetaan CPMK (buatan user; self-contained, data ter-embed)."""
return FileResponse(str(BASE / "static" / "dashboard.html"), media_type="text/html")
@app.get("/rps/{kode}", response_class=HTMLResponse)
def form_rps(request: Request, kode: str, pesan: str = ""):
peta = db.peta_mk(kode)
if not peta:
return RedirectResponse("/", status_code=303)
tersimpan = db.ambil_rps(kode)
if tersimpan:
f = tersimpan["data"]
f.setdefault("referensi_teks", "\n".join(f.get("referensi", [])))
f["komponen"] = _normalisasi_komponen(f.get("komponen", []))
ctx_status = tersimpan
else:
f = form_kosong()
# prefill SKS & semester dari data kurikulum (sheet '14. Susunan MK')
info = db.sks_mk().get(kode)
if info:
f["identitas"]["sks"] = f"{info['sks']:g}"
if info.get("semester"):
f["identitas"]["semester"] = str(info["semester"])
ctx_status = {"status": "", "updated_at": "", "updated_by": ""}
return render(
request, "form_rps.html",
{"peta": peta, "f": f, "status": ctx_status["status"],
"updated_at": ctx_status["updated_at"], "updated_by": ctx_status["updated_by"],
"tahun": db.tahun_aktif(), "pesan": pesan,
"boleh_edit": boleh_rps(request.state.user, kode),
"tahun_lain": db.tahun_rps_lain(kode) if not tersimpan else [],
"sub_uraian": {s["kode"]: s["uraian"] for s in peta["subs"]}},
)
@app.post("/rps/{kode}/salin")
async def salin_rps(request: Request, kode: str):
"""Duplikasi RPS dari tahun ajaran lain ke tahun aktif (status: draft)."""
if not boleh_rps(request.state.user, kode):
return RedirectResponse(
f"/rps/{kode}?pesan=Anda+tidak+memiliki+hak+edit+RPS+untuk+MK+ini.",
status_code=303)
form = await request.form()
dari = form.get("dari") or ""
sumber = db.ambil_rps(kode, tahun=dari) if dari else None
if not sumber:
return RedirectResponse(f"/rps/{kode}?pesan=RPS+sumber+tidak+ditemukan.",
status_code=303)
if db.ambil_rps(kode):
return RedirectResponse(
f"/rps/{kode}?pesan=RPS+tahun+aktif+sudah+ada+-+tidak+ditimpa.",
status_code=303)
db.simpan_rps(kode, sumber["data"], "draft", request.state.user["nama"])
return RedirectResponse(
f"/rps/{kode}?pesan=RPS+disalin+dari+{dari.replace(' ', '+')}+sebagai+draft.",
status_code=303)
@app.post("/rps/{kode}")
async def simpan_rps(request: Request, kode: str):
peta = db.peta_mk(kode)
if not peta:
return RedirectResponse("/", status_code=303)
if not boleh_rps(request.state.user, kode):
return RedirectResponse(
f"/rps/{kode}?pesan=Anda+tidak+memiliki+hak+edit+RPS+untuk+MK+ini.",
status_code=303)
form = await request.form()
identitas = {
k: (form.get(k) or "").strip()
for k in ("sks", "sks_teori", "sks_praktikum", "semester", "prasyarat",
"pengampu", "koordinator", "kaprodi", "rumpun",
"tgl_penyusunan", "pengembang", "kode_dokumen")
}
minggu = []
for w in range(1, 17):
minggu.append({
"ke": w,
"sub": (form.get(f"m{w}_sub") or "").strip(),
"indikator": (form.get(f"m{w}_indikator") or "").strip(),
"kriteria": (form.get(f"m{w}_kriteria") or "").strip(),
"metode": (form.get(f"m{w}_metode") or "").strip(),
"daring": (form.get(f"m{w}_daring") or "").strip(),
"kegiatan": (form.get(f"m{w}_kegiatan") or "").strip(),
"materi": (form.get(f"m{w}_materi") or "").strip(),
"bobot": (form.get(f"m{w}_bobot") or "").strip(),
})
komponen = []
for i, default in enumerate(KOMPONEN_DEFAULT, 1):
komponen.append({
"nama": default["nama"],
"subs": [s for s in form.getlist(f"k{i}_subs")],
"bobot": (form.get(f"k{i}_bobot") or "").strip(),
})
referensi_teks = (form.get("referensi") or "").strip()
referensi = [r.strip() for r in referensi_teks.splitlines() if r.strip()]
pendukung_teks = (form.get("referensi_pendukung") or "").strip()
pendukung = [r.strip() for r in pendukung_teks.splitlines() if r.strip()]
data = {
"identitas": identitas,
"deskripsi": (form.get("deskripsi") or "").strip(),
"bahan_kajian": (form.get("bahan_kajian") or "").strip(),
"media": (form.get("media") or "").strip(),
"minggu": minggu,
"komponen": komponen,
"referensi": referensi,
"referensi_teks": referensi_teks,
"referensi_pendukung": pendukung,
"referensi_pendukung_teks": pendukung_teks,
}
aksi = form.get("aksi") or "draft"
status = "selesai" if aksi == "selesai" else "draft"
db.simpan_rps(kode, data, status, request.state.user["nama"])
return RedirectResponse(f"/rps/{kode}?pesan=Tersimpan+sebagai+{status}.", status_code=303)
@app.get("/rps/{kode}/unduh")
def unduh_rps(kode: str):
peta = db.peta_mk(kode)
tersimpan = db.ambil_rps(kode)
if not peta or not tersimpan:
return RedirectResponse(f"/rps/{kode}", status_code=303)
f = tersimpan["data"]
# resolusi kode Sub-CPMK → teks uraian untuk dokumen
uraian = {s["kode"]: s["uraian"] for s in peta["subs"]}
def sub_teks(kode_sub: str) -> str:
if kode_sub == "__uts__":
return "Sub-CPMK minggu 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 _konteks_nilai(request, kode, pesan="", peringatan=None):
peta = db.peta_mk(kode)
tersimpan = db.ambil_dok("nilai", kode)
pengaturan = db.ambil_pengaturan()
ambang = float(pengaturan["ambang"])
hasil, akhir = None, {}
if tersimpan:
hasil = hitung.rollup_mk(peta, tersimpan["data"], ambang)
if tersimpan["data"].get("mode") == "per_mahasiswa":
rps = db.ambil_rps(kode)
bobot_sub = hitung.bobot_sub_dari_rps(rps["data"] if rps else None)
akhir = {a["npm"]: a["akhir"]
for a in hitung.nilai_akhir_mahasiswa(tersimpan["data"], bobot_sub)}
# grid input langsung: gabungan daftar impor kaprodi + nilai tersimpan
terdaftar = db.mahasiswa_mk(kode)
skor_map, nama_map = {}, {}
if tersimpan and tersimpan["data"].get("mode") == "per_mahasiswa":
for m in tersimpan["data"]["mahasiswa"]:
skor_map[m.get("npm", "")] = m.get("skor") or {}
nama_map[m.get("npm", "")] = m.get("nama", "")
grid, ada = [], set()
for m in terdaftar:
grid.append({"npm": m["npm"], "nama": m["nama"], "kelas": m.get("kelas") or "",
"skor": skor_map.get(m["npm"], {})})
ada.add(m["npm"])
for npm, sk in skor_map.items():
if npm not in ada:
grid.append({"npm": npm, "nama": nama_map.get(npm, ""), "kelas": "",
"skor": sk})
return render(
request, "form_nilai.html",
{"peta": peta, "tersimpan": tersimpan, "hasil": hasil, "ambang": ambang,
"akhir": akhir, "tahun": db.tahun_aktif(), "pesan": pesan,
"terdaftar": terdaftar, "grid": grid,
"peringatan": peringatan or []},
)
def _gabung_nilai(lama: dict, baru: list[dict], sumber: str, oleh: str) -> dict:
"""Gabungkan unggahan ke data per-mahasiswa tersimpan (team teaching):
sel terisi memperbarui, sel kosong tidak menghapus nilai dosen lain,
mahasiswa yang tidak ada di file dibiarkan utuh."""
urut = [dict(m, skor=dict(m.get("skor") or {}))
for m in lama.get("mahasiswa", [])]
per_npm = {m["npm"]: m for m in urut if m.get("npm")}
for m in baru:
ada = per_npm.get(m.get("npm"))
if ada is None:
urut.append(m)
if m.get("npm"):
per_npm[m["npm"]] = m
else:
if m.get("nama"):
ada["nama"] = m["nama"]
ada["skor"].update(m.get("skor") or {})
riwayat = lama.get("sumber") or []
if isinstance(riwayat, str):
riwayat = [riwayat]
riwayat.append(f"{sumber} ({oleh})")
return {"mode": "per_mahasiswa", "mahasiswa": urut, "sumber": riwayat[-8:]}
@app.get("/nilai/{kode}", response_class=HTMLResponse)
def form_nilai(request: Request, kode: str, pesan: str = ""):
if not db.peta_mk(kode):
return RedirectResponse("/", status_code=303)
if not boleh_mk(request.state.user, kode):
return RedirectResponse(
f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.",
status_code=303)
return _konteks_nilai(request, kode, pesan)
@app.get("/nilai/{kode}/template.xlsx")
def template_nilai(request: Request, kode: str):
peta = db.peta_mk(kode)
if not peta:
return RedirectResponse("/", status_code=303)
if not boleh_mk(request.state.user, kode):
return RedirectResponse(f"/rps/{kode}", status_code=303)
isi = nilai_xlsx.buat_template(peta, db.tahun_aktif(), db.mahasiswa_mk(kode))
return Response(
content=isi,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition":
f'attachment; filename="Template_Nilai_{kode}.xlsx"'},
)
@app.post("/nilai/{kode}")
async def simpan_nilai(request: Request, kode: str):
peta = db.peta_mk(kode)
if not peta:
return RedirectResponse("/", status_code=303)
if not boleh_mk(request.state.user, kode):
return RedirectResponse(
f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.",
status_code=303)
form = await request.form()
aksi = form.get("aksi")
updated_by = request.state.user["nama"]
kode_sub = {s["kode"] for s in peta["subs"]}
if aksi == "hapus":
db.hapus_dok("nilai", kode)
return _konteks_nilai(
request, kode,
pesan="Seluruh nilai tersimpan MK ini dihapus — silakan mulai dari awal.")
if aksi == "grid":
# input langsung: menyimpan persis isi tabel yang tampil
mahasiswa = []
i = 0
while f"npm_{i}" in form:
npm = (form.get(f"npm_{i}") or "").strip()
nama = (form.get(f"nama_{i}") or "").strip()
skor = {}
for s in peta["subs"]:
v = (form.get(f"skor_{i}_{s['kode']}") or "").strip()
if not v:
continue
try:
x = float(v)
except ValueError:
continue
if 0 <= x <= 100:
skor[s["kode"]] = x
if skor and (npm or nama):
mahasiswa.append({"npm": npm, "nama": nama, "skor": skor})
i += 1
if not mahasiswa:
return _konteks_nilai(request, kode,
peringatan=["Tidak ada nilai yang diisi."])
lama = db.ambil_dok("nilai", kode)
riwayat = (lama["data"].get("sumber") if lama else None) or []
if isinstance(riwayat, str):
riwayat = [riwayat]
riwayat.append(f"input langsung ({updated_by})")
data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa,
"sumber": riwayat[-8:]}
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
return _konteks_nilai(
request, kode,
pesan=f"Nilai {len(mahasiswa)} mahasiswa tersimpan (input langsung).")
if aksi == "unggah":
berkas = form.get("berkas")
if berkas is None or not getattr(berkas, "filename", ""):
return _konteks_nilai(request, kode,
peringatan=["Pilih file .xlsx terlebih dahulu."])
isi = await berkas.read()
try:
mahasiswa, peringatan = nilai_xlsx.parse_unggahan(isi, kode_sub)
except Exception:
return _konteks_nilai(request, kode,
peringatan=["File tidak terbaca sebagai .xlsx."])
if not mahasiswa:
return _konteks_nilai(request, kode, peringatan=peringatan)
lama = db.ambil_dok("nilai", kode)
if lama and lama["data"].get("mode") == "per_mahasiswa":
data = _gabung_nilai(lama["data"], mahasiswa, berkas.filename, updated_by)
pesan = (f"Unggahan {berkas.filename} digabung: {len(mahasiswa)} mahasiswa "
f"diperbarui, total {len(data['mahasiswa'])} mahasiswa tersimpan.")
else:
data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa,
"sumber": [f"{berkas.filename} ({updated_by})"]}
pesan = f"Nilai {len(mahasiswa)} mahasiswa tersimpan dari {berkas.filename}."
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
return _konteks_nilai(request, kode, pesan=pesan, peringatan=peringatan)
# aksi == 'agregat' — ketik rata-rata per Sub-CPMK
lama = db.ambil_dok("nilai", kode)
if lama and lama["data"].get("mode") == "per_mahasiswa":
return _konteks_nilai(request, kode, peringatan=[
"Data per-mahasiswa sudah tersimpan — menyimpan rata-rata akan membuang "
'rinciannya. Bila memang ingin beralih, hapus dulu lewat tombol "Mulai Ulang".'])
agregat = {}
for s in peta["subs"]:
v = (form.get(f"rata_{s['kode']}") or "").strip()
if v:
try:
x = float(v)
except ValueError:
continue
if 0 <= x <= 100:
agregat[s["kode"]] = x
if not agregat:
return _konteks_nilai(request, kode,
peringatan=["Tidak ada rata-rata yang diisi."])
data = {"mode": "agregat", "agregat": agregat}
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
return _konteks_nilai(
request, kode,
pesan=f"Rata-rata {len(agregat)} Sub-CPMK tersimpan (mode BUKTI AGREGAT).",
)
# ═════════════════════════ TAHAP 2: PORTOFOLIO ═════════════════════════
PORTO_KOSONG = {
"dokumen": {"nomor": "", "revisi": "0", "tgl_terbit": "",
"kurikulum_tahun": "2025", "dekan": "", "status_dokumen": "Master"},
"realisasi": "",
"kendala": [{"kendala": "", "tindak": ""} for _ in range(5)],
"simpulan": "",
}
@app.get("/portofolio/{kode}", response_class=HTMLResponse)
def form_porto(request: Request, kode: str, pesan: str = ""):
peta = db.peta_mk(kode)
if not peta:
return RedirectResponse("/", status_code=303)
tersimpan = db.ambil_dok("portofolio", kode)
if tersimpan:
f = tersimpan["data"]
else:
f = {"dokumen": dict(PORTO_KOSONG["dokumen"]),
"realisasi": "", "simpulan": "",
"kendala": [{"kendala": "", "tindak": ""} for _ in range(5)]}
while len(f.get("kendala", [])) < 5:
f.setdefault("kendala", []).append({"kendala": "", "tindak": ""})
rps = db.ambil_rps(kode)
nilai = db.ambil_dok("nilai", kode)
return render(
request, "form_porto.html",
{"peta": peta, "f": f, "tahun": db.tahun_aktif(), "pesan": pesan,
"status": tersimpan["status"] if tersimpan else "",
"updated_at": tersimpan["updated_at"] if tersimpan else "",
"updated_by": tersimpan["updated_by"] if tersimpan else "",
"boleh_edit": boleh_mk(request.state.user, kode),
"ada_rps": bool(rps), "ada_nilai": bool(nilai)},
)
@app.post("/portofolio/{kode}")
async def simpan_porto(request: Request, kode: str):
if not db.peta_mk(kode):
return RedirectResponse("/", status_code=303)
if not boleh_mk(request.state.user, kode):
return RedirectResponse(
f"/portofolio/{kode}?pesan=Anda+bukan+pengampu+MK+ini.", status_code=303)
form = await request.form()
data = {
"dokumen": {
k: (form.get(k) or "").strip()
for k in ("nomor", "revisi", "tgl_terbit", "kurikulum_tahun",
"dekan", "status_dokumen")
},
"realisasi": (form.get("realisasi") or "").strip(),
"kendala": [
{"kendala": (form.get(f"kendala_{i}") or "").strip(),
"tindak": (form.get(f"tindak_{i}") or "").strip()}
for i in range(1, 6)
],
"simpulan": (form.get("simpulan") or "").strip(),
}
status = "selesai" if form.get("aksi") == "selesai" else "draft"
db.simpan_dok("portofolio", kode, data, status, request.state.user["nama"])
return RedirectResponse(f"/portofolio/{kode}?pesan=Tersimpan+sebagai+{status}.",
status_code=303)
def _bahan_porto(kode: str) -> dict | None:
"""Kumpulkan semua bahan dokumen portofolio satu MK."""
peta = db.peta_mk(kode)
if not peta:
return None
porto = db.ambil_dok("portofolio", kode)
rps = db.ambil_rps(kode)
nilai = db.ambil_dok("nilai", kode)
pengaturan = db.ambil_pengaturan()
ambang = float(pengaturan["ambang"])
skala = hitung.parse_skala(pengaturan["skala_huruf"])
f = porto["data"] if porto else PORTO_KOSONG
rps_data = rps["data"] if rps else None
nilai_data = nilai["data"] if nilai else {"mode": "agregat", "agregat": {}}
bobot_sub = hitung.bobot_sub_dari_rps(rps_data)
# korelasi sub -> CPL: bobot sub dibagi rata ke CPL yang didukung CPMK-nya
cpl_per_cpmk = {c["kode"]: c["cpl"] for c in peta["cpmks"]}
korelasi = {}
for s in peta["subs"]:
cpls = cpl_per_cpmk.get(s["cpmk"], [])
b = bobot_sub.get(s["kode"])
if b and cpls:
korelasi[s["kode"]] = {cp: b / len(cpls) for cp in cpls}
# jumlah minggu per sub & rencana penilaian dari RPS
minggu_sub, rencana = {}, []
if rps_data:
uraian = {s["kode"]: s for s in peta["subs"]}
for m in rps_data.get("minggu", []):
sk = m.get("sub", "")
if sk in uraian:
minggu_sub[sk] = minggu_sub.get(sk, 0) + 1
asesmen = "; ".join(x for x in (m.get("kriteria"), m.get("metode")) if x)
if sk in uraian:
label = sk
elif sk == "__uts__":
label = "UTS"
elif sk == "__uas__":
label = "UAS"
else:
label = sk
rencana.append({"minggu": m.get("ke"), "sub": label,
"asesmen": asesmen, "bobot": m.get("bobot", "")})
skor_sub = hitung.skor_sub(nilai_data, ambang)
rollup = hitung.rollup_mk(peta, nilai_data, ambang)
akhir = (hitung.nilai_akhir_mahasiswa(nilai_data, bobot_sub)
if nilai_data.get("mode") == "per_mahasiswa" else [])
statistik = hitung.statistik_akhir(akhir, skala) if akhir else None
return {
"tahun": db.tahun_aktif(),
"identitas": {**(rps_data or {}).get("identitas", {}),
"nama": peta["nama"], "kode": peta["kode"]},
"dokumen": f.get("dokumen", {}),
"cpl": peta["cpls"],
"cpmk": peta["cpmks"],
"sub": [{"kode": s["kode"], "uraian": s["uraian"], "cpmk": s["cpmk"]}
for s in peta["subs"]],
"korelasi": korelasi, "bobot_sub": bobot_sub, "minggu_sub": minggu_sub,
"rencana": rencana,
"realisasi": f.get("realisasi", ""),
"nilai": nilai_data, "skor_sub": skor_sub, "rollup": rollup,
"akhir": akhir, "statistik": statistik, "skala": skala, "ambang": ambang,
"kendala": f.get("kendala", []), "simpulan": f.get("simpulan", ""),
}
@app.get("/portofolio/{kode}/unduh")
def unduh_porto(request: Request, kode: str):
# dokumen memuat matriks nilai per mahasiswa — batasi pengampu + kaprodi
if not boleh_mk(request.state.user, kode):
return RedirectResponse(
f"/portofolio/{kode}?pesan=Unduhan+portofolio+memuat+nilai+mahasiswa+-+hanya+pengampu+dan+kaprodi.",
status_code=303)
bahan = _bahan_porto(kode)
if not bahan:
return RedirectResponse("/", status_code=303)
isi = build_portofolio(bahan)
nama = re.sub(r"[^A-Za-z0-9_-]+", "_",
f"Portofolio_{kode}_{bahan['identitas']['nama']}") + ".docx"
return Response(
content=isi,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f'attachment; filename="{nama}"'},
)
# ═════════════════════════ TAHAP 2: REKAP & METODOLOGI ═════════════════════════
@app.get("/rekap", response_class=HTMLResponse)
def rekap(request: Request):
hasil = hitung.rollup_prodi()
cpl_urut = sorted(hasil["cpl"].values(), key=lambda c: c["kode"])
cpmk_urut = sorted(hasil["cpmk"].values(), key=lambda c: c["kode"])
nama_mk = {m["kode"]: m["nama"] for m in db.daftar_mk()}
return render(
request, "rekap.html",
{"hasil": hasil, "cpl_urut": cpl_urut, "cpmk_urut": cpmk_urut,
"nama_mk": nama_mk, "tahun": db.tahun_aktif()},
)
@app.get("/pantau", response_class=HTMLResponse)
def pantau(request: Request):
"""Dasbor monitoring prodi: ketercapaian CPL/CPMK semester berjalan,
kelengkapan proses dokumen, dan daftar tindak lanjut untuk kaprodi."""
hasil = hitung.rollup_prodi()
daftar = db.daftar_mk()
nama_mk = {m["kode"]: m["nama"] for m in daftar}
cpl_urut = sorted(hasil["cpl"].values(), key=lambda c: c["kode"])
cpmk_urut = sorted(hasil["cpmk"].values(), key=lambda c: c["kode"])
terukur = [c for c in cpl_urut if c["skor"] is not None]
rata_cpl = sum(c["skor"] for c in terukur) / len(terukur) if terukur else None
# kelengkapan proses dokumen per tahap (dari status 49 MK)
proses = []
for label, tabel in (("RPS", "rps"), ("Nilai", "nilai"),
("Portofolio", "portofolio")):
s = db.status_semua(tabel)
n_sel = sum(1 for v in s.values() if v["status"] == "selesai")
n_draft = sum(1 for v in s.values() if v["status"] == "draft")
proses.append({"label": label, "selesai": n_sel, "draft": n_draft,
"belum": len(daftar) - n_sel - n_draft})
# MK belum setor nilai + pengampunya — bahan tindak lanjut
st_nilai = db.status_semua("nilai")
pengampu = db.pengampu_per_mk()
belum_nilai = [{"kode": m["kode"], "nama": m["nama"],
"pengampu": pengampu.get(m["kode"], [])}
for m in daftar if m["kode"] not in st_nilai]
# tren skor CPL lintas tahun ajaran (sparkline; hanya bila >1 tahun)
daftar_tahun = db.daftar_tahun()
tren = {}
if len(daftar_tahun) > 1:
per_tahun = {t: hitung.rollup_prodi(t) for t in daftar_tahun}
for c in cpl_urut:
titik = [(t, per_tahun[t]["cpl"][c["kode"]]["skor"])
for t in daftar_tahun]
if sum(1 for _, s in titik if s is not None) > 1:
tren[c["kode"]] = titik
return render(
request, "pantau.html",
{"hasil": hasil, "cpl_urut": cpl_urut, "cpmk_urut": cpmk_urut,
"rata_cpl": rata_cpl, "proses": proses, "n_mk": len(daftar),
"belum_nilai": belum_nilai, "tren": tren, "nama_mk": nama_mk,
"tahun": db.tahun_aktif()},
)
@app.get("/metodologi", response_class=HTMLResponse)
def metodologi(request: Request, pesan: str = ""):
pengaturan = db.ambil_pengaturan()
skala = hitung.parse_skala(pengaturan["skala_huruf"])
return render(
request, "metodologi.html",
{"p": pengaturan, "skala": skala, "tahun": db.tahun_aktif(),
"boleh_ubah": request.state.user["role"] == "kaprodi",
"pesan": pesan},
)
@app.post("/metodologi")
async def simpan_metodologi(request: Request):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
form = await request.form()
db.simpan_pengaturan({
"tahun_aktif": (form.get("tahun_aktif") or "").strip()
or db.PENGATURAN_DEFAULT["tahun_aktif"],
"ambang": (form.get("ambang") or "70").strip(),
"metode_antar_mk": form.get("metode_antar_mk") or "rata",
"skala_huruf": (form.get("skala_huruf") or "").strip()
or db.PENGATURAN_DEFAULT["skala_huruf"],
})
return RedirectResponse("/metodologi?pesan=Pengaturan+tersimpan.", status_code=303)
# ═════════════════════════ TAHAP 3: RIWAYAT, CADANGAN, PDF ═════════════════════════
@app.get("/rekap/riwayat", response_class=HTMLResponse)
def rekap_riwayat(request: Request):
"""Tren ketercapaian CPL lintas tahun ajaran."""
daftar_tahun = db.daftar_tahun()
per_tahun = {t: hitung.rollup_prodi(t) for t in daftar_tahun}
kode_cpl = sorted(db.kurikulum()["cpl"].keys())
return render(
request, "riwayat.html",
{"daftar_tahun": daftar_tahun, "per_tahun": per_tahun,
"kode_cpl": kode_cpl, "tahun": db.tahun_aktif()},
)
@app.get("/cadangan.json")
def cadangan_json(request: Request):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
import json as _json
isi = _json.dumps(db.dump_semua(), ensure_ascii=False, indent=1)
stempel = db.tahun_aktif().replace("/", "-").replace(" ", "_")
return Response(
content=isi.encode("utf-8"), media_type="application/json",
headers={"Content-Disposition":
f'attachment; filename="cadangan_rps_porto_{stempel}.json"'},
)
@app.get("/cadangan.db")
def cadangan_db(request: Request):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
return FileResponse(db.DB_PATH, media_type="application/octet-stream",
filename="app.db")
def _docx_ke_pdf(docx_bytes: bytes) -> bytes | None:
"""Konversi via LibreOffice (soffice) bila tersedia; None bila tidak."""
import shutil, subprocess, tempfile, pathlib as _pl
soffice = shutil.which("soffice") or shutil.which("soffice.exe")
if not soffice:
return None
with tempfile.TemporaryDirectory() as tmp:
src = _pl.Path(tmp) / "dokumen.docx"
src.write_bytes(docx_bytes)
try:
subprocess.run([soffice, "--headless", "--convert-to", "pdf",
"--outdir", tmp, str(src)],
check=True, capture_output=True, timeout=120)
except (subprocess.SubprocessError, OSError):
return None
pdf = src.with_suffix(".pdf")
return pdf.read_bytes() if pdf.exists() else None
@app.get("/rps/{kode}/unduh.pdf")
def unduh_rps_pdf(kode: str):
r = unduh_rps(kode)
if not isinstance(r, Response) or r.media_type is None or "wordprocessingml" not in r.media_type:
return r
pdf = _docx_ke_pdf(r.body)
if pdf is None:
return RedirectResponse(
f"/rps/{kode}?pesan=Konverter+PDF+(LibreOffice)+tidak+tersedia+di+server+-+unduh+.docx+lalu+Save+as+PDF+di+Word.",
status_code=303)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition":
f'attachment; filename="RPS_{kode}.pdf"'})
@app.get("/portofolio/{kode}/unduh.pdf")
def unduh_porto_pdf(request: Request, kode: str):
r = unduh_porto(request, kode)
if not isinstance(r, Response) or r.media_type is None or "wordprocessingml" not in r.media_type:
return r
pdf = _docx_ke_pdf(r.body)
if pdf is None:
return RedirectResponse(
f"/portofolio/{kode}?pesan=Konverter+PDF+(LibreOffice)+tidak+tersedia+di+server+-+unduh+.docx+lalu+Save+as+PDF+di+Word.",
status_code=303)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition":
f'attachment; filename="Portofolio_{kode}.pdf"'})