Files
OBE-Mapping/app/generator/portofolio_docx.py
T
Power BI Dev 9be6bd03e3 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>
2026-07-18 12:05:28 +07:00

439 lines
18 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 -*-
"""
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()