# -*- 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 CPL–Sub-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 CPL–Sub-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["nim"]: 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), "NIM") _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("nim", ""), 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("nim", "")), 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()