Files
OBE-Mapping/app/main.py
T
Power BI Dev af369883f3 Tambah halaman RTM (Rancangan Tugas Mahasiswa) tersendiri, terpisah dari Nilai
Dokumen RTM resmi sesuai template baku prodi (FORM RPS & RTM UNTAN), diisi
di halaman /rps/{kode}/rtm sendiri setelah RPS ditandai selesai — blok Tugas
bisa ditambah/dihapus dinamis, dan bisa diunduh sebagai .docx. Murni
dokumentasi, tak menyimpan atau memengaruhi nilai/skor mahasiswa sama sekali.
2026-07-27 07:51:27 +07:00

1592 lines
66 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Aplikasi Penyusunan RPS & Portofolio — Prodi Informatika UNTAN."""
import json
import pathlib
import re
from datetime import datetime
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from . import auth, db, hitung, nilai_xlsx
from .generator.portofolio_docx import build_portofolio
from .generator.rps_untan import build_rps
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 minggu_default() -> list[dict]:
rows = []
for w in range(1, 17):
m = {"ke": w, "sub": "", "indikator": "", "kriteria": "", "metode": "",
"daring": "", "kegiatan": "", "materi": "", "bobot": ""}
if w == 8:
m.update(sub="__uts__", kriteria="Ujian Tengah Semester (UTS)",
metode="Ujian tulis [2 x 50 menit]", materi="Materi minggu 17")
elif w == 16:
m.update(sub="__uas__", kriteria="Ujian Akhir Semester (UAS)",
metode="Ujian tulis [2 x 50 menit]", materi="Materi minggu 915")
rows.append(m)
return rows
def form_kosong() -> dict:
return {
"identitas": {"sks": "", "sks_teori": "", "sks_praktikum": "", "semester": "",
"prasyarat": "", "pengampu": "", "koordinator": "", "kaprodi": "",
"rumpun": "", "tgl_penyusunan": "", "pengembang": "",
"kode_dokumen": ""},
"deskripsi": "",
"bahan_kajian": "",
"media": "",
"minggu": minggu_default(),
"komponen": [dict(k) for k in KOMPONEN_DEFAULT],
"referensi_teks": "",
"referensi_pendukung_teks": "",
"sub_uraian_custom": {},
}
# ── 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
else:
f = form_kosong()
# prefill SKS & semester dari data kurikulum (sheet '14. Susunan MK')
info = db.sks_mk().get(kode)
if info:
f["identitas"]["sks"] = f"{info['sks']:g}"
if info.get("semester"):
f["identitas"]["semester"] = str(info["semester"])
ctx_status = {"status": "", "updated_at": "", "updated_by": ""}
peta = _terapkan_override_sub(peta, f)
return render(
request, "form_rps.html",
{"peta": peta, "f": f, "status": ctx_status["status"],
"updated_at": ctx_status["updated_at"], "updated_by": ctx_status["updated_by"],
"tahun": db.tahun_aktif(), "pesan": pesan,
"boleh_edit": boleh_rps(request.state.user, kode),
"tahun_lain": db.tahun_rps_lain(kode) if not tersimpan else [],
"sub_uraian": {s["kode"]: s["uraian"] for s in peta["subs"]}},
)
@app.post("/rps/{kode}/salin")
async def salin_rps(request: Request, kode: str):
"""Duplikasi RPS dari tahun ajaran lain ke tahun aktif (status: draft)."""
if not boleh_rps(request.state.user, kode):
return RedirectResponse(
f"/rps/{kode}?pesan=Anda+tidak+memiliki+hak+edit+RPS+untuk+MK+ini.",
status_code=303)
form = await request.form()
dari = form.get("dari") or ""
sumber = db.ambil_rps(kode, tahun=dari) if dari else None
if not sumber:
return RedirectResponse(f"/rps/{kode}?pesan=RPS+sumber+tidak+ditemukan.",
status_code=303)
if db.ambil_rps(kode):
return RedirectResponse(
f"/rps/{kode}?pesan=RPS+tahun+aktif+sudah+ada+-+tidak+ditimpa.",
status_code=303)
db.simpan_rps(kode, sumber["data"], "draft", request.state.user["nama"])
return RedirectResponse(
f"/rps/{kode}?pesan=RPS+disalin+dari+{dari.replace(' ', '+')}+sebagai+draft.",
status_code=303)
@app.post("/rps/{kode}")
async def simpan_rps(request: Request, kode: str):
peta = db.peta_mk(kode)
if not peta:
return RedirectResponse("/", status_code=303)
if not boleh_rps(request.state.user, kode):
return RedirectResponse(
f"/rps/{kode}?pesan=Anda+tidak+memiliki+hak+edit+RPS+untuk+MK+ini.",
status_code=303)
form = await request.form()
identitas = {
k: (form.get(k) or "").strip()
for k in ("sks", "sks_teori", "sks_praktikum", "semester", "prasyarat",
"pengampu", "koordinator", "kaprodi", "rumpun",
"tgl_penyusunan", "pengembang", "kode_dokumen")
}
minggu = []
for w in range(1, 17):
minggu.append({
"ke": w,
"sub": (form.get(f"m{w}_sub") or "").strip(),
"indikator": (form.get(f"m{w}_indikator") or "").strip(),
"kriteria": (form.get(f"m{w}_kriteria") or "").strip(),
"metode": (form.get(f"m{w}_metode") or "").strip(),
"daring": (form.get(f"m{w}_daring") or "").strip(),
"kegiatan": (form.get(f"m{w}_kegiatan") or "").strip(),
"materi": (form.get(f"m{w}_materi") or "").strip(),
"bobot": (form.get(f"m{w}_bobot") or "").strip(),
})
komponen = []
for i, default in enumerate(KOMPONEN_DEFAULT, 1):
komponen.append({
"nama": default["nama"],
"subs": [s for s in form.getlist(f"k{i}_subs")],
"bobot": (form.get(f"k{i}_bobot") or "").strip(),
})
referensi_teks = (form.get("referensi") or "").strip()
referensi = [r.strip() for r in referensi_teks.splitlines() if r.strip()]
pendukung_teks = (form.get("referensi_pendukung") or "").strip()
pendukung = [r.strip() for r in pendukung_teks.splitlines() if r.strip()]
sub_uraian_custom = {}
for s in peta["subs"]:
teks = (form.get(f"sub_uraian_{s['kode']}") or "").strip()
if teks and teks != s["uraian"]:
sub_uraian_custom[s["kode"]] = teks
data = {
"identitas": identitas,
"deskripsi": (form.get("deskripsi") or "").strip(),
"bahan_kajian": (form.get("bahan_kajian") or "").strip(),
"media": (form.get("media") or "").strip(),
"minggu": minggu,
"komponen": komponen,
"referensi": referensi,
"referensi_teks": referensi_teks,
"referensi_pendukung": pendukung,
"referensi_pendukung_teks": pendukung_teks,
"sub_uraian_custom": sub_uraian_custom,
}
aksi = form.get("aksi") or "draft"
status = "selesai" if aksi == "selesai" else "draft"
# 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 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}"'},
)
@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 _konteks_nilai(request, kode, pesan="", peringatan=None):
peta = db.peta_mk(kode)
tersimpan = db.ambil_dok("nilai", kode)
pengaturan = db.ambil_pengaturan()
ambang = float(pengaturan["ambang"])
hasil, akhir = None, {}
if tersimpan:
hasil = hitung.rollup_mk(peta, tersimpan["data"], ambang)
if tersimpan["data"].get("mode") == "per_mahasiswa":
rps = db.ambil_rps(kode)
bobot_sub = hitung.bobot_sub_dari_rps(rps["data"] if rps else None)
akhir = {a["nim"]: a["akhir"]
for a in hitung.nilai_akhir_mahasiswa(tersimpan["data"], bobot_sub)}
kelas_dosen = _kelas_dosen(request.state.user, kode)
terdaftar = db.mahasiswa_mk(kode)
grid = _grid_mahasiswa(tersimpan, terdaftar, kelas_dosen)
komponen_daftar = _komponen_daftar(kode)
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))
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,
"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 [],
"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 = lama_data.get("sumber") or []
if isinstance(riwayat, str):
riwayat = [riwayat]
riwayat.append(f"komponen {komponen['nama']}{label} ({updated_by})")
data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sesi": sesi_semua,
"sumber": riwayat[-8:]}
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
pesan = f"Nilai+{label.replace(' ', '+')}+tersimpan+untuk+{len(nilai)}+mahasiswa."
return RedirectResponse(f"/nilai/{kode}?pesan={pesan}", status_code=303)
@app.post("/nilai/{kode}")
async def simpan_nilai(request: Request, kode: str):
peta = db.peta_mk(kode)
if not peta:
return RedirectResponse("/", status_code=303)
if not boleh_mk(request.state.user, kode):
return RedirectResponse(
f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.",
status_code=303)
form = await request.form()
aksi = form.get("aksi")
updated_by = request.state.user["nama"]
if aksi == "hapus":
if _kelas_dosen(request.state.user, kode) is not None:
return _konteks_nilai(request, kode, peringatan=[
"Anda hanya diampukan sebagian kelas MK ini — menghapus seluruh nilai "
"hanya bisa dilakukan kaprodi atau dosen yang mengampu seluruh kelas."])
db.hapus_dok("nilai", kode)
return _konteks_nilai(
request, kode,
pesan="Seluruh nilai tersimpan MK ini dihapus — silakan mulai dari awal.")
if aksi == "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 = lama_data.get("sumber") or []
if isinstance(riwayat, str):
riwayat = [riwayat]
riwayat.append(f"input per komponen ({updated_by})")
data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sesi": sesi_semua,
"sumber": riwayat[-8:]}
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 = lama_data.get("sumber") or []
if isinstance(riwayat, str):
riwayat = [riwayat]
riwayat.append(f"input langsung ({updated_by})")
if kelas_dosen is None:
# dosen tak dibatasi kelas: tabel yang tampil = seluruh MK,
# jadi menyimpan persis isi tabel memang dimaksudkan sebagai pengganti
data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa, "sumber": riwayat[-8:]}
else:
# dosen dibatasi kelas: tabel yang tampil cuma kelasnya — gabungkan
# ke data lama, jangan sampai menghapus nilai kelas lain
urut = [dict(m, skor=dict(m.get("skor") or {}))
for m in lama_data.get("mahasiswa", [])]
per_nim = {m["nim"]: m for m in urut if m.get("nim")}
for m in mahasiswa:
ada = per_nim.get(m["nim"])
if ada is None:
urut.append(m)
per_nim[m["nim"]] = m
else:
ada["nama"] = m["nama"] or ada.get("nama", "")
ada["skor"] = m["skor"]
data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sumber": riwayat[-8:]}
if lama_data.get("sesi"):
# pertahankan log sesi per-komponen — grid ini hanya koreksi manual
# di atasnya, bukan pengganti sumber datanya
data["sesi"] = lama_data["sesi"]
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
return _konteks_nilai(
request, kode,
pesan=f"Nilai {len(mahasiswa)} mahasiswa tersimpan (input langsung).")
return _konteks_nilai(request, kode, peringatan=["Aksi tidak dikenali."])
# ═════════════════════════ TAHAP 2: PORTOFOLIO ═════════════════════════
PORTO_KOSONG = {
"dokumen": {"nomor": "", "revisi": "0", "tgl_terbit": "",
"kurikulum_tahun": "2025", "dekan": "", "status_dokumen": "Master"},
"realisasi": "",
"kendala": [{"kendala": "", "tindak": ""} for _ in range(5)],
"simpulan": "",
}
@app.get("/portofolio/{kode}", response_class=HTMLResponse)
def form_porto(request: Request, kode: str, pesan: str = ""):
peta = db.peta_mk(kode)
if not peta:
return RedirectResponse("/", status_code=303)
tersimpan = db.ambil_dok("portofolio", kode)
if tersimpan:
f = tersimpan["data"]
else:
f = {"dokumen": dict(PORTO_KOSONG["dokumen"]),
"realisasi": "", "simpulan": "",
"kendala": [{"kendala": "", "tindak": ""} for _ in range(5)]}
while len(f.get("kendala", [])) < 5:
f.setdefault("kendala", []).append({"kendala": "", "tindak": ""})
rps = db.ambil_rps(kode)
nilai = db.ambil_dok("nilai", kode)
return render(
request, "form_porto.html",
{"peta": peta, "f": f, "tahun": db.tahun_aktif(), "pesan": pesan,
"status": tersimpan["status"] if tersimpan else "",
"updated_at": tersimpan["updated_at"] if tersimpan else "",
"updated_by": tersimpan["updated_by"] if tersimpan else "",
"boleh_edit": boleh_mk(request.state.user, kode),
"ada_rps": bool(rps), "ada_nilai": bool(nilai)},
)
@app.post("/portofolio/{kode}")
async def simpan_porto(request: Request, kode: str):
if not db.peta_mk(kode):
return RedirectResponse("/", status_code=303)
if not boleh_mk(request.state.user, kode):
return RedirectResponse(
f"/portofolio/{kode}?pesan=Anda+bukan+pengampu+MK+ini.", status_code=303)
form = await request.form()
data = {
"dokumen": {
k: (form.get(k) or "").strip()
for k in ("nomor", "revisi", "tgl_terbit", "kurikulum_tahun",
"dekan", "status_dokumen")
},
"realisasi": (form.get("realisasi") or "").strip(),
"kendala": [
{"kendala": (form.get(f"kendala_{i}") or "").strip(),
"tindak": (form.get(f"tindak_{i}") or "").strip()}
for i in range(1, 6)
],
"simpulan": (form.get("simpulan") or "").strip(),
}
status = "selesai" if form.get("aksi") == "selesai" else "draft"
db.simpan_dok("portofolio", kode, data, status, request.state.user["nama"])
return RedirectResponse(f"/portofolio/{kode}?pesan=Tersimpan+sebagai+{status}.",
status_code=303)
def _bahan_porto(kode: str) -> dict | None:
"""Kumpulkan semua bahan dokumen portofolio satu MK."""
peta = db.peta_mk(kode)
if not peta:
return None
porto = db.ambil_dok("portofolio", kode)
rps = db.ambil_rps(kode)
nilai = db.ambil_dok("nilai", kode)
pengaturan = db.ambil_pengaturan()
ambang = float(pengaturan["ambang"])
skala = hitung.parse_skala(pengaturan["skala_huruf"])
f = porto["data"] if porto else PORTO_KOSONG
rps_data = rps["data"] if rps else None
peta = _terapkan_override_sub(peta, rps_data)
nilai_data = nilai["data"] if nilai else {"mode": "agregat", "agregat": {}}
bobot_sub = hitung.bobot_sub_dari_rps(rps_data)
# korelasi sub -> CPL: bobot sub dibagi rata ke CPL yang didukung CPMK-nya
cpl_per_cpmk = {c["kode"]: c["cpl"] for c in peta["cpmks"]}
korelasi = {}
for s in peta["subs"]:
cpls = cpl_per_cpmk.get(s["cpmk"], [])
b = bobot_sub.get(s["kode"])
if b and cpls:
korelasi[s["kode"]] = {cp: b / len(cpls) for cp in cpls}
# jumlah minggu per sub & rencana penilaian dari RPS
minggu_sub, rencana = {}, []
if rps_data:
uraian = {s["kode"]: s for s in peta["subs"]}
for m in rps_data.get("minggu", []):
sk = m.get("sub", "")
if sk in uraian:
minggu_sub[sk] = minggu_sub.get(sk, 0) + 1
asesmen = "; ".join(x for x in (m.get("kriteria"), m.get("metode")) if x)
if sk in uraian:
label = sk
elif sk == "__uts__":
label = "UTS"
elif sk == "__uas__":
label = "UAS"
else:
label = sk
rencana.append({"minggu": m.get("ke"), "sub": label,
"asesmen": asesmen, "bobot": m.get("bobot", "")})
skor_sub = hitung.skor_sub(nilai_data, ambang)
rollup = hitung.rollup_mk(peta, nilai_data, ambang)
akhir = (hitung.nilai_akhir_mahasiswa(nilai_data, bobot_sub)
if nilai_data.get("mode") == "per_mahasiswa" else [])
statistik = hitung.statistik_akhir(akhir, skala) if akhir else None
return {
"tahun": db.tahun_aktif(),
"identitas": {**(rps_data or {}).get("identitas", {}),
"nama": peta["nama"], "kode": peta["kode"]},
"dokumen": f.get("dokumen", {}),
"cpl": peta["cpls"],
"cpmk": peta["cpmks"],
"sub": [{"kode": s["kode"], "uraian": s["uraian"], "cpmk": s["cpmk"]}
for s in peta["subs"]],
"korelasi": korelasi, "bobot_sub": bobot_sub, "minggu_sub": minggu_sub,
"rencana": rencana,
"realisasi": f.get("realisasi", ""),
"nilai": nilai_data, "skor_sub": skor_sub, "rollup": rollup,
"akhir": akhir, "statistik": statistik, "skala": skala, "ambang": ambang,
"kendala": f.get("kendala", []), "simpulan": f.get("simpulan", ""),
}
@app.get("/portofolio/{kode}/unduh")
def unduh_porto(request: Request, kode: str):
# dokumen memuat matriks nilai per mahasiswa — batasi pengampu + kaprodi
if not boleh_mk(request.state.user, kode):
return RedirectResponse(
f"/portofolio/{kode}?pesan=Unduhan+portofolio+memuat+nilai+mahasiswa+-+hanya+pengampu+dan+kaprodi.",
status_code=303)
bahan = _bahan_porto(kode)
if not bahan:
return RedirectResponse("/", status_code=303)
isi = build_portofolio(bahan)
nama = re.sub(r"[^A-Za-z0-9_-]+", "_",
f"Portofolio_{kode}_{bahan['identitas']['nama']}") + ".docx"
return Response(
content=isi,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f'attachment; filename="{nama}"'},
)
# ═════════════════════════ TAHAP 2: REKAP & METODOLOGI ═════════════════════════
@app.get("/rekap", response_class=HTMLResponse)
def rekap(request: Request):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
hasil = hitung.rollup_prodi()
cpl_urut = sorted(hasil["cpl"].values(), key=lambda c: c["kode"])
cpmk_urut = sorted(hasil["cpmk"].values(), key=lambda c: c["kode"])
nama_mk = {m["kode"]: m["nama"] for m in db.daftar_mk()}
return render(
request, "rekap.html",
{"hasil": hasil, "cpl_urut": cpl_urut, "cpmk_urut": cpmk_urut,
"nama_mk": nama_mk, "tahun": db.tahun_aktif()},
)
@app.get("/pantau", response_class=HTMLResponse)
def pantau(request: Request):
"""Dasbor monitoring prodi: ketercapaian CPL/CPMK semester berjalan,
kelengkapan proses dokumen, dan daftar tindak lanjut untuk kaprodi."""
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
hasil = hitung.rollup_prodi()
daftar = db.daftar_mk()
nama_mk = {m["kode"]: m["nama"] for m in daftar}
cpl_urut = sorted(hasil["cpl"].values(), key=lambda c: c["kode"])
cpmk_urut = sorted(hasil["cpmk"].values(), key=lambda c: c["kode"])
terukur = [c for c in cpl_urut if c["skor"] is not None]
rata_cpl = sum(c["skor"] for c in terukur) / len(terukur) if terukur else None
# kelengkapan proses dokumen per tahap (dari status 49 MK)
proses = []
for label, tabel in (("RPS", "rps"), ("Nilai", "nilai"),
("Portofolio", "portofolio")):
s = db.status_semua(tabel)
n_sel = sum(1 for v in s.values() if v["status"] == "selesai")
n_draft = sum(1 for v in s.values() if v["status"] == "draft")
proses.append({"label": label, "selesai": n_sel, "draft": n_draft,
"belum": len(daftar) - n_sel - n_draft})
# MK belum setor nilai + pengampunya — bahan tindak lanjut
st_nilai = db.status_semua("nilai")
pengampu = db.pengampu_per_mk()
belum_nilai = [{"kode": m["kode"], "nama": m["nama"],
"pengampu": pengampu.get(m["kode"], [])}
for m in daftar if m["kode"] not in st_nilai]
# tren skor CPL lintas tahun ajaran (sparkline; hanya bila >1 tahun)
daftar_tahun = db.daftar_tahun()
tren = {}
if len(daftar_tahun) > 1:
per_tahun = {t: hitung.rollup_prodi(t) for t in daftar_tahun}
for c in cpl_urut:
titik = [(t, per_tahun[t]["cpl"][c["kode"]]["skor"])
for t in daftar_tahun]
if sum(1 for _, s in titik if s is not None) > 1:
tren[c["kode"]] = titik
return render(
request, "pantau.html",
{"hasil": hasil, "cpl_urut": cpl_urut, "cpmk_urut": cpmk_urut,
"rata_cpl": rata_cpl, "proses": proses, "n_mk": len(daftar),
"belum_nilai": belum_nilai, "tren": tren, "nama_mk": nama_mk,
"tahun": db.tahun_aktif()},
)
@app.get("/metodologi", response_class=HTMLResponse)
def metodologi(request: Request, pesan: str = ""):
pengaturan = db.ambil_pengaturan()
skala = hitung.parse_skala(pengaturan["skala_huruf"])
return render(
request, "metodologi.html",
{"p": pengaturan, "skala": skala, "tahun": db.tahun_aktif(),
"boleh_ubah": request.state.user["role"] == "kaprodi",
"pesan": pesan},
)
@app.post("/metodologi")
async def simpan_metodologi(request: Request):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
form = await request.form()
db.simpan_pengaturan({
"tahun_aktif": (form.get("tahun_aktif") or "").strip()
or db.PENGATURAN_DEFAULT["tahun_aktif"],
"ambang": (form.get("ambang") or "70").strip(),
"metode_antar_mk": form.get("metode_antar_mk") or "rata",
"skala_huruf": (form.get("skala_huruf") or "").strip()
or db.PENGATURAN_DEFAULT["skala_huruf"],
})
return RedirectResponse("/metodologi?pesan=Pengaturan+tersimpan.", status_code=303)
# ═════════════════════════ TAHAP 3: RIWAYAT, CADANGAN, PDF ═════════════════════════
@app.get("/rekap/riwayat", response_class=HTMLResponse)
def rekap_riwayat(request: Request):
"""Tren ketercapaian CPL lintas tahun ajaran."""
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
daftar_tahun = db.daftar_tahun()
per_tahun = {t: hitung.rollup_prodi(t) for t in daftar_tahun}
kode_cpl = sorted(db.kurikulum()["cpl"].keys())
return render(
request, "riwayat.html",
{"daftar_tahun": daftar_tahun, "per_tahun": per_tahun,
"kode_cpl": kode_cpl, "tahun": db.tahun_aktif()},
)
@app.get("/cadangan.json")
def cadangan_json(request: Request):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
import json as _json
isi = _json.dumps(db.dump_semua(), ensure_ascii=False, indent=1)
stempel = db.tahun_aktif().replace("/", "-").replace(" ", "_")
return Response(
content=isi.encode("utf-8"), media_type="application/json",
headers={"Content-Disposition":
f'attachment; filename="cadangan_rps_porto_{stempel}.json"'},
)
@app.get("/cadangan.db")
def cadangan_db(request: Request):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
return FileResponse(db.DB_PATH, media_type="application/octet-stream",
filename="app.db")
def _docx_ke_pdf(docx_bytes: bytes) -> bytes | None:
"""Konversi via LibreOffice (soffice) bila tersedia; None bila tidak."""
import shutil, subprocess, tempfile, pathlib as _pl
soffice = shutil.which("soffice") or shutil.which("soffice.exe")
if not soffice:
return None
with tempfile.TemporaryDirectory() as tmp:
src = _pl.Path(tmp) / "dokumen.docx"
src.write_bytes(docx_bytes)
try:
subprocess.run([soffice, "--headless", "--convert-to", "pdf",
"--outdir", tmp, str(src)],
check=True, capture_output=True, timeout=120)
except (subprocess.SubprocessError, OSError):
return None
pdf = src.with_suffix(".pdf")
return pdf.read_bytes() if pdf.exists() else None
@app.get("/rps/{kode}/unduh.pdf")
def unduh_rps_pdf(kode: str):
r = unduh_rps(kode)
if not isinstance(r, Response) or r.media_type is None or "wordprocessingml" not in r.media_type:
return r
pdf = _docx_ke_pdf(r.body)
if pdf is None:
return RedirectResponse(
f"/rps/{kode}?pesan=Konverter+PDF+(LibreOffice)+tidak+tersedia+di+server+-+unduh+.docx+lalu+Save+as+PDF+di+Word.",
status_code=303)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition":
f'attachment; filename="RPS_{kode}.pdf"'})
@app.get("/portofolio/{kode}/unduh.pdf")
def unduh_porto_pdf(request: Request, kode: str):
r = unduh_porto(request, kode)
if not isinstance(r, Response) or r.media_type is None or "wordprocessingml" not in r.media_type:
return r
pdf = _docx_ke_pdf(r.body)
if pdf is None:
return RedirectResponse(
f"/portofolio/{kode}?pesan=Konverter+PDF+(LibreOffice)+tidak+tersedia+di+server+-+unduh+.docx+lalu+Save+as+PDF+di+Word.",
status_code=303)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition":
f'attachment; filename="Portofolio_{kode}.pdf"'})