9be6bd03e3
Dashboard pemetaan statis kini dilayani aplikasi di /dashboard (app/static/dashboard.html). Fitur lengkap Tahap 1-4 + dasbor monitoring, menu global, filter semester, input nilai langsung, penggabungan nilai team teaching, tabel bisa urut. Dockerfile berubah dari nginx statis ke uvicorn/FastAPI: healthcheck, proxy-headers, volume /data (SQLite). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1012 lines
40 KiB
Python
1012 lines
40 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Aplikasi Penyusunan RPS & Portofolio — Prodi Informatika UNTAN."""
|
||
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"))
|
||
|
||
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_kelola(user: dict, kode: str) -> bool:
|
||
"""Pengampu MK ini atau kaprodi — syarat edit & akses nilai."""
|
||
return user["role"] == "kaprodi" or 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 = "kaprodi" if form.get("role") == "kaprodi" else "dosen"
|
||
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)
|
||
|
||
|
||
# bobot default komponen — dari sheet `Asessement` draft kurikulum (Tabel 25)
|
||
KOMPONEN_DEFAULT = [
|
||
{"nama": "Kuis", "subs": [], "bobot": 15},
|
||
{"nama": "Ujian Tengah Semester (UTS)", "subs": [], "bobot": 20},
|
||
{"nama": "Ujian Akhir Semester (UAS)", "subs": [], "bobot": 30},
|
||
{"nama": "Tugas / Praktikum / Proyek", "subs": [], "bobot": 35},
|
||
]
|
||
|
||
|
||
def minggu_default() -> list[dict]:
|
||
rows = []
|
||
for w in range(1, 17):
|
||
m = {"ke": w, "sub": "", "indikator": "", "kriteria": "", "metode": "",
|
||
"daring": "", "kegiatan": "", "materi": "", "bobot": ""}
|
||
if w == 8:
|
||
m.update(sub="__uts__", kriteria="Ujian Tengah Semester (UTS)",
|
||
metode="Ujian tulis [2 x 50 menit]", materi="Materi minggu 1–7")
|
||
elif w == 16:
|
||
m.update(sub="__uas__", kriteria="Ujian Akhir Semester (UAS)",
|
||
metode="Ujian tulis [2 x 50 menit]", materi="Materi minggu 9–15")
|
||
rows.append(m)
|
||
return rows
|
||
|
||
|
||
def form_kosong() -> dict:
|
||
return {
|
||
"identitas": {"sks": "", "sks_teori": "", "sks_praktikum": "", "semester": "",
|
||
"prasyarat": "", "pengampu": "", "koordinator": "", "kaprodi": "",
|
||
"rumpun": "", "tgl_penyusunan": "", "pengembang": "",
|
||
"kode_dokumen": ""},
|
||
"deskripsi": "",
|
||
"bahan_kajian": "",
|
||
"media": "",
|
||
"minggu": minggu_default(),
|
||
"komponen": [dict(k) for k in KOMPONEN_DEFAULT],
|
||
"referensi_teks": "",
|
||
"referensi_pendukung_teks": "",
|
||
}
|
||
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
def beranda(request: Request, semua: int = 0):
|
||
daftar = db.daftar_mk()
|
||
n_total = len(daftar)
|
||
mk_saya = db.mk_diampu(request.state.user["id"])
|
||
# dosen: bawaan hanya MK yang diampu; ?semua=1 menampilkan seluruh MK
|
||
hanya_saya = (request.state.user["role"] == "dosen"
|
||
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", [])))
|
||
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_kelola(request.state.user, kode),
|
||
"tahun_lain": db.tahun_rps_lain(kode) if not tersimpan else []},
|
||
)
|
||
|
||
|
||
@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_kelola(request.state.user, kode):
|
||
return RedirectResponse(
|
||
f"/rps/{kode}?pesan=Anda+bukan+pengampu+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_kelola(request.state.user, kode):
|
||
return RedirectResponse(
|
||
f"/rps/{kode}?pesan=Anda+bukan+pengampu+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 in range(1, len(KOMPONEN_DEFAULT) + 1):
|
||
nama = (form.get(f"k{i}_nama") or "").strip()
|
||
if not nama:
|
||
continue
|
||
komponen.append({
|
||
"nama": nama,
|
||
"subs": [s for s in form.getlist(f"k{i}_subs")],
|
||
"bobot": (form.get(f"k{i}_bobot") or "").strip(),
|
||
})
|
||
referensi_teks = (form.get("referensi") or "").strip()
|
||
referensi = [r.strip() for r in referensi_teks.splitlines() if r.strip()]
|
||
pendukung_teks = (form.get("referensi_pendukung") or "").strip()
|
||
pendukung = [r.strip() for r in pendukung_teks.splitlines() if r.strip()]
|
||
|
||
data = {
|
||
"identitas": identitas,
|
||
"deskripsi": (form.get("deskripsi") or "").strip(),
|
||
"bahan_kajian": (form.get("bahan_kajian") or "").strip(),
|
||
"media": (form.get("media") or "").strip(),
|
||
"minggu": minggu,
|
||
"komponen": komponen,
|
||
"referensi": referensi,
|
||
"referensi_teks": referensi_teks,
|
||
"referensi_pendukung": pendukung,
|
||
"referensi_pendukung_teks": pendukung_teks,
|
||
}
|
||
aksi = form.get("aksi") or "draft"
|
||
status = "selesai" if aksi == "selesai" else "draft"
|
||
db.simpan_rps(kode, data, status, request.state.user["nama"])
|
||
return RedirectResponse(f"/rps/{kode}?pesan=Tersimpan+sebagai+{status}.", status_code=303)
|
||
|
||
|
||
@app.get("/rps/{kode}/unduh")
|
||
def unduh_rps(kode: str):
|
||
peta = db.peta_mk(kode)
|
||
tersimpan = db.ambil_rps(kode)
|
||
if not peta or not tersimpan:
|
||
return RedirectResponse(f"/rps/{kode}", status_code=303)
|
||
f = tersimpan["data"]
|
||
|
||
# resolusi kode Sub-CPMK → teks uraian untuk dokumen
|
||
uraian = {s["kode"]: s["uraian"] for s in peta["subs"]}
|
||
|
||
def sub_teks(kode_sub: str) -> str:
|
||
if kode_sub == "__uts__":
|
||
return "Sub-CPMK minggu 1–7 (diujikan pada UTS)"
|
||
if kode_sub == "__uas__":
|
||
return "Sub-CPMK minggu 9–15 (diujikan pada UAS)"
|
||
if kode_sub in uraian:
|
||
return f"{kode_sub}: {uraian[kode_sub]}"
|
||
return kode_sub
|
||
|
||
d = {
|
||
"tahun": db.tahun_aktif(),
|
||
"identitas": {**f.get("identitas", {}), "nama": peta["nama"], "kode": peta["kode"]},
|
||
"deskripsi": f.get("deskripsi", ""),
|
||
"bahan_kajian": f.get("bahan_kajian", ""),
|
||
"media": f.get("media", ""),
|
||
"cpl": peta["cpls"],
|
||
"cpmk": [{"kode": c["kode"], "desk": c["desk"], "cpl": c["cpl"]} for c in peta["cpmks"]],
|
||
"sub": [{"kode": s["kode"], "uraian": s["uraian"], "cpmk": s["cpmk"]} for s in peta["subs"]],
|
||
"minggu": [{**m, "sub": sub_teks(m.get("sub", ""))} for m in f.get("minggu", [])],
|
||
"komponen": f.get("komponen", []),
|
||
"referensi": f.get("referensi", []),
|
||
"referensi_pendukung": f.get("referensi_pendukung", []),
|
||
}
|
||
isi = build_rps(d)
|
||
nama_file = re.sub(r"[^A-Za-z0-9_-]+", "_", f"RPS_{peta['kode']}_{peta['nama']}") + ".docx"
|
||
return Response(
|
||
content=isi,
|
||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
headers={"Content-Disposition": f'attachment; filename="{nama_file}"'},
|
||
)
|
||
|
||
|
||
# ═════════════════════════ TAHAP 2: NILAI & PENGUKURAN ═════════════════════════
|
||
|
||
|
||
def _konteks_nilai(request, kode, pesan="", peringatan=None):
|
||
peta = db.peta_mk(kode)
|
||
tersimpan = db.ambil_dok("nilai", kode)
|
||
pengaturan = db.ambil_pengaturan()
|
||
ambang = float(pengaturan["ambang"])
|
||
hasil, akhir = None, {}
|
||
if tersimpan:
|
||
hasil = hitung.rollup_mk(peta, tersimpan["data"], ambang)
|
||
if tersimpan["data"].get("mode") == "per_mahasiswa":
|
||
rps = db.ambil_rps(kode)
|
||
bobot_sub = hitung.bobot_sub_dari_rps(rps["data"] if rps else None)
|
||
akhir = {a["npm"]: a["akhir"]
|
||
for a in hitung.nilai_akhir_mahasiswa(tersimpan["data"], bobot_sub)}
|
||
# grid input langsung: gabungan daftar impor kaprodi + nilai tersimpan
|
||
terdaftar = db.mahasiswa_mk(kode)
|
||
skor_map, nama_map = {}, {}
|
||
if tersimpan and tersimpan["data"].get("mode") == "per_mahasiswa":
|
||
for m in tersimpan["data"]["mahasiswa"]:
|
||
skor_map[m.get("npm", "")] = m.get("skor") or {}
|
||
nama_map[m.get("npm", "")] = m.get("nama", "")
|
||
grid, ada = [], set()
|
||
for m in terdaftar:
|
||
grid.append({"npm": m["npm"], "nama": m["nama"], "kelas": m.get("kelas") or "",
|
||
"skor": skor_map.get(m["npm"], {})})
|
||
ada.add(m["npm"])
|
||
for npm, sk in skor_map.items():
|
||
if npm not in ada:
|
||
grid.append({"npm": npm, "nama": nama_map.get(npm, ""), "kelas": "",
|
||
"skor": sk})
|
||
return render(
|
||
request, "form_nilai.html",
|
||
{"peta": peta, "tersimpan": tersimpan, "hasil": hasil, "ambang": ambang,
|
||
"akhir": akhir, "tahun": db.tahun_aktif(), "pesan": pesan,
|
||
"terdaftar": terdaftar, "grid": grid,
|
||
"peringatan": peringatan or []},
|
||
)
|
||
|
||
|
||
def _gabung_nilai(lama: dict, baru: list[dict], sumber: str, oleh: str) -> dict:
|
||
"""Gabungkan unggahan ke data per-mahasiswa tersimpan (team teaching):
|
||
sel terisi memperbarui, sel kosong tidak menghapus nilai dosen lain,
|
||
mahasiswa yang tidak ada di file dibiarkan utuh."""
|
||
urut = [dict(m, skor=dict(m.get("skor") or {}))
|
||
for m in lama.get("mahasiswa", [])]
|
||
per_npm = {m["npm"]: m for m in urut if m.get("npm")}
|
||
for m in baru:
|
||
ada = per_npm.get(m.get("npm"))
|
||
if ada is None:
|
||
urut.append(m)
|
||
if m.get("npm"):
|
||
per_npm[m["npm"]] = m
|
||
else:
|
||
if m.get("nama"):
|
||
ada["nama"] = m["nama"]
|
||
ada["skor"].update(m.get("skor") or {})
|
||
riwayat = lama.get("sumber") or []
|
||
if isinstance(riwayat, str):
|
||
riwayat = [riwayat]
|
||
riwayat.append(f"{sumber} ({oleh})")
|
||
return {"mode": "per_mahasiswa", "mahasiswa": urut, "sumber": riwayat[-8:]}
|
||
|
||
|
||
@app.get("/nilai/{kode}", response_class=HTMLResponse)
|
||
def form_nilai(request: Request, kode: str, pesan: str = ""):
|
||
if not db.peta_mk(kode):
|
||
return RedirectResponse("/", status_code=303)
|
||
if not boleh_kelola(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_kelola(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_kelola(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_kelola(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_kelola(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_kelola(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"'})
|