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
View File
+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()
+335
View File
@@ -0,0 +1,335 @@
# -*- coding: utf-8 -*-
"""
Generator RPS Format APTIKOM 2024 — Prodi Informatika S1 UNTAN.
Diadaptasi dari buat_template_rps.py; gaya visual dipertahankan persis,
placeholder diganti data isian dosen.
"""
import io
import pathlib
from docx import Document
from docx.enum.table import WD_ALIGN_VERTICAL
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
DARK_BLUE = "1F4E79"
MID_BLUE = "2E75B6"
LIGHT_BLUE = "D6E4F0"
GREEN_LITE = "E2EFDA"
YELLOW = "FFF2CC"
WHITE = "FFFFFF"
LOGO = pathlib.Path(__file__).resolve().parent.parent / "static" / "logo-untan.png"
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=10, 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(text or "")
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, bg=DARK_BLUE, fg=WHITE):
_shd(cell, bg)
_ct(cell, text, bold=True, color=fg, align=WD_ALIGN_PARAGRAPH.CENTER)
def _widths(table, cms):
for row in table.rows:
for ci, w in enumerate(cms):
try:
row.cells[ci].width = Cm(w)
except IndexError:
pass
def build_rps(d: dict) -> bytes:
"""d = data RPS tersimpan (lihat main.py: bentuk_data_form)."""
doc = Document()
for sec in doc.sections:
sec.top_margin = Cm(2.5)
sec.bottom_margin = Cm(2.5)
sec.left_margin = Cm(3)
sec.right_margin = Cm(2)
def section_head(text):
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(8)
p.paragraph_format.space_after = Pt(3)
r = p.add_run(text)
r.bold = True
r.font.name = "Times New Roman"
r.font.size = Pt(11)
ident = d.get("identitas", {})
# ── logo UNTAN (dari RPS Template.docx resmi) ──
if LOGO.exists():
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.space_after = Pt(4)
p.add_run().add_picture(str(LOGO), width=Cm(2.6))
# ── judul ──
for txt, sz, bd in [
("RENCANA PEMBELAJARAN SEMESTER (RPS)", 14, True),
("Program Studi Informatika", 12, True),
("Fakultas Matematika dan Ilmu Pengetahuan Alam", 11, False),
("Universitas Tanjungpura", 11, False),
]:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.space_before = Pt(0)
p.paragraph_format.space_after = Pt(2)
r = p.add_run(txt)
r.bold = bd
r.font.name = "Times New Roman"
r.font.size = Pt(sz)
doc.add_paragraph()
# ── A. identitas ──
section_head("A. IDENTITAS MATA KULIAH")
sks = ident.get("sks", "")
sks_t = ident.get("sks_teori", "")
sks_p = ident.get("sks_praktikum", "")
sks_txt = f"{sks} SKS"
if sks_t or sks_p:
sks_txt += f" (Teori: {sks_t or '-'} SKS | Praktikum: {sks_p or '-'} SKS)"
rows_id = [
("Nama Mata Kuliah", ident.get("nama", "")),
("Kode Mata Kuliah", ident.get("kode", "")),
("Bobot SKS", sks_txt),
("Semester", ident.get("semester", "")),
("MK Prasyarat", ident.get("prasyarat", "") or ""),
("Dosen Pengampu", ident.get("pengampu", "")),
("Koordinator MK", ident.get("koordinator", "")),
("Ketua Program Studi", ident.get("kaprodi", "")),
("Tahun Ajaran", d.get("tahun", "")),
]
ta = doc.add_table(rows=len(rows_id), cols=3)
ta.style = "Table Grid"
for i, (lbl, val) in enumerate(rows_id):
c = ta.rows[i].cells
_shd(c[0], LIGHT_BLUE)
_ct(c[0], lbl, bold=True)
_ct(c[1], ":", align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(c[2], val)
_widths(ta, [5.5, 0.5, 9.5])
doc.add_paragraph()
# ── B. CPL ──
section_head("B. CAPAIAN PEMBELAJARAN LULUSAN (CPL) YANG DIBEBANKAN PADA MK")
cpls = d.get("cpl", [])
tb = doc.add_table(rows=1 + max(len(cpls), 1), cols=2)
tb.style = "Table Grid"
_hdr(tb.cell(0, 0), "Kode CPL")
_hdr(tb.cell(0, 1), "Deskripsi CPL")
for i, cpl in enumerate(cpls, 1):
_ct(tb.cell(i, 0), cpl["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tb.cell(i, 1), cpl["desk"])
_widths(tb, [2.5, 13])
doc.add_paragraph()
# ── C. CPMK ──
section_head("C. CAPAIAN PEMBELAJARAN MATA KULIAH (CPMK)")
cpmks = d.get("cpmk", [])
tc = doc.add_table(rows=1 + max(len(cpmks), 1), cols=3)
tc.style = "Table Grid"
_hdr(tc.cell(0, 0), "Kode CPMK")
_hdr(tc.cell(0, 1), "Deskripsi CPMK")
_hdr(tc.cell(0, 2), "CPL yang Didukung")
for i, c in enumerate(cpmks, 1):
_ct(tc.cell(i, 0), c["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tc.cell(i, 1), c["desk"])
_ct(tc.cell(i, 2), ", ".join(c.get("cpl", [])), align=WD_ALIGN_PARAGRAPH.CENTER)
_widths(tc, [2.5, 9, 4])
doc.add_paragraph()
# ── D. Sub-CPMK ──
section_head("D. SUB-CPMK (Kemampuan Akhir Tiap Tahapan Belajar)")
subs = d.get("sub", [])
td = doc.add_table(rows=1 + max(len(subs), 1), cols=3)
td.style = "Table Grid"
_hdr(td.cell(0, 0), "Kode Sub-CPMK")
_hdr(td.cell(0, 1), "Uraian Sub-CPMK")
_hdr(td.cell(0, 2), "CPMK yang\nDidukung")
for i, s in enumerate(subs, 1):
_ct(td.cell(i, 0), s["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(td.cell(i, 1), s["uraian"])
_ct(td.cell(i, 2), s.get("cpmk", ""), align=WD_ALIGN_PARAGRAPH.CENTER)
_widths(td, [3, 9, 3.5])
doc.add_paragraph()
# ── E. deskripsi ──
section_head("E. DESKRIPSI SINGKAT MATA KULIAH")
te = doc.add_table(rows=1, cols=1)
te.style = "Table Grid"
_ct(te.cell(0, 0), d.get("deskripsi", ""))
te.cell(0, 0).width = Cm(15.5)
doc.add_paragraph()
# ── F. tabel mingguan ──
section_head("F. RENCANA PEMBELAJARAN SEMESTER — Tabel F APTIKOM 2024")
minggu = d.get("minggu", [])
nrow = 2 + len(minggu) + 1
tf = doc.add_table(rows=nrow, cols=7)
tf.style = "Table Grid"
_hdr(tf.cell(0, 0), "Minggu\nke-")
_hdr(tf.cell(0, 1), "Sub-CPMK\n(Kemampuan Akhir\nyang Direncanakan)")
_hdr(tf.cell(0, 2), "Penilaian")
_hdr(tf.cell(0, 4), "Bentuk & Metode\nPembelajaran\n[Estimasi Waktu]")
_hdr(tf.cell(0, 5), "Materi Pembelajaran\n[Bahan Kajian]\n[Referensi]")
_hdr(tf.cell(0, 6), "Bobot\n(%)")
tf.cell(0, 2).merge(tf.cell(0, 3))
_hdr(tf.cell(1, 2), "Indikator\nPencapaian", bg=MID_BLUE)
_hdr(tf.cell(1, 3), "Kriteria &\nBentuk Penilaian", bg=MID_BLUE)
for ci in (0, 1, 4, 5, 6):
tf.cell(0, ci).merge(tf.cell(1, ci))
total_bobot = 0.0
for idx, m in enumerate(minggu):
ri = idx + 2
w = m.get("ke", idx + 1)
cells = [tf.cell(ri, ci) for ci in range(7)]
if w == 8:
for c in cells:
_shd(c, GREEN_LITE)
elif w == 16:
for c in cells:
_shd(c, YELLOW)
_ct(cells[0], str(w), align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(cells[1], m.get("sub", ""))
_ct(cells[2], m.get("indikator", ""))
_ct(cells[3], m.get("kriteria", ""))
_ct(cells[4], m.get("metode", ""))
_ct(cells[5], m.get("materi", ""))
bobot = m.get("bobot", "")
_ct(cells[6], str(bobot) if bobot != "" else "", align=WD_ALIGN_PARAGRAPH.CENTER)
try:
total_bobot += float(bobot)
except (TypeError, ValueError):
pass
last = nrow - 1
tf.cell(last, 0).merge(tf.cell(last, 5))
_shd(tf.cell(last, 0), LIGHT_BLUE)
_ct(tf.cell(last, 0), "Total Bobot Penilaian", bold=True, align=WD_ALIGN_PARAGRAPH.RIGHT)
_shd(tf.cell(last, 6), LIGHT_BLUE)
total_txt = f"{total_bobot:g}%"
_ct(tf.cell(last, 6), total_txt, bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
_widths(tf, [1.0, 3.0, 2.5, 2.5, 3.0, 3.0, 1.0])
doc.add_paragraph()
# ── G. rekap bobot ──
section_head("G. REKAP BOBOT PENILAIAN")
komponen = d.get("komponen", [])
tg = doc.add_table(rows=1 + len(komponen) + 1, cols=3)
tg.style = "Table Grid"
_hdr(tg.cell(0, 0), "Komponen Penilaian")
_hdr(tg.cell(0, 1), "Sub-CPMK yang Dinilai")
_hdr(tg.cell(0, 2), "Bobot (%)")
tot_komp = 0.0
for i, k in enumerate(komponen, 1):
_ct(tg.cell(i, 0), k.get("nama", ""))
_ct(tg.cell(i, 1), ", ".join(k.get("subs", [])))
b = k.get("bobot", "")
_ct(tg.cell(i, 2), str(b), align=WD_ALIGN_PARAGRAPH.CENTER)
try:
tot_komp += float(b)
except (TypeError, ValueError):
pass
li = len(komponen) + 1
for ci in range(3):
_shd(tg.cell(li, ci), LIGHT_BLUE)
_ct(tg.cell(li, 0), "Total", bold=True)
_ct(tg.cell(li, 2), f"{tot_komp:g}", bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
_widths(tg, [4, 9, 2.5])
doc.add_paragraph()
# ── H. referensi ──
section_head("H. DAFTAR REFERENSI")
refs = d.get("referensi", [])
th = doc.add_table(rows=1 + max(len(refs), 1), cols=2)
th.style = "Table Grid"
_hdr(th.cell(0, 0), "No.")
_hdr(th.cell(0, 1), "Referensi (Format APA)")
for i, ref in enumerate(refs, 1):
_ct(th.cell(i, 0), str(i), align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(th.cell(i, 1), ref)
_widths(th, [1, 14.5])
doc.add_paragraph()
# ── I. otorisasi ──
section_head("I. OTORISASI")
ti = doc.add_table(rows=4, cols=2)
ti.style = "Table Grid"
_shd(ti.cell(0, 0), LIGHT_BLUE)
_shd(ti.cell(0, 1), LIGHT_BLUE)
_ct(ti.cell(0, 0), "Koordinator Mata Kuliah", bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(ti.cell(0, 1), "Ketua Program Studi", bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(ti.cell(1, 0), "\n\n\n\n")
_ct(ti.cell(1, 1), "\n\n\n\n")
koor = ident.get("koordinator", "").strip()
kapr = ident.get("kaprodi", "").strip()
_ct(ti.cell(2, 0), f"( {koor or '_____________________________'} )",
align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(ti.cell(2, 1), f"( {kapr or '_____________________________'} )",
align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(ti.cell(3, 0), "NIP. ....................................", align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(ti.cell(3, 1), "NIP. ....................................", align=WD_ALIGN_PARAGRAPH.CENTER)
doc.add_paragraph()
# ── J. lampiran kisi-kisi (bukti constructive alignment) ──
section_head("J. LAMPIRAN — KISI-KISI ASESMEN (Komponen × Sub-CPMK)")
tj = doc.add_table(rows=1 + max(len(komponen), 1), cols=4)
tj.style = "Table Grid"
_hdr(tj.cell(0, 0), "Komponen")
_hdr(tj.cell(0, 1), "Sub-CPMK yang Dinilai")
_hdr(tj.cell(0, 2), "Porsi per Sub-CPMK (%)")
_hdr(tj.cell(0, 3), "Bobot Komponen (%)")
for i, k in enumerate(komponen, 1):
subs_k = k.get("subs", [])
try:
bobot = float(k.get("bobot", 0) or 0)
except (TypeError, ValueError):
bobot = 0.0
porsi = f"{bobot / len(subs_k):.1f}" if subs_k else ""
_ct(tj.cell(i, 0), k.get("nama", ""))
_ct(tj.cell(i, 1), ", ".join(subs_k) or "")
_ct(tj.cell(i, 2), f"{porsi} (bagi rata)" if subs_k else "",
align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tj.cell(i, 3), str(k.get("bobot", "")), align=WD_ALIGN_PARAGRAPH.CENTER)
_widths(tj, [3.5, 7, 3, 2])
p = doc.add_paragraph()
r = p.add_run(
"Porsi per Sub-CPMK dihitung bagi rata dari bobot komponen "
"(kebijakan default prodi); penyesuaian porsi per butir soal dicatat "
"pada dokumen portofolio mata kuliah."
)
r.italic = True
r.font.name = "Times New Roman"
r.font.size = Pt(9)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
+254
View File
@@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
"""
Generator RPS format resmi UNTAN (standar Dikti, SN Dikti Permendikbud 3/2020).
Pendekatan: file template resmi (`templates/rps_untan.docx`) dimuat apa adanya,
sel-selnya diisi data, dan baris digandakan seperlunya — sehingga tata letak,
merge sel, dan logo dijamin identik dengan template. Ditambah satu lampiran
kisi-kisi asesmen (bukti constructive alignment) setelah tabel utama.
Peta baris tabel template (39 baris x 18 kolom):
0 kop (logo | identitas institusi | kode dokumen)
2/3 label & isian: MK, kode, rumpun, bobot T/P, semester, tgl
4/5 otorisasi: pengembang, koordinator, kaprodi
6..11 CPL (header + 5 baris contoh)
12..14 CPMK (header + 2 baris contoh)
15..17 Sub-CPMK (header + 2 baris contoh)
18..27 peta CPL-CPMK, deskripsi, bahan kajian, pustaka U/P, media,
pengampu, MK syarat
28..30 header tabel mingguan
31..37 baris contoh mingguan (31 normal, 33 UTS, 37 UAS)
38 total bobot
"""
import copy
import io
import os
import pathlib
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Pt
from docx.table import _Row
TEMPLATE = pathlib.Path(__file__).resolve().parent / "templates" / "rps_untan.docx"
FAKULTAS = os.environ.get("FAKULTAS", "FAKULTAS TEKNIK")
PRODI = os.environ.get("PRODI", "PROGRAM STUDI INFORMATIKA")
def _set_text(cell, text, align=None, bold=None):
"""Ganti isi sel; gaya paragraf/tabel template dipertahankan."""
for p in cell.paragraphs[1:]:
p._p.getparent().remove(p._p)
p = cell.paragraphs[0]
for r in list(p.runs):
r._r.getparent().remove(r._r)
run = p.add_run()
for i, line in enumerate((text or "").split("\n")):
if i:
run.add_break()
run.add_text(line)
if bold is not None:
run.bold = bold
if align is not None:
p.alignment = align
def _unique_cells(row):
seen, out = None, []
for c in row.cells:
if c._tc is seen:
continue
seen = c._tc
out.append(c)
return out
def _clone_after(table, proto_tr, ref_tr):
"""Salin baris prototipe, kosongkan teksnya, sisipkan setelah ref_tr."""
new_tr = copy.deepcopy(proto_tr)
ref_tr.addnext(new_tr)
row = _Row(new_tr, table)
for c in _unique_cells(row):
_set_text(c, "")
return row
def _fmt(v, suffix=""):
return f"{v}{suffix}" if v not in (None, "") else ""
def _grid_borders(table):
"""Beri garis penuh pada tabel (template tidak punya style 'Table Grid')."""
tbl_pr = table._tbl.tblPr
borders = OxmlElement("w:tblBorders")
for sisi in ("top", "left", "bottom", "right", "insideH", "insideV"):
el = OxmlElement(f"w:{sisi}")
el.set(qn("w:val"), "single")
el.set(qn("w:sz"), "4")
el.set(qn("w:color"), "000000")
borders.append(el)
tbl_pr.append(borders)
def build_rps(d: dict) -> bytes:
doc = Document(str(TEMPLATE))
t = doc.tables[0]
ident = d.get("identitas", {})
# ── kop & identitas ──
_set_text(
t.cell(0, 3),
f"UNIVERSITAS TANJUNGPURA\n{FAKULTAS}\n{PRODI}",
align=WD_ALIGN_PARAGRAPH.CENTER, bold=True,
)
_set_text(t.cell(0, 16), "Kode Dokumen\n" + (ident.get("kode_dokumen") or ""))
_set_text(t.cell(3, 0), ident.get("nama", ""), bold=True)
_set_text(t.cell(3, 5), ident.get("kode", ""))
_set_text(t.cell(3, 7), ident.get("rumpun", ""))
_set_text(t.cell(3, 10), "T = " + (ident.get("sks_teori") or "-"))
_set_text(t.cell(3, 11), "P = " + (ident.get("sks_praktikum") or "-"))
_set_text(t.cell(3, 13), ident.get("semester", ""))
_set_text(t.cell(3, 15), ident.get("tgl_penyusunan", ""))
_set_text(t.cell(5, 5), ident.get("pengembang") or ident.get("pengampu", ""))
_set_text(t.cell(5, 10), ident.get("koordinator", ""))
_set_text(t.cell(5, 13), ident.get("kaprodi", ""))
trs = [r._tr for r in t.rows] # indeks baris template asli
def isi_daftar(proto_idx, hapus_idx, items, kolom_kode=2, kolom_isi=5):
"""Isi blok daftar (CPL/CPMK/Sub-CPMK): prototipe digandakan per item."""
proto = trs[proto_idx]
ref = proto
for kode, isi in items:
row = _clone_after(t, proto, ref)
_set_text(row.cells[kolom_kode], kode, bold=True)
_set_text(row.cells[kolom_isi], isi)
ref = row._tr
for i in hapus_idx:
trs[i].getparent().remove(trs[i])
proto.getparent().remove(proto)
# ── CPL / CPMK / Sub-CPMK (baris digandakan sesuai data) ──
isi_daftar(7, [8, 9, 10, 11], [(c["kode"], c["desk"]) for c in d.get("cpl", [])])
isi_daftar(13, [14], [(c["kode"], c["desk"]) for c in d.get("cpmk", [])])
isi_daftar(16, [17], [(s["kode"], s["uraian"]) for s in d.get("sub", [])])
# ── peta CPLCPMK & blok naratif ──
peta_txt = "\n".join(
f"{c['kode']}{', '.join(c.get('cpl', [])) or '-'}"
for c in d.get("cpmk", [])
)
_set_text(t.cell(18, 2), peta_txt)
_set_text(t.cell(19, 2), d.get("deskripsi", ""))
_set_text(t.cell(20, 2), d.get("bahan_kajian", ""))
utama = d.get("referensi", [])
pendukung = d.get("referensi_pendukung", [])
_set_text(t.cell(22, 2), "\n".join(f"{i}. {r}" for i, r in enumerate(utama, 1)))
_set_text(t.cell(24, 2), "\n".join(f"{i}. {r}" for i, r in enumerate(pendukung, 1)))
_set_text(t.cell(25, 2), d.get("media", ""))
_set_text(t.cell(26, 2), ident.get("pengampu", ""))
_set_text(t.cell(27, 2), ident.get("prasyarat") or "")
# ── tabel mingguan ──
proto_norm, proto_uts, proto_uas = trs[31], trs[33], trs[37]
total = 0.0
ref = trs[30] # sisipkan setelah baris nomor kolom (1)(2)...(9)
for m in d.get("minggu", []):
w = m.get("ke")
try:
total += float(m.get("bobot") or 0)
except (TypeError, ValueError):
pass
if w in (8, 16):
row = _clone_after(t, proto_uts if w == 8 else proto_uas, ref)
cells = _unique_cells(row)
_set_text(cells[0], str(w), align=WD_ALIGN_PARAGRAPH.CENTER, bold=True)
nama_ujian = "Ujian Tengah Semester (UTS)" if w == 8 else "Ujian Akhir Semester (UAS)"
gab = [nama_ujian]
if m.get("sub"):
gab.append(f"Cakupan: {m['sub']}")
if m.get("indikator"):
gab.append(f"Indikator: {m['indikator']}")
if m.get("metode"):
gab.append(f"Bentuk: {m['metode']}")
_set_text(cells[1], "\n".join(gab))
_set_text(cells[2], _fmt(m.get("bobot")), align=WD_ALIGN_PARAGRAPH.CENTER)
else:
row = _clone_after(t, proto_norm, ref)
cells = _unique_cells(row)
_set_text(cells[0], str(w), align=WD_ALIGN_PARAGRAPH.CENTER)
_set_text(cells[1], m.get("sub", ""))
_set_text(cells[2], m.get("indikator", ""))
_set_text(cells[3], m.get("kriteria", ""))
_set_text(cells[4], m.get("metode", "")) # tatap muka
_set_text(cells[5], m.get("daring", ""))
_set_text(cells[6], m.get("kegiatan", ""))
_set_text(cells[7], m.get("materi", ""))
_set_text(cells[8], _fmt(m.get("bobot")), align=WD_ALIGN_PARAGRAPH.CENTER)
ref = row._tr
for i in (31, 32, 33, 34, 35, 36, 37): # buang seluruh baris contoh
trs[i].getparent().remove(trs[i])
_set_text(
_unique_cells(_Row(trs[38], t))[-1],
f"{total:g}", align=WD_ALIGN_PARAGRAPH.CENTER, bold=True,
)
# ── buang paragraf petunjuk pengisian (catatan SN Dikti) setelah tabel ──
body = doc.element.body
tbl_seen = False
for el in list(body):
if el.tag == qn("w:tbl"):
tbl_seen = True
continue
if tbl_seen and el.tag == qn("w:p"):
body.remove(el)
# ── lampiran kisi-kisi asesmen ──
doc.add_page_break()
p = doc.add_paragraph()
r = p.add_run("LAMPIRAN — KISI-KISI ASESMEN (Komponen Penilaian × Sub-CPMK)")
r.bold = True
r.font.size = Pt(11)
komponen = d.get("komponen", [])
tk = doc.add_table(rows=1 + max(len(komponen), 1), cols=4)
_grid_borders(tk)
for ci, judul in enumerate(
["Komponen", "Sub-CPMK yang Dinilai", "Porsi per Sub-CPMK (%)", "Bobot (%)"]
):
_set_text(tk.cell(0, ci), judul, bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
for i, k in enumerate(komponen, 1):
subs_k = k.get("subs", [])
try:
bobot = float(k.get("bobot") or 0)
except (TypeError, ValueError):
bobot = 0.0
_set_text(tk.cell(i, 0), k.get("nama", ""))
_set_text(tk.cell(i, 1), ", ".join(subs_k) or "")
_set_text(
tk.cell(i, 2),
f"{bobot / len(subs_k):.1f} (bagi rata)" if subs_k else "",
align=WD_ALIGN_PARAGRAPH.CENTER,
)
_set_text(tk.cell(i, 3), str(k.get("bobot", "")), align=WD_ALIGN_PARAGRAPH.CENTER)
p = doc.add_paragraph()
r = p.add_run(
"Porsi per Sub-CPMK dihitung bagi rata dari bobot komponen (kebijakan "
"default prodi); penyesuaian porsi per butir soal dicatat pada dokumen "
"portofolio mata kuliah."
)
r.italic = True
r.font.size = Pt(9)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
Binary file not shown.