Gabungkan aplikasi RPS & Portofolio OBE (dashboard jadi bagian aplikasi)

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>
This commit is contained in:
Power BI Dev
2026-07-18 12:05:28 +07:00
parent 3405dc21ff
commit 9be6bd03e3
42 changed files with 11366 additions and 53 deletions
+438
View File
@@ -0,0 +1,438 @@
# -*- coding: utf-8 -*-
"""
Generator Portofolio Mata Kuliah (.docx).
Struktur mengikuti `Contoh Portofolio MK 040126.xlsx`:
Cover · Lembar Pengesahan · A CPL · B CPMK & Sub-CPMK · C Korelasi CPLSub-CPMK
· D Rencana Penilaian & Tugas · E Realisasi Pembelajaran · F Penilaian
· G Evaluasi Ketercapaian · H Simpulan & Tindak Lanjut.
"""
import io
import os
import pathlib
from docx import Document
from docx.enum.section import WD_ORIENT
from docx.enum.table import WD_ALIGN_VERTICAL
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
DARK_BLUE = "1F4E79"
LIGHT_BLUE = "D6E4F0"
RED = "9A3324"
WHITE = "FFFFFF"
LOGO = pathlib.Path(__file__).resolve().parent.parent / "static" / "logo-untan.png"
FAKULTAS = os.environ.get("FAKULTAS", "FAKULTAS TEKNIK")
PRODI = os.environ.get("PRODI", "PROGRAM STUDI INFORMATIKA")
def _shd(cell, hex_color):
tc_pr = cell._tc.get_or_add_tcPr()
el = OxmlElement("w:shd")
el.set(qn("w:val"), "clear")
el.set(qn("w:color"), "auto")
el.set(qn("w:fill"), hex_color)
tc_pr.append(el)
def _ct(cell, text, bold=False, italic=False, size=9, color=None,
align=WD_ALIGN_PARAGRAPH.LEFT):
p = cell.paragraphs[0]
p.alignment = align
p.paragraph_format.space_before = Pt(1)
p.paragraph_format.space_after = Pt(1)
run = p.add_run()
for i, line in enumerate(str(text or "").split("\n")):
if i:
run.add_break()
run.add_text(line)
run.bold = bold
run.italic = italic
run.font.name = "Times New Roman"
run.font.size = Pt(size)
if color:
run.font.color.rgb = RGBColor.from_string(color)
cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
def _hdr(cell, text, size=9):
_shd(cell, DARK_BLUE)
_ct(cell, text, bold=True, color=WHITE, size=size, align=WD_ALIGN_PARAGRAPH.CENTER)
def _borders(table):
tbl_pr = table._tbl.tblPr
el = OxmlElement("w:tblBorders")
for sisi in ("top", "left", "bottom", "right", "insideH", "insideV"):
b = OxmlElement(f"w:{sisi}")
b.set(qn("w:val"), "single")
b.set(qn("w:sz"), "4")
b.set(qn("w:color"), "000000")
el.append(b)
tbl_pr.append(el)
def _f(x, nd=2):
return f"{x:.{nd}f}" if isinstance(x, (int, float)) else ""
def build_portofolio(d: dict) -> bytes:
doc = Document()
for sec in doc.sections:
sec.orientation = WD_ORIENT.LANDSCAPE
sec.page_width, sec.page_height = Cm(29.7), Cm(21.0)
sec.top_margin = sec.bottom_margin = Cm(2)
sec.left_margin = sec.right_margin = Cm(2)
ident = d.get("identitas", {})
dok = d.get("dokumen", {})
def para(text, size=11, bold=False, italic=False, align=WD_ALIGN_PARAGRAPH.LEFT,
before=6, after=3):
p = doc.add_paragraph()
p.alignment = align
p.paragraph_format.space_before = Pt(before)
p.paragraph_format.space_after = Pt(after)
r = p.add_run(text)
r.bold, r.italic = bold, italic
r.font.name = "Times New Roman"
r.font.size = Pt(size)
return p
def bagian(judul):
para(judul, size=12, bold=True, before=14)
# ── COVER ──
for _ in range(3):
doc.add_paragraph()
para("PORTOFOLIO MATA KULIAH", size=22, bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
para(f"{ident.get('nama', '')} ({ident.get('kode', '')})", size=16, bold=True,
align=WD_ALIGN_PARAGRAPH.CENTER)
doc.add_paragraph()
if LOGO.exists():
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.add_run().add_picture(str(LOGO), width=Cm(4.5))
doc.add_paragraph()
para(PRODI, size=13, bold=True, align=WD_ALIGN_PARAGRAPH.CENTER, after=0)
para(FAKULTAS, size=12, align=WD_ALIGN_PARAGRAPH.CENTER, before=0, after=0)
para("UNIVERSITAS TANJUNGPURA", size=12, align=WD_ALIGN_PARAGRAPH.CENTER,
before=0, after=0)
para(f"TAHUN AJARAN {d.get('tahun', '')}", size=12, align=WD_ALIGN_PARAGRAPH.CENTER,
before=0)
doc.paragraphs[-1].runs[-1].add_break(WD_BREAK.PAGE)
# ── LEMBAR PENGESAHAN ──
para("LEMBAR PENGESAHAN", size=14, bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
para(f"PORTOFOLIO MATA KULIAH — KURIKULUM OBE {dok.get('kurikulum_tahun', '')}",
size=11, align=WD_ALIGN_PARAGRAPH.CENTER)
doc.add_paragraph()
ti = doc.add_table(rows=5, cols=3)
_borders(ti)
isian = [
("Nama Dokumen", f"Portofolio MK {ident.get('nama', '')}"),
("Nomor Dokumen", dok.get("nomor", "") or ""),
("No. Revisi", dok.get("revisi", "") or ""),
("Tanggal Terbit", dok.get("tgl_terbit", "") or ""),
("Status Dokumen", dok.get("status_dokumen", "Master")),
]
for i, (k, v) in enumerate(isian):
_shd(ti.cell(i, 0), LIGHT_BLUE)
_ct(ti.cell(i, 0), k, bold=True, size=10)
_ct(ti.cell(i, 1), ":", size=10, align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(ti.cell(i, 2), v, size=10)
ti.rows[i].cells[0].width = Cm(5)
ti.rows[i].cells[1].width = Cm(0.7)
ti.rows[i].cells[2].width = Cm(12)
doc.add_paragraph()
tt = doc.add_table(rows=4, cols=3)
_borders(tt)
for ci, (peran, nama) in enumerate([
("DISUSUN OLEH\nKoordinator Mata Kuliah", ident.get("koordinator", "")),
("DIPERIKSA OLEH\nKetua Program Studi", ident.get("kaprodi", "")),
("DISAHKAN OLEH\nDekan", dok.get("dekan", "")),
]):
_hdr(tt.cell(0, ci), peran, size=10)
_ct(tt.cell(1, ci), "\n\n\n")
_ct(tt.cell(2, ci), f"( {nama or '______________________'} )",
align=WD_ALIGN_PARAGRAPH.CENTER, size=10)
_ct(tt.cell(3, ci), "NIP. ......................................",
align=WD_ALIGN_PARAGRAPH.CENTER, size=10)
doc.paragraphs[-1].runs[-1].add_break(WD_BREAK.PAGE) if doc.paragraphs[-1].runs else doc.add_page_break()
# ── A. CPL ──
bagian("A. CAPAIAN PEMBELAJARAN LULUSAN (CPL) YANG DIBEBANKAN PADA MK")
cpls = d.get("cpl", [])
ta = doc.add_table(rows=1 + len(cpls), cols=2)
_borders(ta)
_hdr(ta.cell(0, 0), "Kode CPL")
_hdr(ta.cell(0, 1), "Uraian CPL")
for i, c in enumerate(cpls, 1):
_ct(ta.cell(i, 0), c["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(ta.cell(i, 1), c["desk"])
ta.rows[i].cells[0].width = Cm(3)
ta.rows[i].cells[1].width = Cm(22)
# ── B. CPMK & Sub-CPMK ──
bagian("B. CPMK DAN SUB-CPMK")
cpmks = d.get("cpmk", [])
tb = doc.add_table(rows=1 + len(cpmks), cols=3)
_borders(tb)
_hdr(tb.cell(0, 0), "Kode CPMK")
_hdr(tb.cell(0, 1), "Uraian CPMK")
_hdr(tb.cell(0, 2), "CPL Didukung")
for i, c in enumerate(cpmks, 1):
_ct(tb.cell(i, 0), c["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tb.cell(i, 1), c["desk"])
_ct(tb.cell(i, 2), ", ".join(c.get("cpl", [])), align=WD_ALIGN_PARAGRAPH.CENTER)
doc.add_paragraph()
subs = d.get("sub", [])
ts = doc.add_table(rows=1 + len(subs), cols=3)
_borders(ts)
_hdr(ts.cell(0, 0), "Kode Sub-CPMK")
_hdr(ts.cell(0, 1), "Kemampuan Akhir Tiap Tahapan Belajar")
_hdr(ts.cell(0, 2), "CPMK")
for i, s in enumerate(subs, 1):
_ct(ts.cell(i, 0), s["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(ts.cell(i, 1), s["uraian"])
_ct(ts.cell(i, 2), s.get("cpmk", ""), align=WD_ALIGN_PARAGRAPH.CENTER)
# ── C. korelasi CPLSub-CPMK ──
bagian("C. KORELASI CPL TERHADAP SUB-CPMK (BOBOT PENILAIAN, %)")
korelasi = d.get("korelasi", {}) # {sub: {cpl: porsi}}
bobot_sub = d.get("bobot_sub", {})
minggu_sub = d.get("minggu_sub", {})
kode_cpl = [c["kode"] for c in cpls]
tc = doc.add_table(rows=1 + len(subs) + 1, cols=3 + len(kode_cpl))
_borders(tc)
_hdr(tc.cell(0, 0), "Sub-CPMK")
for j, cp in enumerate(kode_cpl):
_hdr(tc.cell(0, 1 + j), f"{cp}\n(%)")
_hdr(tc.cell(0, 1 + len(kode_cpl)), "Bobot\nPenilaian (%)")
_hdr(tc.cell(0, 2 + len(kode_cpl)), "Jumlah\nMinggu")
tot_cpl = {cp: 0.0 for cp in kode_cpl}
tot_bobot = 0.0
for i, s in enumerate(subs, 1):
_ct(tc.cell(i, 0), s["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
for j, cp in enumerate(kode_cpl):
v = korelasi.get(s["kode"], {}).get(cp)
_ct(tc.cell(i, 1 + j), _f(v, 1) if v else "", align=WD_ALIGN_PARAGRAPH.CENTER)
tot_cpl[cp] += v or 0
b = bobot_sub.get(s["kode"])
tot_bobot += b or 0
_ct(tc.cell(i, 1 + len(kode_cpl)), _f(b, 1) if b else "",
align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tc.cell(i, 2 + len(kode_cpl)), str(minggu_sub.get(s["kode"], "")),
align=WD_ALIGN_PARAGRAPH.CENTER)
last = len(subs) + 1
_shd(tc.cell(last, 0), LIGHT_BLUE)
_ct(tc.cell(last, 0), "Total", bold=True, align=WD_ALIGN_PARAGRAPH.RIGHT)
for j, cp in enumerate(kode_cpl):
_shd(tc.cell(last, 1 + j), LIGHT_BLUE)
_ct(tc.cell(last, 1 + j), _f(tot_cpl[cp], 1), bold=True,
align=WD_ALIGN_PARAGRAPH.CENTER)
_shd(tc.cell(last, 1 + len(kode_cpl)), LIGHT_BLUE)
_ct(tc.cell(last, 1 + len(kode_cpl)), _f(tot_bobot, 1), bold=True,
align=WD_ALIGN_PARAGRAPH.CENTER)
_shd(tc.cell(last, 2 + len(kode_cpl)), LIGHT_BLUE)
# ── D. rencana penilaian & tugas (dari RPS) ──
bagian("D. RENCANA PENILAIAN DAN RENCANA TUGAS (DARI RPS)")
rencana = d.get("rencana", [])
td = doc.add_table(rows=1 + max(len(rencana), 1), cols=4)
_borders(td)
for ci, h in enumerate(["Minggu ke-", "Sub-CPMK", "Bentuk Asesmen", "Bobot (%)"]):
_hdr(td.cell(0, ci), h)
for i, r in enumerate(rencana, 1):
_ct(td.cell(i, 0), str(r["minggu"]), align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(td.cell(i, 1), r["sub"])
_ct(td.cell(i, 2), r["asesmen"])
_ct(td.cell(i, 3), str(r["bobot"]), align=WD_ALIGN_PARAGRAPH.CENTER)
para("RPS lengkap diterbitkan sebagai dokumen terpisah dari aplikasi yang sama.",
size=9, italic=True)
# ── E. realisasi ──
bagian("E. REALISASI PEMBELAJARAN")
te = doc.add_table(rows=1, cols=1)
_borders(te)
_ct(te.cell(0, 0), d.get("realisasi", "") or "", size=10)
# ── F. penilaian ──
bagian("F. PENILAIAN MATA KULIAH")
nilai = d.get("nilai", {})
skor_sub = d.get("skor_sub", {})
if nilai.get("mode") == "per_mahasiswa":
mhs = nilai.get("mahasiswa", [])
akhir = {a["npm"]: a["akhir"] for a in d.get("akhir", [])}
kol_sub = [s["kode"] for s in subs]
tf = doc.add_table(rows=2 + len(mhs) + 3, cols=3 + len(kol_sub) + 1)
_borders(tf)
_hdr(tf.cell(0, 0), "No.")
_hdr(tf.cell(0, 1), "NPM")
_hdr(tf.cell(0, 2), "Nama")
for j, sk in enumerate(kol_sub):
_hdr(tf.cell(0, 3 + j), sk, size=7)
_hdr(tf.cell(0, 3 + len(kol_sub)), "Nilai\nAkhir", size=7)
# baris bobot
_shd(tf.cell(1, 0), LIGHT_BLUE)
_ct(tf.cell(1, 0), "Bobot", bold=True, size=8)
for j, sk in enumerate(kol_sub):
_shd(tf.cell(1, 3 + j), LIGHT_BLUE)
b = d.get("bobot_sub", {}).get(sk)
_ct(tf.cell(1, 3 + j), _f((b or 0) / 100, 2), size=8,
align=WD_ALIGN_PARAGRAPH.CENTER)
for i, m in enumerate(mhs, 2):
_ct(tf.cell(i, 0), str(i - 1), size=8, align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tf.cell(i, 1), m.get("npm", ""), size=8)
_ct(tf.cell(i, 2), m.get("nama", ""), size=8)
for j, sk in enumerate(kol_sub):
v = (m.get("skor") or {}).get(sk)
_ct(tf.cell(i, 3 + j), _f(v, 0) if v is not None else "",
size=8, align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tf.cell(i, 3 + len(kol_sub)), _f(akhir.get(m.get("npm", "")), 1),
size=8, bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
# baris statistik per sub
base = 2 + len(mhs)
for k, (label, kunci) in enumerate([("Rata-rata", "rata"),
("Maksimum", "max"), ("Minimum", "min")]):
_shd(tf.cell(base + k, 0), LIGHT_BLUE)
_ct(tf.cell(base + k, 0), label, bold=True, size=8)
for j, sk in enumerate(kol_sub):
info = skor_sub.get(sk)
if not info:
continue
if kunci == "rata":
v = info["rata"]
else:
xs = [(m.get("skor") or {}).get(sk) for m in mhs]
xs = [x for x in xs if x is not None]
v = (max(xs) if kunci == "max" else min(xs)) if xs else None
_ct(tf.cell(base + k, 3 + j), _f(v, 1), size=8,
align=WD_ALIGN_PARAGRAPH.CENTER)
else:
para("Mode input: rata-rata kelas per Sub-CPMK (BUKTI AGREGAT — tidak "
"memuat data per mahasiswa).", size=10, italic=True)
tf = doc.add_table(rows=1 + len(subs), cols=2)
_borders(tf)
_hdr(tf.cell(0, 0), "Sub-CPMK")
_hdr(tf.cell(0, 1), "Rata-rata Kelas")
for i, s in enumerate(subs, 1):
info = skor_sub.get(s["kode"])
_ct(tf.cell(i, 0), s["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tf.cell(i, 1), _f(info["rata"], 2) if info else "",
align=WD_ALIGN_PARAGRAPH.CENTER)
doc.add_paragraph()
skala = d.get("skala", [])
tsx = doc.add_table(rows=1 + len(skala), cols=3)
_borders(tsx)
_hdr(tsx.cell(0, 0), "Huruf")
_hdr(tsx.cell(0, 1), "Rentang Nilai")
_hdr(tsx.cell(0, 2), "Angka Mutu")
for i, s in enumerate(skala, 1):
_ct(tsx.cell(i, 0), s["huruf"], align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tsx.cell(i, 1), f"{s['lo']:g} {s['hi']:g}", align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tsx.cell(i, 2), _f(s["angka"], 2) if s["angka"] is not None else "",
align=WD_ALIGN_PARAGRAPH.CENTER)
# ── G. evaluasi ketercapaian ──
bagian("G. EVALUASI KETERCAPAIAN CPL DAN CPMK")
rollup = d.get("rollup", {})
ambang = d.get("ambang", 70)
baris_g = []
for cp in cpls:
subs_cp = [s for s in subs
if any(c["kode"] == s.get("cpmk") and cp["kode"] in c.get("cpl", [])
for c in cpmks)]
for k, s in enumerate(subs_cp):
info = skor_sub.get(s["kode"])
baris_g.append({
"cpl": cp["kode"] if k == 0 else "",
"sub": s["kode"],
"rata_sub": info["rata"] if info else None,
"rata_cpl": rollup.get("cpl", {}).get(cp["kode"], {}).get("skor")
if k == 0 else None,
"lulus": info["lulus_pct"] if info else None,
"pertama": k == 0,
"kode_cpl": cp["kode"],
})
tg = doc.add_table(rows=1 + max(len(baris_g), 1), cols=6)
_borders(tg)
for ci, h in enumerate(["CPL", "Sub-CPMK", "Rata-rata Nilai\nSub-CPMK",
"Rata-rata\nNilai CPL", "% Kelulusan\n(≥ ambang)",
"Kesimpulan"]):
_hdr(tg.cell(0, ci), h)
for i, b in enumerate(baris_g, 1):
_ct(tg.cell(i, 0), b["cpl"], bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tg.cell(i, 1), b["sub"], align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tg.cell(i, 2), _f(b["rata_sub"]), align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tg.cell(i, 3), _f(b["rata_cpl"]) if b["pertama"] else "",
bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tg.cell(i, 4), _f(b["lulus"], 1) if b["lulus"] is not None else "",
align=WD_ALIGN_PARAGRAPH.CENTER)
if b["pertama"]:
skor = b["rata_cpl"]
if skor is None:
ket, warna = "Belum terukur", None
elif skor >= ambang:
ket, warna = "TERCAPAI", None
else:
ket, warna = "BELUM TERCAPAI", RED
_ct(tg.cell(i, 5), ket, bold=True, color=warna,
align=WD_ALIGN_PARAGRAPH.CENTER)
para(f"Ambang ketercapaian: {ambang:g}. Metode: rata-rata sederhana (bagi rata) "
"sesuai Metodologi Pengukuran prodi.", size=9, italic=True)
# ── H. simpulan ──
bagian("H. SIMPULAN HASIL EVALUASI DAN TINDAK LANJUT")
stat = d.get("statistik")
if stat:
th = doc.add_table(rows=7, cols=2)
_borders(th)
_hdr(th.cell(0, 0), "Statistik Deskriptif (Nilai Akhir)")
_hdr(th.cell(0, 1), "Nilai")
for i, (k, v) in enumerate([
("Jumlah mahasiswa", stat["n"]), ("Mean", _f(stat["mean"])),
("Median", _f(stat["median"])), ("Standar deviasi", _f(stat["stdev"])),
("Minimum", _f(stat["min"])), ("Maksimum rentang",
f"{_f(stat['max'])} {_f(stat['range'])}"),
], 1):
_ct(th.cell(i, 0), k)
_ct(th.cell(i, 1), str(v), align=WD_ALIGN_PARAGRAPH.CENTER)
doc.add_paragraph()
frek = stat["frekuensi"]
tfr = doc.add_table(rows=2, cols=1 + len(frek))
_borders(tfr)
_hdr(tfr.cell(0, 0), "Huruf")
_shd(tfr.cell(1, 0), LIGHT_BLUE)
_ct(tfr.cell(1, 0), "Frekuensi", bold=True)
for j, (h, n) in enumerate(frek.items(), 1):
_hdr(tfr.cell(0, j), h)
_ct(tfr.cell(1, j), str(n), align=WD_ALIGN_PARAGRAPH.CENTER)
else:
para("Statistik nilai akhir tidak tersedia (mode bukti agregat).",
size=10, italic=True)
doc.add_paragraph()
kendala = [k for k in d.get("kendala", []) if k.get("kendala")]
tk = doc.add_table(rows=1 + max(len(kendala), 1), cols=3)
_borders(tk)
_hdr(tk.cell(0, 0), "No.")
_hdr(tk.cell(0, 1), "Kendala")
_hdr(tk.cell(0, 2), "Tindak Lanjut")
for i, k in enumerate(kendala, 1):
_ct(tk.cell(i, 0), str(i), align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tk.cell(i, 1), k.get("kendala", ""))
_ct(tk.cell(i, 2), k.get("tindak", ""))
if d.get("simpulan"):
doc.add_paragraph()
ts2 = doc.add_table(rows=1, cols=1)
_borders(ts2)
_ct(ts2.cell(0, 0), d["simpulan"], size=10)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()