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
+28
View File
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""Autentikasi: hashing password (PBKDF2) & token sesi."""
import hashlib
import hmac
import secrets
_ITERASI = 200_000
def hash_password(password: str) -> str:
salt = secrets.token_hex(16)
h = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"),
bytes.fromhex(salt), _ITERASI)
return f"{salt}${h.hex()}"
def verifikasi_password(password: str, tersimpan: str) -> bool:
try:
salt, h = tersimpan.split("$", 1)
calon = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"),
bytes.fromhex(salt), _ITERASI)
return hmac.compare_digest(calon.hex(), h)
except (ValueError, TypeError):
return False
def token_baru() -> str:
return secrets.token_urlsafe(32)
+486
View File
@@ -0,0 +1,486 @@
# -*- coding: utf-8 -*-
"""Lapisan data: kurikulum (read-only, dari seed JSON) + isian RPS (SQLite)."""
import json
import os
import pathlib
import sqlite3
from datetime import datetime, timezone, timedelta
BASE = pathlib.Path(__file__).resolve().parent.parent
SEED = BASE / "seed" / "kurikulum.json"
DB_PATH = os.environ.get("DB_PATH", str(BASE / "data" / "app.db"))
WIB = timezone(timedelta(hours=7))
TAHUN_AJARAN_AKTIF = os.environ.get("TAHUN_AJARAN", "2026/2027 Ganjil")
def tahun_aktif() -> str:
"""Tahun ajaran aktif — dari pengaturan prodi, fallback env TAHUN_AJARAN."""
return ambil_pengaturan().get("tahun_aktif") or TAHUN_AJARAN_AKTIF
# ── kurikulum (dimuat sekali saat start) ─────────────────────────────────────
_kur = None
_sks = None
def kurikulum() -> dict:
global _kur
if _kur is None:
_kur = json.loads(SEED.read_text(encoding="utf-8"))
return _kur
def sks_mk() -> dict:
"""{kode: {'sks': x, 'semester': n}} dari seed/sks.json (boleh kosong)."""
global _sks
if _sks is None:
p = SEED.parent / "sks.json"
_sks = json.loads(p.read_text(encoding="utf-8")) if p.exists() else {}
return _sks
def daftar_mk() -> list[dict]:
"""Semua MK terurut nomor kurikulum, dengan ringkasan pemetaan."""
k = kurikulum()
out = []
for kode, mk in k["mk"].items():
sub_mk = k["sub"].get(kode, {"cpmks": {}})
n_sub = sum(len(c["subs"]) for c in sub_mk["cpmks"].values())
out.append(
{
"kode": kode,
"nama": mk["nama"],
"no": mk.get("no", 999),
"cpls": sorted(mk.get("cpls", [])),
"n_cpmk": len(sub_mk["cpmks"]),
"n_sub": n_sub,
}
)
return sorted(out, key=lambda m: m["no"])
def peta_mk(kode: str) -> dict | None:
"""Pemetaan lengkap satu MK: CPL, CPMK, dan Sub-CPMK yang dipikulnya."""
k = kurikulum()
mk = k["mk"].get(kode)
if not mk:
return None
sub_mk = k["sub"].get(kode, {"cpmks": {}})
cpmks = []
for ckey, cval in sub_mk["cpmks"].items():
info = k["cpmk"].get(ckey, {})
cpmks.append(
{
"kode": info.get("kode", ckey),
"key": ckey,
"desk": cval["desk"],
"cpl": info.get("cpl", []),
"subs": cval["subs"],
}
)
cpmks.sort(key=lambda c: c["kode"])
cpl_kode = sorted({cp for c in cpmks for cp in c["cpl"]} | set(mk.get("cpls", [])))
cpls = [{"kode": cp, "desk": k["cpl"][cp]["desk"]} for cp in cpl_kode if cp in k["cpl"]]
subs = [
{"kode": s["kode"], "uraian": s["uraian"], "cpmk": c["kode"]}
for c in cpmks
for s in c["subs"]
]
return {"kode": kode, "nama": mk["nama"], "cpls": cpls, "cpmks": cpmks, "subs": subs}
# ── SQLite: isian RPS ────────────────────────────────────────────────────────
def get_conn() -> sqlite3.Connection:
pathlib.Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
with get_conn() as conn:
for tabel in ("rps", "portofolio", "nilai"):
conn.execute(
f"""CREATE TABLE IF NOT EXISTS {tabel} (
mk_kode TEXT NOT NULL,
tahun TEXT NOT NULL,
data TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL DEFAULT '',
PRIMARY KEY (mk_kode, tahun)
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS rps_arsip (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mk_kode TEXT NOT NULL,
tahun TEXT NOT NULL,
data TEXT NOT NULL,
status TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS pengaturan (
kunci TEXT PRIMARY KEY,
nilai TEXT NOT NULL
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS pengguna (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('kaprodi','dosen')),
pw_hash TEXT NOT NULL,
dibuat TEXT NOT NULL
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS sesi (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
dibuat TEXT NOT NULL
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS penugasan (
user_id INTEGER NOT NULL,
mk_kode TEXT NOT NULL,
PRIMARY KEY (user_id, mk_kode)
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS mahasiswa (
npm TEXT NOT NULL,
nama TEXT NOT NULL,
mk_kode TEXT NOT NULL,
kelas TEXT NOT NULL DEFAULT '',
tahun TEXT NOT NULL,
PRIMARY KEY (npm, mk_kode, tahun)
)"""
)
# ── pengaturan prodi ─────────────────────────────────────────────────────────
PENGATURAN_DEFAULT = {
"tahun_aktif": TAHUN_AJARAN_AKTIF,
"ambang": "70",
"metode_antar_mk": "rata", # 'rata' (bagi rata) | 'sks' (tertimbang SKS)
# skala huruf mutu — dari contoh portofolio; format: HURUF|MIN|MAX|ANGKA
"skala_huruf": (
"A+|91|100|4.00\nA|86|90|3.75\nA-|81|85|3.50\nB+|76|80|3.25\n"
"B|71|75|3.00\nB-|66|70|2.75\nC+|61|65|2.50\nC|56|60|2.00\n"
"D|41|55|1.00\nE|0|40|0.00"
),
}
def ambil_pengaturan() -> dict:
with get_conn() as conn:
rows = conn.execute("SELECT kunci, nilai FROM pengaturan").fetchall()
out = dict(PENGATURAN_DEFAULT)
out.update({r["kunci"]: r["nilai"] for r in rows})
return out
def simpan_pengaturan(d: dict) -> None:
with get_conn() as conn:
for k, v in d.items():
if k in PENGATURAN_DEFAULT:
conn.execute(
"INSERT INTO pengaturan (kunci, nilai) VALUES (?,?) "
"ON CONFLICT(kunci) DO UPDATE SET nilai=excluded.nilai",
(k, str(v)),
)
_TABEL_DOK = ("rps", "portofolio", "nilai")
def simpan_dok(tabel: str, mk_kode: str, data: dict, status: str, updated_by: str,
tahun: str | None = None) -> None:
assert tabel in _TABEL_DOK
tahun = tahun or tahun_aktif()
now = datetime.now(WIB).strftime("%Y-%m-%d %H:%M")
payload = json.dumps(data, ensure_ascii=False)
with get_conn() as conn:
conn.execute(
f"""INSERT INTO {tabel} (mk_kode, tahun, data, status, updated_at, updated_by)
VALUES (?,?,?,?,?,?)
ON CONFLICT(mk_kode, tahun) DO UPDATE SET
data=excluded.data, status=excluded.status,
updated_at=excluded.updated_at, updated_by=excluded.updated_by""",
(mk_kode, tahun, payload, status, now, updated_by),
)
# snapshot arsip RPS setiap kali status ditetapkan 'selesai'
if tabel == "rps" and status == "selesai":
conn.execute(
"""INSERT INTO rps_arsip (mk_kode, tahun, data, status, updated_at, updated_by)
VALUES (?,?,?,?,?,?)""",
(mk_kode, tahun, payload, status, now, updated_by),
)
def hapus_dok(tabel: str, mk_kode: str, tahun: str | None = None) -> None:
assert tabel in _TABEL_DOK
tahun = tahun or tahun_aktif()
with get_conn() as conn:
conn.execute(f"DELETE FROM {tabel} WHERE mk_kode=? AND tahun=?",
(mk_kode, tahun))
def ambil_dok(tabel: str, mk_kode: str, tahun: str | None = None) -> dict | None:
assert tabel in _TABEL_DOK
tahun = tahun or tahun_aktif()
with get_conn() as conn:
row = conn.execute(
f"SELECT * FROM {tabel} WHERE mk_kode=? AND tahun=?", (mk_kode, tahun)
).fetchone()
if not row:
return None
return {
"data": json.loads(row["data"]),
"status": row["status"],
"updated_at": row["updated_at"],
"updated_by": row["updated_by"],
}
def status_semua(tabel: str = "rps", tahun: str | None = None) -> dict[str, dict]:
assert tabel in _TABEL_DOK
tahun = tahun or tahun_aktif()
with get_conn() as conn:
rows = conn.execute(
f"SELECT mk_kode, status, updated_at, updated_by FROM {tabel} WHERE tahun=?",
(tahun,),
).fetchall()
return {r["mk_kode"]: dict(r) for r in rows}
def semua_nilai(tahun: str | None = None) -> dict[str, dict]:
tahun = tahun or tahun_aktif()
with get_conn() as conn:
rows = conn.execute(
"SELECT mk_kode, data FROM nilai WHERE tahun=?", (tahun,)
).fetchall()
return {r["mk_kode"]: json.loads(r["data"]) for r in rows}
def daftar_tahun() -> list[str]:
"""Semua tahun ajaran yang pernah punya data (RPS/nilai/portofolio)."""
tahun = {tahun_aktif()}
with get_conn() as conn:
for tabel in _TABEL_DOK:
for r in conn.execute(f"SELECT DISTINCT tahun FROM {tabel}"):
tahun.add(r["tahun"])
return sorted(tahun)
def tahun_rps_lain(mk_kode: str) -> list[str]:
"""Tahun lain (selain aktif) yang punya RPS untuk MK ini — bahan duplikasi."""
aktif = tahun_aktif()
with get_conn() as conn:
rows = conn.execute(
"SELECT tahun FROM rps WHERE mk_kode=? AND tahun<>? ORDER BY tahun DESC",
(mk_kode, aktif),
).fetchall()
return [r["tahun"] for r in rows]
def dump_semua() -> dict:
"""Seluruh isi database sebagai dict (untuk cadangan JSON)."""
out = {}
with get_conn() as conn:
for tabel in (*_TABEL_DOK, "rps_arsip", "pengaturan",
"pengguna", "penugasan", "mahasiswa"):
rows = conn.execute(f"SELECT * FROM {tabel}").fetchall()
out[tabel] = [dict(r) for r in rows]
for tabel in _TABEL_DOK + ("rps_arsip",):
for r in out.get(tabel, []):
r["data"] = json.loads(r["data"])
return out
# ── pengguna, sesi, penugasan ────────────────────────────────────────────────
def ada_pengguna() -> bool:
with get_conn() as conn:
return conn.execute("SELECT 1 FROM pengguna LIMIT 1").fetchone() is not None
def buat_pengguna(username: str, nama: str, role: str, pw_hash: str) -> int | None:
now = datetime.now(WIB).strftime("%Y-%m-%d %H:%M")
try:
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pengguna (username, nama, role, pw_hash, dibuat) "
"VALUES (?,?,?,?,?)",
(username, nama, role, pw_hash, now),
)
return cur.lastrowid
except sqlite3.IntegrityError:
return None
def pengguna_by_username(username: str) -> dict | None:
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM pengguna WHERE username=?", (username,)
).fetchone()
return dict(row) if row else None
def pengguna_by_id(user_id: int) -> dict | None:
with get_conn() as conn:
row = conn.execute("SELECT * FROM pengguna WHERE id=?", (user_id,)).fetchone()
return dict(row) if row else None
def daftar_pengguna() -> list[dict]:
with get_conn() as conn:
rows = conn.execute(
"SELECT id, username, nama, role, dibuat FROM pengguna "
"ORDER BY role, nama"
).fetchall()
tugas = conn.execute(
"SELECT user_id, mk_kode FROM penugasan ORDER BY mk_kode"
).fetchall()
per_user: dict[int, list[str]] = {}
for t in tugas:
per_user.setdefault(t["user_id"], []).append(t["mk_kode"])
return [{**dict(r), "mk": per_user.get(r["id"], [])} for r in rows]
def hapus_pengguna(user_id: int) -> None:
with get_conn() as conn:
conn.execute("DELETE FROM pengguna WHERE id=?", (user_id,))
conn.execute("DELETE FROM sesi WHERE user_id=?", (user_id,))
conn.execute("DELETE FROM penugasan WHERE user_id=?", (user_id,))
def ubah_password(user_id: int, pw_hash: str) -> None:
with get_conn() as conn:
conn.execute("UPDATE pengguna SET pw_hash=? WHERE id=?", (pw_hash, user_id))
# sesi lama gugur agar pemegang password lama langsung keluar
conn.execute("DELETE FROM sesi WHERE user_id=?", (user_id,))
def set_penugasan(user_id: int, kode_list: list[str]) -> None:
with get_conn() as conn:
conn.execute("DELETE FROM penugasan WHERE user_id=?", (user_id,))
conn.executemany(
"INSERT OR IGNORE INTO penugasan (user_id, mk_kode) VALUES (?,?)",
[(user_id, k) for k in kode_list],
)
def mk_diampu(user_id: int) -> set[str]:
with get_conn() as conn:
rows = conn.execute(
"SELECT mk_kode FROM penugasan WHERE user_id=?", (user_id,)
).fetchall()
return {r["mk_kode"] for r in rows}
def pengampu_per_mk() -> dict[str, list[str]]:
"""{mk_kode: [nama dosen, ...]} — untuk kolom pengampu di beranda."""
with get_conn() as conn:
rows = conn.execute(
"SELECT p.mk_kode, u.nama FROM penugasan p "
"JOIN pengguna u ON u.id = p.user_id ORDER BY u.nama"
).fetchall()
out: dict[str, list[str]] = {}
for r in rows:
out.setdefault(r["mk_kode"], []).append(r["nama"])
return out
def buat_sesi(user_id: int, token: str) -> None:
now = datetime.now(WIB).strftime("%Y-%m-%d %H:%M")
with get_conn() as conn:
conn.execute(
"INSERT INTO sesi (token, user_id, dibuat) VALUES (?,?,?)",
(token, user_id, now),
)
def user_dari_sesi(token: str) -> dict | None:
with get_conn() as conn:
row = conn.execute(
"SELECT u.* FROM sesi s JOIN pengguna u ON u.id = s.user_id "
"WHERE s.token=?",
(token,),
).fetchone()
return dict(row) if row else None
def hapus_sesi(token: str) -> None:
with get_conn() as conn:
conn.execute("DELETE FROM sesi WHERE token=?", (token,))
# ── mahasiswa (kontrak MK per kelas, diimpor kaprodi) ────────────────────────
def ganti_mahasiswa(baris: list[dict], tahun: str | None = None) -> None:
"""Ganti seluruh daftar mahasiswa tahun tsb dengan hasil impor terbaru."""
tahun = tahun or tahun_aktif()
with get_conn() as conn:
conn.execute("DELETE FROM mahasiswa WHERE tahun=?", (tahun,))
conn.executemany(
"INSERT OR REPLACE INTO mahasiswa (npm, nama, mk_kode, kelas, tahun) "
"VALUES (?,?,?,?,?)",
[(b["npm"], b["nama"], b["mk_kode"], b.get("kelas", ""), tahun)
for b in baris],
)
def mahasiswa_mk(mk_kode: str, tahun: str | None = None) -> list[dict]:
tahun = tahun or tahun_aktif()
with get_conn() as conn:
rows = conn.execute(
"SELECT npm, nama, kelas FROM mahasiswa WHERE mk_kode=? AND tahun=? "
"ORDER BY kelas, npm",
(mk_kode, tahun),
).fetchall()
return [dict(r) for r in rows]
def ringkasan_mahasiswa(tahun: str | None = None) -> dict[str, dict]:
"""{mk_kode: {'n': jumlah, 'kelas': [daftar kelas]}} tahun aktif."""
tahun = tahun or tahun_aktif()
with get_conn() as conn:
rows = conn.execute(
"SELECT mk_kode, kelas, COUNT(*) AS n FROM mahasiswa WHERE tahun=? "
"GROUP BY mk_kode, kelas",
(tahun,),
).fetchall()
out: dict[str, dict] = {}
for r in rows:
d = out.setdefault(r["mk_kode"], {"n": 0, "kelas": []})
d["n"] += r["n"]
if r["kelas"]:
d["kelas"].append(r["kelas"])
return out
# kompatibilitas rute lama
def simpan_rps(mk_kode, data, status, updated_by, tahun=None):
simpan_dok("rps", mk_kode, data, status, updated_by, tahun)
def ambil_rps(mk_kode, tahun=None):
return ambil_dok("rps", mk_kode, tahun)
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.
+226
View File
@@ -0,0 +1,226 @@
# -*- coding: utf-8 -*-
"""
Mesin pengukuran ketercapaian CPL/CPMK (inti OBE).
Kebijakan (RANCANGAN.md §7, disepakati 13 Jul 2026):
- Nilai dicatat per mahasiswa per Sub-CPMK (mengikuti format contoh portofolio);
mode 'agregat' (rata-rata kelas per Sub-CPMK) diperbolehkan tapi ditandai.
- Roll-up dalam MK : Sub-CPMK → CPMK → CPL, rata-rata sederhana (bagi rata).
- Roll-up prodi : CPMK prodi = rata-rata lintas MK pengukurnya;
CPL = rata-rata CPMK pendukungnya. Opsi tertimbang SKS
tersedia via pengaturan 'metode_antar_mk' = 'sks'
(butuh data SKS; tanpa data SKS jatuh ke bagi rata).
- % kelulusan = persentase mahasiswa dengan skor ≥ ambang (hanya mode
per-mahasiswa; mode agregat tidak menghasilkan % kelulusan).
"""
import statistics
from . import db
def _rata(xs):
xs = [x for x in xs if x is not None]
return sum(xs) / len(xs) if xs else None
def parse_skala(teks: str) -> list[dict]:
"""'A+|91|100|4.00' per baris → [{'huruf','lo','hi','angka'}] terurut turun."""
out = []
for line in (teks or "").splitlines():
bag = [b.strip() for b in line.split("|")]
if len(bag) >= 3:
try:
out.append({
"huruf": bag[0], "lo": float(bag[1]), "hi": float(bag[2]),
"angka": float(bag[3]) if len(bag) > 3 else None,
})
except ValueError:
continue
return sorted(out, key=lambda s: -s["lo"])
def huruf_mutu(skor: float, skala: list[dict]) -> str:
for s in skala:
if s["lo"] <= skor <= s["hi"]:
return s["huruf"]
return "?"
def skor_sub(nilai_data: dict, ambang: float) -> dict:
"""Skor per Sub-CPMK dari data nilai tersimpan.
nilai_data = {"mode": "per_mahasiswa"|"agregat",
"mahasiswa": [{"npm","nama","skor":{sub: nilai}}] |
"agregat": {sub: rata}}
{sub: {"rata": x, "n": jumlah_mhs, "lulus_pct": y|None}}
"""
out = {}
if nilai_data.get("mode") == "per_mahasiswa":
per_sub: dict[str, list[float]] = {}
for m in nilai_data.get("mahasiswa", []):
for sub, v in (m.get("skor") or {}).items():
if v is not None:
per_sub.setdefault(sub, []).append(float(v))
for sub, xs in per_sub.items():
out[sub] = {
"rata": _rata(xs),
"n": len(xs),
"lulus_pct": 100.0 * sum(1 for x in xs if x >= ambang) / len(xs),
}
else:
for sub, v in (nilai_data.get("agregat") or {}).items():
if v in (None, ""):
continue
out[sub] = {"rata": float(v), "n": None, "lulus_pct": None}
return out
def bobot_sub_dari_rps(rps_data: dict | None) -> dict[str, float]:
"""Bobot (%) tiap Sub-CPMK = jumlah porsi bagi-rata dari komponen RPS (kisi-kisi)."""
bobot: dict[str, float] = {}
if not rps_data:
return bobot
for k in rps_data.get("komponen", []):
subs = k.get("subs", [])
try:
b = float(k.get("bobot") or 0)
except (TypeError, ValueError):
continue
for s in subs:
bobot[s] = bobot.get(s, 0.0) + b / len(subs)
return bobot
def nilai_akhir_mahasiswa(nilai_data: dict, bobot_sub: dict[str, float]) -> list[dict]:
"""Nilai akhir tertimbang bobot Sub-CPMK per mahasiswa (mode per_mahasiswa)."""
out = []
for m in nilai_data.get("mahasiswa", []):
skor = m.get("skor") or {}
pasangan = [(bobot_sub.get(s), v) for s, v in skor.items() if v is not None]
berbobot = [(b, v) for b, v in pasangan if b]
if berbobot:
total_b = sum(b for b, _ in berbobot)
akhir = sum(b * v for b, v in berbobot) / total_b
else: # RPS belum mengisi bobot → rata-rata sederhana
akhir = _rata([v for _, v in pasangan])
if akhir is not None:
out.append({"npm": m.get("npm", ""), "nama": m.get("nama", ""),
"akhir": akhir})
return out
def statistik_akhir(akhir: list[dict], skala: list[dict]) -> dict | None:
xs = [a["akhir"] for a in akhir]
if not xs:
return None
frek = {s["huruf"]: 0 for s in skala}
for x in xs:
h = huruf_mutu(x, skala)
frek[h] = frek.get(h, 0) + 1
return {
"n": len(xs),
"mean": statistics.mean(xs),
"median": statistics.median(xs),
"stdev": statistics.stdev(xs) if len(xs) > 1 else 0.0,
"min": min(xs),
"max": max(xs),
"range": max(xs) - min(xs),
"frekuensi": frek,
}
def rollup_mk(peta: dict, nilai_data: dict, ambang: float) -> dict:
"""Ketercapaian satu MK: per Sub-CPMK → CPMK → CPL (bagi rata)."""
sub = skor_sub(nilai_data, ambang)
cpmk: dict[str, dict] = {}
for c in peta["cpmks"]:
skor = _rata([sub[s["kode"]]["rata"] for s in c["subs"] if s["kode"] in sub])
n_ternilai = sum(1 for s in c["subs"] if s["kode"] in sub)
cpmk[c["kode"]] = {"skor": skor, "cpl": c["cpl"],
"n_sub": len(c["subs"]), "n_ternilai": n_ternilai}
cpl: dict[str, dict] = {}
for cp in peta["cpls"]:
skor = _rata([v["skor"] for v in cpmk.values()
if cp["kode"] in v["cpl"] and v["skor"] is not None])
cpl[cp["kode"]] = {"skor": skor}
return {"sub": sub, "cpmk": cpmk, "cpl": cpl,
"mode": nilai_data.get("mode", "agregat")}
def rollup_prodi(tahun: str | None = None) -> dict:
"""Ketercapaian tingkat prodi + kelengkapan bukti per CPL/CPMK."""
tahun = tahun or db.tahun_aktif()
pengaturan = db.ambil_pengaturan()
ambang = float(pengaturan["ambang"])
pakai_sks = pengaturan.get("metode_antar_mk") == "sks"
sks = db.sks_mk() if pakai_sks else {}
k = db.kurikulum()
semua = db.semua_nilai(tahun)
def rata_antar_mk(entri):
"""Skor CPMK lintas MK: bagi rata, atau tertimbang SKS bila diatur."""
if not entri:
return None
if pakai_sks and sks:
bobot = [(float(sks.get(mk, {}).get("sks") or 0), s) for mk, s, _ in entri]
total = sum(b for b, _ in bobot if b)
if total:
return sum(b * s for b, s in bobot if b) / total
return _rata([s for _, s, _ in entri])
# skor CPMK per MK
per_cpmk: dict[str, list[tuple[str, float, str]]] = {} # key cpmk → [(mk, skor, mode)]
for mk_kode, nilai_data in semua.items():
peta = db.peta_mk(mk_kode)
if not peta:
continue
hasil = rollup_mk(peta, nilai_data, ambang)
for kode_cpmk, v in hasil["cpmk"].items():
if v["skor"] is not None:
per_cpmk.setdefault(kode_cpmk, []).append(
(mk_kode, v["skor"], hasil["mode"])
)
# CPMK prodi = bagi rata lintas MK pengukur; kelengkapan = pengukur/pemikul
cpmk_prodi: dict[str, dict] = {}
for ckey, info in k["cpmk"].items():
kode = info.get("kode", ckey)
pemikul = info.get("mk", [])
entri = per_cpmk.get(kode, [])
cpmk_prodi[kode] = {
"kode": kode,
"cpl": info.get("cpl", []),
"skor": rata_antar_mk(entri),
"n_pemikul": len(pemikul),
"n_setor": len(entri),
"ada_agregat": any(m == "agregat" for _, _, m in entri),
"mk_terlemah": min(entri, key=lambda e: e[1])[:2] if entri else None,
}
# CPL = bagi rata CPMK pendukung
cpl_prodi: dict[str, dict] = {}
for kode_cpl, info in k["cpl"].items():
pendukung = [c for c in cpmk_prodi.values() if kode_cpl in c["cpl"]]
ternilai = [c for c in pendukung if c["skor"] is not None]
skor = _rata([c["skor"] for c in ternilai])
terlemah = min(ternilai, key=lambda c: c["skor"]) if ternilai else None
cpl_prodi[kode_cpl] = {
"kode": kode_cpl,
"desk": info["desk"],
"skor": skor,
"tercapai": (skor is not None and skor >= ambang),
"n_cpmk": len(pendukung),
"n_cpmk_ternilai": len(ternilai),
"n_mk_pemikul": sum(c["n_pemikul"] for c in pendukung),
"n_mk_setor": sum(c["n_setor"] for c in pendukung),
"ada_agregat": any(c["ada_agregat"] for c in ternilai),
"cpmk_terlemah": terlemah["kode"] if terlemah else None,
"mk_terlemah": (terlemah["mk_terlemah"] if terlemah else None),
}
return {"cpl": cpl_prodi, "cpmk": cpmk_prodi, "ambang": ambang,
"n_mk_setor": len(semua), "tahun": tahun,
"metode": "sks" if (pakai_sks and sks) else "rata"}
+1011
View File
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
# -*- coding: utf-8 -*-
"""Template & parser Excel nilai per mahasiswa per Sub-CPMK."""
import io
import openpyxl
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
BIRU = "1F4E79"
BIRU_MUDA = "D6E4F0"
def buat_template(peta: dict, tahun: str, mahasiswa: list[dict] | None = None) -> bytes:
"""Template xlsx: kolom NPM, Nama, Kelas, lalu satu kolom per Sub-CPMK.
Bila daftar mahasiswa (impor kaprodi) tersedia, baris NPM/Nama/Kelas
sudah terisi — dosen tinggal mengisi skornya.
"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Nilai"
subs = peta["subs"]
ws.append([f"NILAI PER SUB-CPMK — {peta['nama']} ({peta['kode']}) — {tahun}"])
ws.append(["Petunjuk: isi skor 0100 per mahasiswa per Sub-CPMK. "
"Jangan mengubah baris kode Sub-CPMK."])
ws.append([])
header = ["NPM", "Nama Mahasiswa", "Kelas"] + [s["kode"] for s in subs]
ws.append(header)
uraian = ["", "", ""] + [s["uraian"][:80] for s in subs]
ws.append(uraian)
for m in mahasiswa or []:
ws.append([m["npm"], m["nama"], m.get("kelas", "")])
for ci, _ in enumerate(header, 1):
c = ws.cell(row=4, column=ci)
c.font = Font(bold=True, color="FFFFFF")
c.fill = PatternFill("solid", fgColor=BIRU)
c.alignment = Alignment(horizontal="center")
u = ws.cell(row=5, column=ci)
u.font = Font(italic=True, size=8)
u.alignment = Alignment(wrap_text=True, vertical="top")
ws.column_dimensions[get_column_letter(ci)].width = (
14 if ci == 1 else 28 if ci == 2 else 9 if ci == 3 else 14)
ws.freeze_panes = "D6"
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def template_mahasiswa(daftar_mk: list[dict], tahun: str) -> bytes:
"""Template impor daftar mahasiswa: NPM | Nama | Kode MK | Kelas."""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Mahasiswa"
ws.append([f"DAFTAR MAHASISWA MENGONTRAK MK — {tahun}"])
ws.append(["Petunjuk: satu baris per kontrak (mahasiswa yang mengambil 5 MK "
"berarti 5 baris). Kode MK harus sesuai sheet 'Kode MK'."])
ws.append([])
ws.append(["NPM", "Nama Mahasiswa", "Kode MK", "Kelas"])
for ci, lebar in enumerate((16, 30, 14, 10), 1):
c = ws.cell(row=4, column=ci)
c.font = Font(bold=True, color="FFFFFF")
c.fill = PatternFill("solid", fgColor=BIRU)
c.alignment = Alignment(horizontal="center")
ws.column_dimensions[get_column_letter(ci)].width = lebar
ws.freeze_panes = "A5"
rujukan = wb.create_sheet("Kode MK")
rujukan.append(["Kode MK", "Nama Mata Kuliah"])
for ci in (1, 2):
c = rujukan.cell(row=1, column=ci)
c.font = Font(bold=True, color="FFFFFF")
c.fill = PatternFill("solid", fgColor=BIRU)
for mk in daftar_mk:
rujukan.append([mk["kode"], mk["nama"]])
rujukan.column_dimensions["A"].width = 14
rujukan.column_dimensions["B"].width = 46
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def parse_mahasiswa(isi: bytes, kode_mk_valid: set[str]) -> tuple[list[dict], list[str]]:
"""Baca xlsx daftar mahasiswa → (baris kontrak, peringatan).
Header dicari otomatis (baris yang memuat 'NPM'); kolom dikenali dari
judulnya: NPM, Nama, Kode MK, Kelas.
"""
wb = openpyxl.load_workbook(io.BytesIO(isi), read_only=True, data_only=True)
ws = wb["Mahasiswa"] if "Mahasiswa" in wb.sheetnames else wb.active
rows = list(ws.iter_rows(values_only=True))
kol = {}
header_i = None
for i, row in enumerate(rows):
vals = [str(v).strip().lower() if v is not None else "" for v in row]
if "npm" in vals:
header_i = i
for ci, v in enumerate(vals):
if v == "npm":
kol["npm"] = ci
elif "nama" in v:
kol.setdefault("nama", ci)
elif "kode" in v or v == "mk":
kol.setdefault("mk", ci)
elif "kelas" in v:
kol.setdefault("kelas", ci)
break
if header_i is None or "npm" not in kol or "mk" not in kol:
return [], ["Header tidak dikenali: pastikan ada kolom NPM, Nama, "
"Kode MK, dan Kelas (pakai template dari aplikasi)."]
def ambil(vals, kunci):
ci = kol.get(kunci)
if ci is None or ci >= len(vals) or vals[ci] is None:
return ""
v = vals[ci]
# NPM/kelas numerik dari Excel → buang '.0'
if isinstance(v, float) and v.is_integer():
v = int(v)
return str(v).strip()
baris, peringatan, tak_dikenal = [], [], set()
terlihat = set()
for row in rows[header_i + 1:]:
vals = list(row)
npm = ambil(vals, "npm")
mk = ambil(vals, "mk").upper()
if not npm or not mk:
continue
if mk not in kode_mk_valid:
tak_dikenal.add(mk)
continue
if (npm, mk) in terlihat:
continue
terlihat.add((npm, mk))
baris.append({"npm": npm, "nama": ambil(vals, "nama"),
"mk_kode": mk, "kelas": ambil(vals, "kelas")})
if tak_dikenal:
peringatan.append(
f"{len(tak_dikenal)} kode MK tidak dikenal dan dilewati: "
+ ", ".join(sorted(tak_dikenal)[:6])
+ ("" if len(tak_dikenal) > 6 else ""))
if not baris:
peringatan.append("Tidak ada baris kontrak yang terbaca.")
return baris, peringatan
def parse_unggahan(isi: bytes, kode_sub_valid: set[str]) -> tuple[list[dict], list[str]]:
"""Baca xlsx nilai → (mahasiswa, peringatan).
Baris header dicari otomatis: baris yang memuat 'NPM'. Kolom Sub-CPMK
dicocokkan dengan kode yang valid untuk MK tsb.
"""
wb = openpyxl.load_workbook(io.BytesIO(isi), read_only=True, data_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
header_i, kolom_sub = None, {}
for i, row in enumerate(rows):
vals = [str(v).strip() if v is not None else "" for v in row]
if "NPM" in vals:
header_i = i
for ci, v in enumerate(vals):
if v in kode_sub_valid:
kolom_sub[ci] = v
npm_i = vals.index("NPM")
nama_i = npm_i + 1
break
if header_i is None or not kolom_sub:
return [], ["Header tidak dikenali: pastikan memakai template dari aplikasi "
"(ada kolom NPM dan kode Sub-CPMK)."]
mahasiswa, peringatan = [], []
for row in rows[header_i + 1:]:
vals = list(row)
npm = str(vals[npm_i]).strip() if npm_i < len(vals) and vals[npm_i] is not None else ""
nama = str(vals[nama_i]).strip() if nama_i < len(vals) and vals[nama_i] is not None else ""
skor = {}
for ci, sub in kolom_sub.items():
v = vals[ci] if ci < len(vals) else None
if v is None or v == "":
continue
try:
x = float(v)
except (TypeError, ValueError):
continue
if 0 <= x <= 100:
skor[sub] = x
else:
peringatan.append(f"{npm or nama}: nilai {sub} = {x} di luar 0100, dilewati.")
if skor and (npm or nama):
mahasiswa.append({"npm": npm, "nama": nama, "skor": skor})
if not mahasiswa:
peringatan.append("Tidak ada baris nilai yang terbaca.")
hilang = kode_sub_valid - {s for m in mahasiswa for s in m["skor"]}
if mahasiswa and hilang:
peringatan.append(
f"{len(hilang)} Sub-CPMK tanpa nilai sama sekali: "
+ ", ".join(sorted(hilang)[:8]) + ("" if len(hilang) > 8 else "")
)
return mahasiswa, peringatan
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+265
View File
@@ -0,0 +1,265 @@
/* ─── Arah visual A · Dokumen Dinas ───────────────────────────────
Bahasa visual tata naskah resmi: kop bergaris ganda, Times New
Roman, tabel bergaris penuh, biru 1F4E79 dari template Word RPS,
status berupa stempel. Tanpa framework. */
:root {
--paper: #ffffff;
--desk: #ececec; /* latar di luar "kertas" */
--ink: #1a1a1a;
--blue: #1f4e79;
--blue-fill: #d6e4f0;
--row-alt: #f3f7fb;
--red: #9a3324;
--grey: #767676;
--green-fill: #e2efda;
--yellow-fill: #fff2cc;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--desk);
color: var(--ink);
font-family: "Times New Roman", Times, serif;
font-size: 15px;
line-height: 1.45;
}
/* "kertas" landscape penuh — memakai seluruh lebar layar */
.kertas {
max-width: none;
margin: 12px 16px 44px;
background: var(--paper);
border: 1px solid #c9c9c9;
padding: 26px 40px 40px;
}
.kop-teks, .kop { max-width: none; }
/* ── kop: logo kiri, teks tengah (tata naskah dinas) ── */
.kop { display: flex; align-items: center; gap: 16px; text-align: center; }
.kop-logo { flex: 0 0 74px; }
.kop-imbang { flex: 0 0 74px; } /* penyeimbang agar teks tepat di tengah */
.kop-teks { flex: 1; }
.kop .uni { font-size: 15px; letter-spacing: .06em; }
.kop .prodi{ font-size: 21px; font-weight: bold; letter-spacing: .02em; }
.kop .app { font-size: 13px; font-style: italic; margin-top: 2px; }
.kop-rule { border: none; border-top: 2.5px solid var(--ink); margin: 14px 0 2px; }
.kop-rule2 { border: none; border-top: 1px solid var(--ink); margin: 0 0 24px; }
/* ── menu utama (di bawah kop) ── */
.menu-baris {
display: flex; justify-content: space-between; align-items: baseline;
gap: 16px; flex-wrap: wrap; margin: -16px 0 22px;
padding: 7px 0; border-bottom: 1px solid #c9c9c9;
font-size: 14px;
}
.menu-kiri { display: flex; gap: 4px 18px; flex-wrap: wrap; }
.menu-baris a { color: var(--blue); text-decoration: none; }
.menu-baris a:hover { text-decoration: underline; }
.menu-baris a.aktif {
color: var(--ink); font-weight: bold;
border-bottom: 2px solid var(--blue); text-decoration: none;
}
.menu-kanan { font-size: 13px; color: var(--grey); }
/* ── kotak login ── */
.kertas { min-height: calc(100vh - 56px); }
/* halaman login: kotak di tengah vertikal, kaki menempel di dasar kertas */
.kertas:has(.login-kotak) { display: flex; flex-direction: column; }
.login-kotak {
width: 100%; max-width: 460px; margin: 40px auto 60px;
border: 1px solid var(--ink); padding: 26px 30px 30px;
}
.kertas:has(.login-kotak) .login-kotak { margin: auto; }
.login-kotak h1 { text-align: center; }
.login-kotak label { display: block; font-size: 14px; margin: 12px 0 3px; }
/* ── navigasi & meta ── */
.nav-baris {
display: flex; justify-content: space-between; align-items: baseline;
gap: 16px; flex-wrap: wrap; margin-bottom: 18px; font-size: 14px;
}
.nav-baris a { color: var(--blue); }
.kpi-baris { display: flex; gap: 26px; flex-wrap: wrap; margin-bottom: 20px; font-size: 14px; }
.kpi-baris b { font-size: 17px; }
.kpi-baris span { color: var(--grey); }
.kpi-pct {
font-family: "Segoe UI", Arial, sans-serif; font-size: 11px; font-weight: 700;
padding: 1px 6px; border: 1.5px solid; white-space: nowrap;
}
.kpi-baris span.kpi-hijau { color: #205020; border-color: #205020; background: var(--green-fill); }
.kpi-baris span.kpi-kuning { color: #7a5c00; border-color: #7a5c00; background: var(--yellow-fill); }
.kpi-baris span.kpi-merah { color: var(--red); border-color: var(--red); background: #f6ddd8; }
/* ── judul bagian (meniru section_head dokumen) ── */
h1 { font-size: 19px; margin: 0 0 14px; }
h2.bagian {
font-size: 15px; font-weight: bold; margin: 26px 0 8px;
}
.keterangan { font-size: 13px; font-style: italic; color: var(--grey); margin: 4px 0 10px; }
/* ── tabel gaya "Table Grid" Word ── */
table.grid { border-collapse: collapse; width: 100%; font-size: 14px; }
table.grid th {
background: var(--blue); color: #fff; font-weight: bold;
padding: 6px 9px; border: 1px solid var(--ink); text-align: left;
}
table.grid th.sub { background: #2e75b6; }
table.grid td { border: 1px solid var(--ink); padding: 6px 9px; vertical-align: top; }
table.grid tr:nth-child(even) td { background: var(--row-alt); }
table.grid td.tengah, table.grid th.tengah { text-align: center; }
table.grid td.label { background: var(--blue-fill); font-weight: bold; width: 220px; }
table.grid tr.uts td { background: var(--green-fill); }
table.grid tr.uas td { background: var(--yellow-fill); }
table.grid tr.total td { background: var(--blue-fill); font-weight: bold; }
.gulir { overflow-x: auto; }
/* header tabel yang bisa diurut (tabel.js) */
table.grid th.bisa-urut { cursor: pointer; user-select: none; }
table.grid th.bisa-urut:hover { background: #2e75b6; }
table.grid th.bisa-urut[data-arah="asc"]::after { content: " ▲"; font-size: 10px; }
table.grid th.bisa-urut[data-arah="desc"]::after { content: " ▼"; font-size: 10px; }
/* ── cara input alternatif (dilipat) ── */
details.cara-alt { margin-top: 26px; }
details.cara-alt summary {
font-size: 15px; font-weight: bold; cursor: pointer; margin-bottom: 8px;
}
details.cara-alt summary:hover { color: var(--blue); }
/* ── baris filter tabel ── */
.filter-baris {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: 0 0 10px; font-size: 14px;
}
/* ── stempel status ── */
.stamp {
display: inline-block; font-family: "Segoe UI", Arial, sans-serif;
font-size: 10.5px; font-weight: 700; letter-spacing: .14em;
padding: 2px 9px; transform: rotate(-2deg); white-space: nowrap;
}
.stamp-ok { color: var(--blue); border: 2px solid var(--blue); }
.stamp-draft { color: var(--grey); border: 2px dashed var(--grey); }
.stamp-no { color: var(--red); border: 2px solid var(--red); }
/* ── form ── */
input[type="text"], input[type="number"], input[type="password"], textarea, select {
font-family: "Times New Roman", Times, serif;
font-size: 14px; color: var(--ink);
border: 1px solid #999; background: #fff;
padding: 4px 6px; width: 100%;
}
textarea { resize: vertical; }
input:focus, textarea:focus, select:focus {
outline: 2px solid var(--blue); outline-offset: 0;
}
td.isian { padding: 2px !important; background: #fff !important; }
td.isian input, td.isian textarea, td.isian select { border: none; }
td.isian input:focus, td.isian textarea:focus, td.isian select:focus {
outline: 2px solid var(--blue); outline-offset: -2px;
}
.tombol {
font-family: "Times New Roman", Times, serif; font-size: 14px;
background: var(--blue); color: #fff; border: 1px solid var(--blue);
padding: 7px 18px; cursor: pointer;
}
.tombol.garis { background: transparent; color: var(--blue); }
.tombol.selesai { background: #205020; border-color: #205020; }
.tombol:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }
a.tombol { text-decoration: none; display: inline-block; }
.aksi-baris {
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
margin-top: 26px; padding-top: 16px; border-top: 1px solid #c9c9c9;
}
.aksi-baris .spacer { flex: 1; }
/* peringatan validasi */
.peringatan {
border: 2px solid var(--red); color: var(--red);
padding: 8px 12px; font-size: 14px; margin: 12px 0;
}
.info {
border: 1px solid var(--blue); background: var(--blue-fill);
padding: 8px 12px; font-size: 14px; margin: 12px 0;
}
#total-bobot-f.salah, #total-bobot-g.salah { color: var(--red); font-weight: bold; }
/* kelompok checkbox sub-cpmk */
.pilih-sub { border: 1px solid #999; padding: 6px 8px; max-height: 150px; overflow-y: auto; background: #fff; }
.pilih-sub label { display: block; font-size: 13px; margin: 2px 0; }
.pilih-sub input { width: auto; margin-right: 6px; }
/* ── dasbor monitoring (/pantau) ── */
.grafik { width: 100%; max-width: 940px; height: auto; display: block; margin: 6px 0 10px; }
.grafik text.sumbu {
font-family: "Segoe UI", Arial, sans-serif; font-size: 11px; fill: #555;
}
.grafik text.sumbu.pudar { fill: #999; }
.grafik text.nilai-bar {
font-family: "Segoe UI", Arial, sans-serif; font-size: 11px; fill: var(--ink);
paint-order: stroke; stroke: #fff; stroke-width: 3px; stroke-linejoin: round;
}
.grafik g.bar rect[fill="transparent"] { transition: fill .1s; }
.grafik g.bar:hover rect[fill="transparent"] { fill: var(--row-alt); }
.legenda {
display: flex; gap: 18px; flex-wrap: wrap; align-items: center;
font-family: "Segoe UI", Arial, sans-serif; font-size: 12px; color: #555;
margin: 2px 0 4px;
}
.legenda .chip {
display: inline-block; width: 12px; height: 12px; border-radius: 2px;
vertical-align: -1px; margin-right: 5px;
}
.legenda .chip-kosong {
display: inline-block; width: 12px; height: 12px; border-radius: 2px;
border: 1px dashed var(--grey); vertical-align: -1px; margin-right: 5px;
}
.legenda .chip-garis {
display: inline-block; width: 18px; border-top: 2px dashed var(--red);
vertical-align: 3px; margin-right: 5px;
}
.proses-baris { display: flex; align-items: center; gap: 10px; margin: 6px 0; max-width: 940px; }
.proses-label { flex: 0 0 90px; font-size: 14px; }
.proses-jalur { flex: 1; display: flex; gap: 2px; height: 26px; }
.proses-jalur .seg {
display: flex; align-items: center; justify-content: center;
font-family: "Segoe UI", Arial, sans-serif; font-size: 12px;
border-radius: 2px; min-width: 20px; overflow: hidden;
}
.proses-jalur .seg-kosong { border: 1px dashed #bbb; color: var(--grey); }
.tren-kisi {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px; margin: 8px 0 4px;
}
.tren-kartu {
border: 1px solid #ccc; padding: 8px 10px 4px;
display: flex; align-items: center; gap: 10px;
font-family: "Segoe UI", Arial, sans-serif; font-size: 12px;
}
.tren-kartu b { flex: 0 0 46px; font-size: 12px; }
.tren-svg { flex: 1; height: 46px; }
.tren-akhir { font-weight: 700; font-size: 13px; }
.perhatian-kisi {
display: grid; grid-template-columns: 1fr 1fr; gap: 24px; align-items: start;
}
@media (max-width: 900px) { .perhatian-kisi { grid-template-columns: 1fr; } }
.kaki {
margin-top: 28px; display: flex; justify-content: space-between; gap: 12px;
flex-wrap: wrap; font-size: 12.5px; color: var(--grey); font-style: italic;
}
@media print {
body { background: #fff; }
.kertas { border: none; margin: 0; padding: 0; }
.aksi-baris, .nav-baris { display: none; }
}
+58
View File
@@ -0,0 +1,58 @@
/* Urutkan tabel.grid dengan klik header (naik/turun).
Tabel di dalam <form> dilewati (baris berisi isian, urutan tak relevan);
baris .total selalu tetap di bawah. */
(function () {
"use strict";
function nilaiSel(tr, i) {
var td = tr.cells[i];
return td ? td.textContent.trim() : "";
}
function sebagaiAngka(t) {
// "76,2", "86 %", "18/49" → angka; "—"/kosong → null
var m = t.replace(",", ".").match(/-?\d+(\.\d+)?/);
return m ? parseFloat(m[0]) : null;
}
function pasang(tabel) {
var thead = tabel.tHead;
var tbody = tabel.tBodies[0];
if (!thead || !tbody || thead.rows.length !== 1) return;
Array.prototype.forEach.call(thead.rows[0].cells, function (th, i) {
th.classList.add("bisa-urut");
th.addEventListener("click", function () {
var naik = th.dataset.arah !== "asc";
Array.prototype.forEach.call(thead.rows[0].cells, function (h) {
delete h.dataset.arah;
h.removeAttribute("aria-sort");
});
th.dataset.arah = naik ? "asc" : "desc";
th.setAttribute("aria-sort", naik ? "ascending" : "descending");
var baris = Array.prototype.slice.call(tbody.rows);
var biasa = baris.filter(function (tr) { return !tr.classList.contains("total"); });
var total = baris.filter(function (tr) { return tr.classList.contains("total"); });
biasa.sort(function (a, b) {
var ta = nilaiSel(a, i), tb = nilaiSel(b, i);
var na = sebagaiAngka(ta), nb = sebagaiAngka(tb);
var hasil;
if (na !== null && nb !== null) hasil = na - nb;
else if (na === null && nb === null) hasil = ta.localeCompare(tb, "id");
else hasil = na === null ? 1 : -1; // sel kosong/— selalu di akhir
return naik ? hasil : -hasil;
});
biasa.concat(total).forEach(function (tr) { tbody.appendChild(tr); });
});
});
}
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll("table.grid").forEach(function (t) {
if (!t.closest("form")) pasang(t);
});
});
})();
+121
View File
@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block judul %}Kelola Akun &amp; Mahasiswa — RPS &amp; Portofolio UNTAN{% endblock %}
{% block isi %}
<h1>Kelola Akun &amp; Mahasiswa</h1>
{% if pesan %}<div class="info">{{ pesan }}</div>{% endif %}
<h2 class="bagian">A. Akun Pengguna &amp; Penugasan Mata Kuliah</h2>
<p class="keterangan">Dosen hanya dapat mengedit RPS/portofolio dan melihat nilai
mahasiswa pada MK yang ditugaskan kepadanya. Kaprodi dapat mengakses semuanya.</p>
<div class="gulir">
<table class="grid">
<thead>
<tr>
<th style="width:170px">Nama</th>
<th style="width:160px">NIP</th>
<th style="width:90px">Peran</th>
<th>MK yang Diampu</th>
<th style="width:340px">Tindakan</th>
</tr>
</thead>
<tbody>
{% for p in pengguna %}
<tr>
<td>{{ p.nama }}</td>
<td>{{ p.username }}</td>
<td>{{ 'Kaprodi' if p.role == 'kaprodi' else 'Dosen' }}</td>
<td>
{% if p.role == 'kaprodi' %}
<i style="color:var(--grey)">semua MK</i>
{% else %}
{{ p.mk|join(', ') if p.mk else '—' }}
<details style="margin-top:6px">
<summary style="cursor:pointer;color:var(--blue);font-size:13px">Ubah penugasan…</summary>
<form method="post" action="/admin/pengguna/{{ p.id }}/tugas">
<div class="pilih-sub" style="max-height:220px;margin:6px 0">
{% for mk in daftar %}
<label><input type="checkbox" name="mk" value="{{ mk.kode }}"
{% if mk.kode in p.mk %}checked{% endif %}> {{ mk.kode }} — {{ mk.nama }}</label>
{% endfor %}
</div>
<button class="tombol garis" type="submit" style="padding:4px 12px;font-size:13px">Simpan Penugasan</button>
</form>
</details>
{% endif %}
</td>
<td>
<form method="post" action="/admin/pengguna/{{ p.id }}/reset"
style="display:inline">
<button class="tombol garis" type="submit"
style="padding:4px 10px;font-size:13px">Reset Password ke NIP</button>
</form>
{% if p.id != user.id %}
<form method="post" action="/admin/pengguna/{{ p.id }}/hapus"
style="display:inline" onsubmit="return confirm('Hapus akun {{ p.nama }} ({{ p.username }})?')">
<button class="tombol garis" type="submit"
style="padding:4px 10px;font-size:13px;color:var(--red);border-color:var(--red)">Hapus</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<h2 class="bagian">B. Buat Akun Baru</h2>
<p class="keterangan">NIP menjadi username sekaligus password awal — dosen langsung
bisa masuk dengan NIP-nya.</p>
<form method="post" action="/admin/pengguna" class="aksi-baris"
style="border-top:none;padding-top:0;margin-top:8px">
<label>Nama lengkap <input type="text" name="nama" required style="width:230px"></label>
<label>NIP <input type="text" name="nip" required style="width:180px"></label>
<label>Peran
<select name="role" style="width:110px">
<option value="dosen" selected>Dosen</option>
<option value="kaprodi">Kaprodi</option>
</select>
</label>
<button class="tombol" type="submit">Buat Akun</button>
</form>
<h2 class="bagian">C. Daftar Mahasiswa Mengontrak MK — {{ tahun }}</h2>
<p class="keterangan">Unggah satu file Excel berisi seluruh kontrak semester ini
(kolom: NPM, Nama, Kode MK, Kelas — satu baris per kontrak). Impor baru
<b>menggantikan seluruh daftar</b> tahun ajaran aktif. Daftar ini dipakai untuk
mengisi otomatis template nilai tiap MK.</p>
<div class="aksi-baris" style="border-top:none;padding-top:0;margin-top:8px">
<a class="tombol garis" href="/admin/mahasiswa/template.xlsx">Unduh Template Daftar Mahasiswa (.xlsx)</a>
<form method="post" action="/admin/mahasiswa" enctype="multipart/form-data"
style="display:inline-flex;gap:10px;align-items:center">
<input type="file" name="berkas" accept=".xlsx" required>
<button class="tombol" type="submit">Impor &amp; Ganti Daftar</button>
</form>
</div>
{% if mhs %}
<div class="gulir" style="margin-top:12px">
<table class="grid" style="max-width:720px">
<thead>
<tr><th style="width:120px">Kode MK</th><th class="tengah" style="width:130px">Mahasiswa</th><th>Kelas</th></tr>
</thead>
<tbody>
{% for mk in daftar if mk.kode in mhs %}
<tr>
<td>{{ mk.kode }} — {{ mk.nama }}</td>
<td class="tengah">{{ mhs[mk.kode].n }}</td>
<td>{{ mhs[mk.kode].kelas|join(', ') if mhs[mk.kode].kelas else '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="keterangan">Belum ada daftar mahasiswa untuk tahun ajaran ini.</p>
{% endif %}
<h2 class="bagian">D. Cadangan Data</h2>
<p class="keterangan">Unduh salinan seluruh isian secara berkala dan simpan di
tempat aman — terutama sebelum pemutakhiran aplikasi atau pergantian server.</p>
<a class="tombol garis" href="/cadangan.json">Unduh cadangan JSON (seluruh isian)</a>
<a class="tombol garis" href="/cadangan.db">Unduh cadangan SQLite (file DB mentah)</a>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block judul %}RPS &amp; Portofolio — Informatika UNTAN{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/tabel.js" defer></script>
</head>
<body>
<div class="kertas">
<div class="kop">
<img class="kop-logo" src="/static/logo-untan.png" alt="Logo Universitas Tanjungpura" width="74" height="74">
<div class="kop-teks">
<div class="uni">UNIVERSITAS TANJUNGPURA — FAKULTAS TEKNIK</div>
<div class="prodi">PROGRAM STUDI INFORMATIKA</div>
<div class="app">Sistem Penyusunan RPS &amp; Portofolio Mata Kuliah — Kurikulum OBE · {{ tahun }}</div>
</div>
<span class="kop-imbang" aria-hidden="true"></span>
</div>
<hr class="kop-rule"><hr class="kop-rule2">
{% if user %}
{% set path = request.url.path %}
{% macro tautan(href, label) -%}
<a href="{{ href }}"{% if path == href %} class="aktif" aria-current="page"{% endif %}>{{ label }}</a>
{%- endmacro %}
<nav class="menu-baris">
<span class="menu-kiri">
{{ tautan('/', 'Beranda') }}
{{ tautan('/pantau', 'Dasbor Monitoring') }}
{{ tautan('/rekap', 'Rekap CPL/CPMK') }}
{{ tautan('/rekap/riwayat', 'Riwayat') }}
{{ tautan('/metodologi', 'Metodologi') }}
{{ tautan('/dashboard', 'Pemetaan CPMK') }}
{% if user.role == 'kaprodi' %}{{ tautan('/admin', 'Kelola Akun & Mahasiswa') }}{% endif %}
</span>
<span class="menu-kanan">
<b>{{ user.nama }}</b> — {{ 'Kaprodi' if user.role == 'kaprodi' else 'Dosen' }}
· <a href="/keluar">Keluar</a>
</span>
</nav>
{% endif %}
{% block isi %}{% endblock %}
<div class="kaki">
<span>Format keluaran: RPS APTIKOM 2024 (.docx) · Portofolio MK (.docx)</span>
<span>Pemetaan: 10 CPL · 34 CPMK · 49 MK · 549 Sub-CPMK</span>
</div>
</div>
</body>
</html>
+140
View File
@@ -0,0 +1,140 @@
{% extends "base.html" %}
{% block isi %}
<div class="kpi-baris" title="Persentase dihitung dari MK pada semester terpilih — hijau ≥ 75 %, kuning 4074 %, merah < 40 %">
<div><b id="kpi-mk">{{ daftar|length }}</b> <span>mata kuliah</span></div>
<div><b id="kpi-rps">{{ n_selesai }}</b> <span>RPS selesai</span> <span class="kpi-pct" id="pct-rps"></span></div>
<div><b id="kpi-draft">{{ n_draft }}</b> <span>RPS draft</span></div>
<div><b id="kpi-nilai">{{ st_nilai|length }}</b> <span>MK setor nilai</span> <span class="kpi-pct" id="pct-nilai"></span></div>
<div><b id="kpi-porto">{{ st_porto.values()|selectattr('status', 'equalto', 'selesai')|list|length }}</b> <span>portofolio selesai</span> <span class="kpi-pct" id="pct-porto"></span></div>
</div>
{% if user.role == 'dosen' %}
{% if hanya_saya %}
<div class="info" style="margin-top:0">Menampilkan <b>{{ daftar|length }} MK yang Anda ampu</b>.
RPS mata kuliah lain tetap terbuka baca — <a href="/?semua=1">tampilkan seluruh {{ n_total }} MK</a>.</div>
{% elif mk_saya %}
<div class="info" style="margin-top:0">Menampilkan seluruh {{ n_total }} MK —
<a href="/">kembali ke MK yang Anda ampu saja</a>.</div>
{% else %}
<div class="peringatan" style="margin-top:0">Belum ada MK yang ditugaskan kepada
Anda — hubungi kaprodi agar menetapkan penugasan di halaman Kelola Akun.
Sementara itu seluruh MK ditampilkan (hanya-baca).</div>
{% endif %}
{% endif %}
<div class="filter-baris">
<label for="filter-smt">Tampilkan semester:</label>
<select id="filter-smt" style="width:auto">
<option value="">Semua</option>
<option value="ganjil">Ganjil (1, 3, 5, 7)</option>
<option value="genap">Genap (2, 4, 6, 8)</option>
{% for s in smt.values()|select|unique|sort %}
<option value="{{ s }}">Semester {{ s }}</option>
{% endfor %}
</select>
<span id="filter-info" class="keterangan" style="margin:0"></span>
</div>
{% macro stempel(st) -%}
{% if st and st.status == 'selesai' %}<span class="stamp stamp-ok">SELESAI</span>
{% elif st %}<span class="stamp stamp-draft">DRAFT</span>
{% else %}<span class="stamp stamp-no">BELUM</span>{% endif %}
{%- endmacro %}
<div class="gulir">
<table class="grid">
<thead>
<tr>
<th style="width:56px">No.</th>
<th style="width:112px">Kode</th>
<th class="tengah" style="width:52px">Smt</th>
<th>Mata Kuliah</th>
<th style="width:150px">Pengampu</th>
<th style="width:120px">CPL Dipikul</th>
<th class="tengah" style="width:104px">RPS</th>
<th class="tengah" style="width:104px">Nilai</th>
<th class="tengah" style="width:104px">Portofolio</th>
<th style="width:170px">Pemutakhiran Terakhir</th>
</tr>
</thead>
<tbody>
{% for mk in daftar %}
{% set st = status.get(mk.kode) %}
{% set sn = st_nilai.get(mk.kode) %}
{% set sp = st_porto.get(mk.kode) %}
<tr data-smt="{{ smt.get(mk.kode) or '' }}"
data-rps="{{ st.status if st else '' }}"
data-nilai="{{ sn.status if sn else '' }}"
data-porto="{{ sp.status if sp else '' }}">
<td class="tengah">{{ mk.no }}</td>
<td>{{ mk.kode }}</td>
<td class="tengah">{{ smt.get(mk.kode) or '—' }}</td>
<td>{{ mk.nama }}<br>
<span style="font-size:12px;color:var(--grey)">{{ mk.n_cpmk }} CPMK · {{ mk.n_sub }} Sub-CPMK</span>
</td>
<td style="font-size:13px">{{ pengampu.get(mk.kode, [])|join(', ') or '—' }}</td>
<td>{{ mk.cpls|map('replace','CPL-','')|join(', ') }}</td>
<td class="tengah"><a href="/rps/{{ mk.kode }}" style="text-decoration:none">{{ stempel(st) }}</a></td>
<td class="tengah"><a href="/nilai/{{ mk.kode }}" style="text-decoration:none">{{ stempel(sn) }}</a></td>
<td class="tengah"><a href="/portofolio/{{ mk.kode }}" style="text-decoration:none">{{ stempel(sp) }}</a></td>
<td style="font-size:12.5px">
{% set terakhir = [st, sn, sp]|select|sort(attribute='updated_at')|last %}
{% if terakhir %}{{ terakhir.updated_at }}{% if terakhir.updated_by %} — {{ terakhir.updated_by }}{% endif %}{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
(function () {
var pilih = document.getElementById('filter-smt');
var info = document.getElementById('filter-info');
var baris = document.querySelectorAll('table.grid tbody tr');
function cocok(tr, v) {
var smt = tr.dataset.smt;
return v === '' ? true :
v === 'ganjil' ? smt !== '' && smt % 2 === 1 :
v === 'genap' ? smt !== '' && smt % 2 === 0 :
smt === v;
}
function isiPct(id, n, total) {
var el = document.getElementById(id);
if (!total) { el.textContent = ''; el.className = 'kpi-pct'; return; }
var p = Math.round(100 * n / total);
el.textContent = p + ' %';
el.className = 'kpi-pct ' +
(p >= 75 ? 'kpi-hijau' : p >= 40 ? 'kpi-kuning' : 'kpi-merah');
}
function perbarui() {
var v = pilih.value, n = 0, rSel = 0, rDraft = 0, nilai = 0, porto = 0;
baris.forEach(function (tr) {
var tampil = cocok(tr, v);
tr.style.display = tampil ? '' : 'none';
if (!tampil) return;
n++;
if (tr.dataset.rps === 'selesai') rSel++;
if (tr.dataset.rps === 'draft') rDraft++;
if (tr.dataset.nilai !== '') nilai++;
if (tr.dataset.porto === 'selesai') porto++;
});
document.getElementById('kpi-mk').textContent = n;
document.getElementById('kpi-rps').textContent = rSel;
document.getElementById('kpi-draft').textContent = rDraft;
document.getElementById('kpi-nilai').textContent = nilai;
document.getElementById('kpi-porto').textContent = porto;
isiPct('pct-rps', rSel, n);
isiPct('pct-nilai', nilai, n);
isiPct('pct-porto', porto, n);
info.textContent = v === '' ? '' : n + ' dari ' + baris.length + ' MK ditampilkan';
}
pilih.addEventListener('change', perbarui);
perbarui();
})();
</script>
<p class="keterangan" style="margin-top:10px">Klik stempel untuk membuka form terkait. Alur per MK: <b>RPS</b> (rencana &amp; bobot) → <b>Nilai</b> (unggah Excel / rata-rata) → <b>Portofolio</b> (refleksi &amp; dokumen akhir). Rekap prodi menggulung otomatis dari nilai yang masuk.</p>
{% endblock %}
+189
View File
@@ -0,0 +1,189 @@
{% extends "base.html" %}
{% block judul %}Nilai {{ peta.nama }} — Informatika UNTAN{% endblock %}
{% block isi %}
<div class="nav-baris">
<div>MK ini: <a href="/rps/{{ peta.kode }}">RPS</a> · <b>Input Nilai</b> · <a href="/portofolio/{{ peta.kode }}">Portofolio</a></div>
</div>
<h1>Input Nilai — {{ peta.nama }} <span style="color:var(--grey)">({{ peta.kode }})</span></h1>
<p class="keterangan">Apa pun cara inputnya, pengukuran memakai <b>satu metodologi
yang sama</b>: skor per Sub-CPMK digulung ke CPMK lalu CPL
(rincian di halaman <a href="/metodologi">Metodologi</a>). Pilih <b>satu</b> cara
di bawah — menyimpan lewat cara mana pun menggantikan data nilai MK ini.</p>
{% if pesan %}<div class="info">{{ pesan }}</div>{% endif %}
{% for w in peringatan %}<div class="peringatan">{{ w }}</div>{% endfor %}
{% if tersimpan %}
<div class="info">
Data tersimpan: mode
<b>{% if tersimpan.data.mode == 'per_mahasiswa' %}PER MAHASISWA ({{ tersimpan.data.mahasiswa|length }} mahasiswa){% else %}BUKTI AGREGAT (rata-rata kelas){% endif %}</b>
— terakhir disimpan {{ tersimpan.updated_at }}{% if tersimpan.updated_by %} oleh {{ tersimpan.updated_by }}{% endif %}.
{% if tersimpan.data.sumber %}<br>Riwayat unggahan:
{{ tersimpan.data.sumber if tersimpan.data.sumber is string else tersimpan.data.sumber|join(' · ') }}.{% endif %}
<form method="post" action="/nilai/{{ peta.kode }}" style="display:inline;margin-left:8px"
onsubmit="return confirm('Hapus SELURUH nilai tersimpan MK ini untuk {{ tahun }}? Tindakan ini tidak bisa dibatalkan.')">
<button class="tombol garis" type="submit" name="aksi" value="hapus"
style="color:var(--red);border-color:var(--red);padding:2px 10px;font-size:13px">Mulai Ulang (hapus semua nilai)</button>
</form>
</div>
{% endif %}
{% if grid %}
{% set per_mhs = tersimpan and tersimpan.data.mode == 'per_mahasiswa' %}
<h2 class="bagian">NILAI PER MAHASISWA <span style="font-weight:normal;font-style:italic;color:var(--grey)">(ketik langsung di sel, 0100; kolom Nilai Akhir &amp; baris rata-rata dihitung dari data tersimpan)</span></h2>
<p class="keterangan">
Daftar mahasiswa dari impor kaprodi. Kosongkan sel yang belum dinilai;
<b>Simpan Nilai</b> menyimpan persis isi tabel. Nilai Akhir = tertimbang bobot
komponen RPS; angkanya diperbarui setelah disimpan.
</p>
<form method="post" action="/nilai/{{ peta.kode }}">
<div class="gulir" style="max-height:480px;overflow-y:auto">
{% set ada_kelas = grid|selectattr('kelas')|list|length > 0 %}
{% set n_kiri = 4 if ada_kelas else 3 %}
<table class="grid">
<thead>
<tr>
<th style="width:44px" class="tengah">No.</th>
<th style="width:112px">NPM</th>
<th style="min-width:150px">Nama</th>
{% if ada_kelas %}<th style="width:60px" class="tengah">Kelas</th>{% endif %}
{% for s in peta.subs %}<th class="tengah" style="font-size:11px;min-width:62px">{{ s.kode }}</th>{% endfor %}
<th class="tengah" style="width:80px">Nilai Akhir</th>
</tr>
</thead>
<tbody>
{% for m in grid %}
{% set i = loop.index0 %}
<tr>
<td class="tengah">{{ loop.index }}
<input type="hidden" name="npm_{{ i }}" value="{{ m.npm }}">
<input type="hidden" name="nama_{{ i }}" value="{{ m.nama }}"></td>
<td>{{ m.npm }}</td>
<td>{{ m.nama }}</td>
{% if ada_kelas %}<td class="tengah">{{ m.kelas or '—' }}</td>{% endif %}
{% for s in peta.subs %}
<td class="isian"><input type="number" name="skor_{{ i }}_{{ s.kode }}"
min="0" max="100" step="0.01"
value="{{ '%g'|format(m.skor[s.kode]) if s.kode in m.skor else '' }}"></td>
{% endfor %}
<td class="tengah"><b>{{ '%.1f'|format(akhir.get(m.npm)) if akhir.get(m.npm) is not none else '—' }}</b></td>
</tr>
{% endfor %}
{% if per_mhs and hasil %}
<tr class="total">
<td colspan="{{ n_kiri }}" style="text-align:right">Rata-rata kelas (tersimpan)</td>
{% for s in peta.subs %}
{% set info = hasil.sub.get(s.kode) %}
<td class="tengah">{{ '%.1f'|format(info.rata) if info else '—' }}</td>
{% endfor %}
<td class="tengah">{{ '%.1f'|format(akhir.values()|sum / akhir|length) if akhir else '—' }}</td>
</tr>
<tr class="total">
<td colspan="{{ n_kiri }}" style="text-align:right">% lulus (≥ {{ ambang|round(0)|int }})</td>
{% for s in peta.subs %}
{% set info = hasil.sub.get(s.kode) %}
<td class="tengah" {% if info and info.lulus_pct is not none and info.rata < ambang %}style="color:var(--red)"{% endif %}>{{ '%.0f'|format(info.lulus_pct) ~ '%' if info and info.lulus_pct is not none else '—' }}</td>
{% endfor %}
<td></td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="aksi-baris">
<span style="color:var(--grey);font-size:13px">Disimpan atas nama <b>{{ user.nama }}</b></span>
<span class="spacer"></span>
<button class="tombol" type="submit" name="aksi" value="grid">Simpan Nilai</button>
</div>
</form>
{% endif %}
<details class="cara-alt"{% if not grid %} open{% endif %}>
<summary>{{ 'ALTERNATIF — ' if grid }}UNGGAH EXCEL PER MAHASISWA <span style="font-weight:normal;font-style:italic;color:var(--grey)">(cocok bila nilai sudah direkap di Excel, atau team teaching mengisi terpisah)</span></summary>
<p class="keterangan">
Unduh template berkolom Sub-CPMK MK ini, isi nilai 0100 per mahasiswa, lalu unggah kembali.
{% if terdaftar %}Template sudah berisi <b>{{ terdaftar|length }} mahasiswa</b> yang
mengontrak MK ini (dari daftar impor kaprodi).
{% else %}<b>Daftar mahasiswa MK ini belum diimpor kaprodi</b> — template kosong,
isi NPM &amp; nama secara manual.{% endif %}
<br><b>Team teaching:</b> tiap dosen cukup mengunggah bagiannya sendiri — kolom
Sub-CPMK yang diampunya (mis. sebelum/sesudah UTS) atau baris mahasiswa kelasnya.
Unggahan <b>digabung</b>: sel terisi memperbarui nilai, sel kosong tidak menghapus
nilai yang sudah diunggah dosen lain.
</p>
<div class="aksi-baris" style="border-top:none;margin-top:0;padding-top:0">
<a class="tombol garis" href="/nilai/{{ peta.kode }}/template.xlsx">Unduh Template Excel</a>
</div>
<form method="post" action="/nilai/{{ peta.kode }}" enctype="multipart/form-data">
<div class="aksi-baris" style="border-top:none">
<input type="file" name="berkas" accept=".xlsx" style="width:auto">
<button class="tombol" type="submit" name="aksi" value="unggah">Unggah &amp; Hitung</button>
</div>
</form>
</details>
<details class="cara-alt"{% if tersimpan and tersimpan.data.mode == 'agregat' %} open{% endif %}>
<summary>ALTERNATIF — KETIK RATA-RATA KELAS PER SUB-CPMK <span style="font-weight:normal;font-style:italic;color:var(--grey)">(hanya bila nilai per mahasiswa tidak tersedia; ditandai "bukti agregat" pada rekap)</span></summary>
<form method="post" action="/nilai/{{ peta.kode }}">
<div class="gulir" style="max-height:420px;overflow-y:auto">
<table class="grid">
<thead><tr><th style="width:120px">Sub-CPMK</th><th>Uraian</th><th class="tengah" style="width:130px">Rata-rata (0100)</th></tr></thead>
<tbody>
{% for s in peta.subs %}
<tr>
<td class="tengah">{{ s.kode }}</td>
<td>{{ s.uraian[:110] }}{% if s.uraian|length > 110 %}…{% endif %}</td>
<td class="isian"><input type="number" name="rata_{{ s.kode }}" min="0" max="100" step="0.01"
value="{% if tersimpan and tersimpan.data.mode == 'agregat' %}{{ tersimpan.data.agregat.get(s.kode, '') }}{% endif %}"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="aksi-baris">
<span style="color:var(--grey);font-size:13px">Disimpan atas nama <b>{{ user.nama }}</b></span>
<span class="spacer"></span>
<button class="tombol garis" type="submit" name="aksi" value="agregat">Simpan Rata-rata</button>
</div>
</form>
</details>
{% if hasil %}
<h2 class="bagian">HASIL KETERCAPAIAN MK INI <span style="font-weight:normal;font-style:italic;color:var(--grey)">(ambang {{ ambang|round(0)|int }}; roll-up bagi rata)</span></h2>
<div class="gulir">
<table class="grid">
<thead><tr><th>CPMK</th><th class="tengah">Skor</th><th class="tengah">Sub Ternilai</th><th>CPL Didukung</th></tr></thead>
<tbody>
{% for kode_c, v in hasil.cpmk.items() %}
<tr>
<td>{{ kode_c }}</td>
<td class="tengah">{% if v.skor is not none %}<b>{{ '%.2f'|format(v.skor) }}</b>{% else %}—{% endif %}</td>
<td class="tengah">{{ v.n_ternilai }}/{{ v.n_sub }}</td>
<td>{{ v.cpl|join(', ') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="gulir" style="margin-top:10px">
<table class="grid">
<thead><tr><th>CPL (dalam MK ini)</th><th class="tengah">Skor</th><th class="tengah">Status</th></tr></thead>
<tbody>
{% for kode_c, v in hasil.cpl.items() %}
<tr>
<td>{{ kode_c }}</td>
<td class="tengah">{% if v.skor is not none %}<b>{{ '%.2f'|format(v.skor) }}</b>{% else %}—{% endif %}</td>
<td class="tengah">
{% if v.skor is none %}<span class="stamp stamp-draft">BELUM TERUKUR</span>
{% elif v.skor >= ambang %}<span class="stamp stamp-ok">TERCAPAI</span>
{% else %}<span class="stamp stamp-no">DI BAWAH AMBANG</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
+83
View File
@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block judul %}Portofolio {{ peta.nama }} — Informatika UNTAN{% endblock %}
{% block isi %}
<div class="nav-baris">
<div>MK ini: <a href="/rps/{{ peta.kode }}">RPS</a> · <a href="/nilai/{{ peta.kode }}">Input Nilai</a> · <b>Portofolio</b></div>
<div>
{% if status == 'selesai' %}<span class="stamp stamp-ok">SELESAI</span>
{% elif status == 'draft' %}<span class="stamp stamp-draft">DRAFT</span>
{% else %}<span class="stamp stamp-no">BELUM</span>{% endif %}
{% if updated_at %}<span style="color:var(--grey);font-size:13px"> · disimpan {{ updated_at }}{% if updated_by %} oleh {{ updated_by }}{% endif %}</span>{% endif %}
</div>
</div>
<h1>Portofolio Mata Kuliah — {{ peta.nama }} <span style="color:var(--grey)">({{ peta.kode }})</span></h1>
{% if pesan %}<div class="info">{{ pesan }}</div>{% endif %}
{% if not boleh_edit %}
<div class="info">Anda bukan pengampu MK ini — form terbuka <b>hanya untuk dibaca</b>;
unduhan dokumen (memuat nilai mahasiswa) juga dibatasi untuk pengampu dan kaprodi.</div>
{% endif %}
{% if not ada_rps %}
<div class="peringatan">RPS MK ini belum disusun — bagian Rencana Penilaian, korelasi bobot, dan nilai akhir tertimbang akan kosong. <a href="/rps/{{ peta.kode }}">Susun RPS dulu &rarr;</a></div>
{% endif %}
{% if not ada_nilai %}
<div class="peringatan">Nilai MK ini belum diinput — bagian Penilaian, Evaluasi Ketercapaian, dan Simpulan statistik akan kosong. <a href="/nilai/{{ peta.kode }}">Input nilai dulu &rarr;</a></div>
{% endif %}
{% if ada_rps and ada_nilai %}
<div class="info">RPS &#10003; dan Nilai &#10003; tersedia — tabel korelasi, penilaian, evaluasi ketercapaian, dan statistik akan diisi otomatis di dokumen. Anda hanya melengkapi bagian di bawah.</div>
{% endif %}
<form method="post" action="/portofolio/{{ peta.kode }}">
<h2 class="bagian">IDENTITAS DOKUMEN (LEMBAR PENGESAHAN)</h2>
<table class="grid">
<tr><td class="label">Nomor Dokumen</td><td class="isian"><input type="text" name="nomor" value="{{ f.dokumen.nomor }}" placeholder="mis. PORTO/INF/2026/001 — boleh kosong"></td></tr>
<tr><td class="label">No. Revisi</td><td class="isian"><input type="text" name="revisi" value="{{ f.dokumen.revisi }}"></td></tr>
<tr><td class="label">Tanggal Terbit</td><td class="isian"><input type="text" name="tgl_terbit" value="{{ f.dokumen.tgl_terbit }}" placeholder="mis. 20 Desember 2026"></td></tr>
<tr><td class="label">Kurikulum Tahun</td><td class="isian"><input type="text" name="kurikulum_tahun" value="{{ f.dokumen.kurikulum_tahun }}"></td></tr>
<tr><td class="label">Nama Dekan</td><td class="isian"><input type="text" name="dekan" value="{{ f.dokumen.dekan }}" placeholder="untuk kolom pengesahan"></td></tr>
<tr><td class="label">Status Dokumen</td>
<td class="isian">
<select name="status_dokumen" style="width:auto">
<option {% if f.dokumen.status_dokumen == 'Master' %}selected{% endif %}>Master</option>
<option {% if f.dokumen.status_dokumen == 'Copy' %}selected{% endif %}>Copy</option>
</select>
</td></tr>
</table>
<h2 class="bagian">REALISASI PEMBELAJARAN</h2>
<p class="keterangan">Ceritakan pelaksanaan nyata semester ini: penyesuaian jadwal/materi/metode terhadap rencana di RPS, kehadiran, dan hal penting lain.</p>
<textarea name="realisasi" rows="6">{{ f.realisasi }}</textarea>
<h2 class="bagian">KENDALA &amp; TINDAK LANJUT</h2>
<table class="grid">
<thead><tr><th style="width:50px" class="tengah">No.</th><th>Kendala</th><th>Tindak Lanjut</th></tr></thead>
<tbody>
{% for k in f.kendala[:5] %}
<tr>
<td class="tengah">{{ loop.index }}</td>
<td class="isian"><textarea name="kendala_{{ loop.index }}" rows="2">{{ k.kendala }}</textarea></td>
<td class="isian"><textarea name="tindak_{{ loop.index }}" rows="2">{{ k.tindak }}</textarea></td>
</tr>
{% endfor %}
</tbody>
</table>
<h2 class="bagian">SIMPULAN HASIL EVALUASI</h2>
<p class="keterangan">Statistik nilai, frekuensi huruf mutu, dan tabel ketercapaian CPL/CPMK dihitung dan dicetak otomatis; tuliskan simpulan naratifnya di sini.</p>
<textarea name="simpulan" rows="4">{{ f.simpulan }}</textarea>
<div class="aksi-baris">
{% if boleh_edit %}
<span style="color:var(--grey);font-size:13px">Disimpan atas nama <b>{{ user.nama }}</b></span>
<span class="spacer"></span>
<button class="tombol garis" type="submit" name="aksi" value="draft">Simpan Draft</button>
<button class="tombol selesai" type="submit" name="aksi" value="selesai">Simpan &amp; Tandai Selesai</button>
{% if status %}<a class="tombol" href="/portofolio/{{ peta.kode }}/unduh">Unduh .docx</a>
<a class="tombol garis" href="/portofolio/{{ peta.kode }}/unduh.pdf">PDF</a>{% endif %}
{% endif %}
</div>
</form>
{% endblock %}
+228
View File
@@ -0,0 +1,228 @@
{% extends "base.html" %}
{% block judul %}RPS {{ peta.nama }} — Informatika UNTAN{% endblock %}
{% block isi %}
<div class="nav-baris">
<div>MK ini: <b>RPS</b> · <a href="/nilai/{{ peta.kode }}">Input Nilai</a> · <a href="/portofolio/{{ peta.kode }}">Portofolio</a></div>
<div>
{% if status == 'selesai' %}<span class="stamp stamp-ok">SELESAI</span>
{% elif status == 'draft' %}<span class="stamp stamp-draft">DRAFT</span>
{% else %}<span class="stamp stamp-no">BELUM</span>{% endif %}
{% if updated_at %}<span style="color:var(--grey);font-size:13px"> · disimpan {{ updated_at }}{% if updated_by %} oleh {{ updated_by }}{% endif %}</span>{% endif %}
</div>
</div>
<h1>Rencana Pembelajaran Semester — {{ peta.nama }} <span style="color:var(--grey)">({{ peta.kode }})</span></h1>
{% if pesan %}<div class="info">{{ pesan }}</div>{% endif %}
{% if not boleh_edit %}
<div class="info">Anda bukan pengampu MK ini — form terbuka <b>hanya untuk dibaca</b>.
Hubungi kaprodi bila penugasan MK Anda perlu diubah.</div>
{% endif %}
{% if tahun_lain and boleh_edit %}
<div class="info">
MK ini punya RPS pada tahun ajaran lain. Salin sebagai titik awal (status draft, bisa diedit):
{% for t in tahun_lain %}
<form method="post" action="/rps/{{ peta.kode }}/salin" style="display:inline">
<input type="hidden" name="dari" value="{{ t }}">
<button class="tombol garis" type="submit" style="padding:3px 10px;font-size:13px">Salin dari {{ t }}</button>
</form>
{% endfor %}
</div>
{% endif %}
<form method="post" action="/rps/{{ peta.kode }}">
<h2 class="bagian">A. IDENTITAS MATA KULIAH</h2>
<table class="grid">
<tr><td class="label">Nama Mata Kuliah</td><td>{{ peta.nama }}</td></tr>
<tr><td class="label">Kode Mata Kuliah</td><td>{{ peta.kode }}</td></tr>
<tr><td class="label">Bobot SKS</td>
<td class="isian" style="display:flex;gap:8px;align-items:center;border:none">
<input type="number" name="sks" min="0" max="8" style="width:70px" value="{{ f.identitas.sks }}"> SKS
&nbsp;(Teori: <input type="number" name="sks_teori" min="0" max="8" style="width:70px" value="{{ f.identitas.sks_teori }}">
| Praktikum: <input type="number" name="sks_praktikum" min="0" max="8" style="width:70px" value="{{ f.identitas.sks_praktikum }}">)
</td></tr>
<tr><td class="label">Semester</td><td class="isian"><input type="text" name="semester" value="{{ f.identitas.semester }}" placeholder="mis. 3 (Ganjil)"></td></tr>
<tr><td class="label">Rumpun MK</td><td class="isian"><input type="text" name="rumpun" value="{{ f.identitas.rumpun }}" placeholder="mis. MK Inti Keilmuan"></td></tr>
<tr><td class="label">MK Prasyarat</td><td class="isian"><input type="text" name="prasyarat" value="{{ f.identitas.prasyarat }}" placeholder="kosongkan bila tidak ada"></td></tr>
<tr><td class="label">Dosen Pengampu</td><td class="isian"><input type="text" name="pengampu" value="{{ f.identitas.pengampu }}" placeholder="Nama Dosen 1 | Nama Dosen 2"></td></tr>
<tr><td class="label">Dosen Pengembang RPS</td><td class="isian"><input type="text" name="pengembang" value="{{ f.identitas.pengembang }}" placeholder="bila kosong: diisi nama dosen pengampu"></td></tr>
<tr><td class="label">Koordinator MK</td><td class="isian"><input type="text" name="koordinator" value="{{ f.identitas.koordinator }}"></td></tr>
<tr><td class="label">Ketua Program Studi</td><td class="isian"><input type="text" name="kaprodi" value="{{ f.identitas.kaprodi }}"></td></tr>
<tr><td class="label">Tgl Penyusunan</td><td class="isian"><input type="text" name="tgl_penyusunan" value="{{ f.identitas.tgl_penyusunan }}" placeholder="mis. 13 Juli 2026"></td></tr>
<tr><td class="label">Kode Dokumen</td><td class="isian"><input type="text" name="kode_dokumen" value="{{ f.identitas.kode_dokumen }}" placeholder="diisi bagian penjaminan mutu, boleh kosong"></td></tr>
</table>
<h2 class="bagian">B. CPL YANG DIBEBANKAN PADA MK <span style="font-weight:normal;font-style:italic;color:var(--grey)">— otomatis dari pemetaan kurikulum</span></h2>
<table class="grid">
<thead><tr><th style="width:90px">Kode</th><th>Deskripsi CPL</th></tr></thead>
<tbody>
{% for c in peta.cpls %}
<tr><td class="tengah">{{ c.kode }}</td><td>{{ c.desk }}</td></tr>
{% endfor %}
</tbody>
</table>
<h2 class="bagian">C. CPMK <span style="font-weight:normal;font-style:italic;color:var(--grey)">— otomatis dari pemetaan kurikulum</span></h2>
<table class="grid">
<thead><tr><th style="width:110px">Kode</th><th>Deskripsi CPMK</th><th style="width:130px">CPL Didukung</th></tr></thead>
<tbody>
{% for c in peta.cpmks %}
<tr><td class="tengah">{{ c.kode }}</td><td>{{ c.desk }}</td><td class="tengah">{{ c.cpl|join(', ') }}</td></tr>
{% endfor %}
</tbody>
</table>
<h2 class="bagian">D. SUB-CPMK <span style="font-weight:normal;font-style:italic;color:var(--grey)">— otomatis dari pemetaan kurikulum ({{ peta.subs|length }} butir)</span></h2>
<div class="gulir" style="max-height:320px;overflow-y:auto">
<table class="grid">
<thead><tr><th style="width:110px">Kode</th><th>Uraian Sub-CPMK</th><th style="width:110px">CPMK</th></tr></thead>
<tbody>
{% for s in peta.subs %}
<tr><td class="tengah">{{ s.kode }}</td><td>{{ s.uraian }}</td><td class="tengah">{{ s.cpmk }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<h2 class="bagian">E. DESKRIPSI SINGKAT MATA KULIAH</h2>
<textarea name="deskripsi" rows="4" placeholder="Topik utama, pendekatan pembelajaran, relevansi dengan profil lulusan dan CPL. Maksimal ± 100 kata.">{{ f.deskripsi }}</textarea>
<h2 class="bagian">E2. BAHAN KAJIAN — MATERI PEMBELAJARAN</h2>
<textarea name="bahan_kajian" rows="4" placeholder="Materi/bahan kajian MK secara berurutan (sesuai format RPS UNTAN).">{{ f.bahan_kajian }}</textarea>
<h2 class="bagian">E3. MEDIA PEMBELAJARAN</h2>
<textarea name="media" rows="2" placeholder="mis. LMS (e-learning UNTAN), LCD proyektor, perangkat lunak pendukung.">{{ f.media }}</textarea>
<h2 class="bagian">F. RENCANA MINGGUAN <span id="total-bobot-f" style="font-weight:normal">— total bobot: <b>0</b>%</span></h2>
<p class="keterangan">Minggu 8 = UTS, minggu 16 = UAS. Total bobot seluruh minggu harus 100 %.</p>
<div class="gulir">
<table class="grid" id="tabel-f">
<thead>
<tr>
<th class="tengah" style="width:52px" rowspan="2">Mg</th>
<th style="min-width:190px" rowspan="2">Sub-CPMK</th>
<th colspan="2">Penilaian</th>
<th colspan="2">Bentuk/Metode Pembelajaran [Estimasi Waktu]</th>
<th style="min-width:140px" rowspan="2">Kegiatan Pembelajaran</th>
<th style="min-width:150px" rowspan="2">Materi [Pustaka]</th>
<th class="tengah" style="width:70px" rowspan="2">Bobot (%)</th>
</tr>
<tr>
<th class="sub" style="min-width:150px">Indikator</th>
<th class="sub" style="min-width:140px">Kriteria &amp; Teknik</th>
<th class="sub" style="min-width:130px">Tatap Muka</th>
<th class="sub" style="min-width:120px">Daring</th>
</tr>
</thead>
<tbody>
{% for m in f.minggu %}
<tr class="{% if m.ke == 8 %}uts{% elif m.ke == 16 %}uas{% endif %}">
<td class="tengah">{{ m.ke }}</td>
<td class="isian">
<select name="m{{ m.ke }}_sub">
<option value="">— pilih —</option>
{% for s in peta.subs %}
<option value="{{ s.kode }}" {% if m.sub == s.kode %}selected{% endif %}>{{ s.kode }} — {{ s.uraian[:60] }}{% if s.uraian|length > 60 %}…{% endif %}</option>
{% endfor %}
<option value="__uts__" {% if m.sub == '__uts__' %}selected{% endif %}>Sub-CPMK minggu 17 (UTS)</option>
<option value="__uas__" {% if m.sub == '__uas__' %}selected{% endif %}>Sub-CPMK minggu 915 (UAS)</option>
</select>
</td>
<td class="isian"><textarea name="m{{ m.ke }}_indikator" rows="2">{{ m.indikator }}</textarea></td>
<td class="isian"><textarea name="m{{ m.ke }}_kriteria" rows="2">{{ m.kriteria }}</textarea></td>
<td class="isian"><textarea name="m{{ m.ke }}_metode" rows="2">{{ m.metode }}</textarea></td>
<td class="isian"><textarea name="m{{ m.ke }}_daring" rows="2">{{ m.daring }}</textarea></td>
<td class="isian"><textarea name="m{{ m.ke }}_kegiatan" rows="2">{{ m.kegiatan }}</textarea></td>
<td class="isian"><textarea name="m{{ m.ke }}_materi" rows="2">{{ m.materi }}</textarea></td>
<td class="isian"><input type="number" class="bobot-f" name="m{{ m.ke }}_bobot" min="0" max="100" step="0.5" value="{{ m.bobot }}"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<h2 class="bagian">G. REKAP BOBOT PENILAIAN <span id="total-bobot-g" style="font-weight:normal">— total: <b>0</b>%</span></h2>
<p class="keterangan">Centang Sub-CPMK yang dinilai tiap komponen. Setiap Sub-CPMK harus tercakup minimal satu komponen agar CPMK/CPL-nya terukur.</p>
<div class="gulir">
<table class="grid">
<thead>
<tr><th style="width:190px">Komponen</th><th>Sub-CPMK yang Dinilai</th><th class="tengah" style="width:90px">Bobot (%)</th></tr>
</thead>
<tbody>
{% for k in f.komponen %}
{% set ki = loop.index %}
<tr>
<td class="isian"><input type="text" name="k{{ ki }}_nama" value="{{ k.nama }}"></td>
<td>
<div class="pilih-sub">
{% for s in peta.subs %}
<label><input type="checkbox" class="sub-k" name="k{{ ki }}_subs" value="{{ s.kode }}" {% if s.kode in k.subs %}checked{% endif %}>{{ s.kode }} — {{ s.uraian[:70] }}{% if s.uraian|length > 70 %}…{% endif %}</label>
{% endfor %}
</div>
</td>
<td class="isian"><input type="number" class="bobot-g" name="k{{ ki }}_bobot" min="0" max="100" step="0.5" value="{{ k.bobot }}"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="info" id="cek-cakupan" hidden></div>
<h2 class="bagian">H. PUSTAKA UTAMA</h2>
<p class="keterangan">Satu referensi per baris, format APA.</p>
<textarea name="referensi" rows="5" placeholder="Penulis. (Tahun). Judul. Penerbit / Jurnal, Vol(No), Hal. https://doi.org/...">{{ f.referensi_teks }}</textarea>
<h2 class="bagian">H2. PUSTAKA PENDUKUNG</h2>
<textarea name="referensi_pendukung" rows="3" placeholder="Satu referensi per baris; boleh kosong.">{{ f.referensi_pendukung_teks }}</textarea>
<div class="aksi-baris">
{% if boleh_edit %}
<span style="color:var(--grey);font-size:13px">Disimpan atas nama <b>{{ user.nama }}</b></span>
<span class="spacer"></span>
<button class="tombol garis" type="submit" name="aksi" value="draft">Simpan Draft</button>
<button class="tombol selesai" type="submit" name="aksi" value="selesai">Simpan &amp; Tandai Selesai</button>
{% else %}
<span class="spacer"></span>
{% endif %}
{% if status %}<a class="tombol" href="/rps/{{ peta.kode }}/unduh">Unduh .docx</a>
<a class="tombol garis" href="/rps/{{ peta.kode }}/unduh.pdf">PDF</a>{% endif %}
</div>
</form>
<script>
function hitungTotal(kelas, idLabel) {
let t = 0;
document.querySelectorAll(kelas).forEach(el => { t += parseFloat(el.value) || 0; });
const label = document.querySelector(idLabel);
label.querySelector('b').textContent = (Math.round(t * 100) / 100);
label.classList.toggle('salah', Math.abs(t - 100) > 0.01);
}
function cekCakupan() {
const dinilai = new Set();
document.querySelectorAll('.sub-k:checked').forEach(cb => dinilai.add(cb.value));
const semua = [{% for s in peta.subs %}'{{ s.kode }}'{% if not loop.last %},{% endif %}{% endfor %}];
const hilang = semua.filter(k => !dinilai.has(k));
const kotak = document.getElementById('cek-cakupan');
if (hilang.length === 0) {
kotak.hidden = false;
kotak.className = 'info';
kotak.textContent = 'Semua ' + semua.length + ' Sub-CPMK sudah tercakup komponen penilaian — CPMK dan CPL mata kuliah ini akan terukur penuh.';
} else {
kotak.hidden = false;
kotak.className = 'peringatan';
kotak.textContent = 'Perhatian: ' + hilang.length + ' Sub-CPMK belum dinilai komponen mana pun (ketercapaian CPMK/CPL-nya tidak akan terukur): ' + hilang.join(', ');
}
}
function segarkan() {
hitungTotal('.bobot-f', '#total-bobot-f');
hitungTotal('.bobot-g', '#total-bobot-g');
cekCakupan();
}
document.addEventListener('input', segarkan);
segarkan();
</script>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block judul %}{{ 'Penyiapan Awal' if mode == 'setup' else 'Masuk' }} — RPS &amp; Portofolio UNTAN{% endblock %}
{% block isi %}
<div class="login-kotak">
{% if mode == 'setup' %}
<h1>Penyiapan Awal</h1>
<p class="keterangan" style="text-align:center">Belum ada akun. Buat akun
<b>Ketua Program Studi</b> terlebih dahulu — akun ini yang nanti membuat
akun para dosen.</p>
{% else %}
<h1>Masuk</h1>
<p class="keterangan" style="text-align:center">Halaman ini memuat data nilai
mahasiswa — akses memerlukan akun. Hubungi kaprodi bila belum punya.</p>
{% endif %}
{% if pesan %}<div class="peringatan">{{ pesan }}</div>{% endif %}
<form method="post" action="{{ '/setup' if mode == 'setup' else '/login' }}">
{% if mode == 'setup' %}
<label for="nama">Nama lengkap (dengan gelar)</label>
<input type="text" id="nama" name="nama" required autofocus>
<label for="nip">NIP</label>
<input type="text" id="nip" name="nip" autocomplete="username" required>
<p class="keterangan">NIP menjadi username sekaligus password awal.</p>
{% else %}
<label for="nip">NIP</label>
<input type="text" id="nip" name="nip" autocomplete="username" required autofocus>
<label for="password">Password <span style="color:var(--grey);font-weight:normal">(awal: NIP Anda)</span></label>
<input type="password" id="password" name="password"
autocomplete="current-password" required>
{% endif %}
<div class="aksi-baris" style="border-top:none;padding-top:6px">
<button class="tombol" type="submit" style="width:100%">
{{ 'Buat Akun Kaprodi & Masuk' if mode == 'setup' else 'Masuk' }}
</button>
</div>
</form>
</div>
{% endblock %}
+82
View File
@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block judul %}Metodologi Pengukuran — Informatika UNTAN{% endblock %}
{% block isi %}
<div class="nav-baris">
<span></span>
<a href="javascript:window.print()">Cetak halaman ini</a>
</div>
<h1>Metodologi Pengukuran Ketercapaian CPL/CPMK</h1>
<p class="keterangan">Halaman ini adalah dokumentasi resmi metode pengukuran — jawaban baku untuk asesor atas pertanyaan "angka ketercapaian ini dihitung dari mana?".</p>
{% if pesan %}<div class="info">{{ pesan }}</div>{% endif %}
<h2 class="bagian">1. SUMBER DATA</h2>
<table class="grid">
<tr><td class="label">Relasi CPLCPMKMKSub-CPMK</td><td>Pemetaan kurikulum OBE prodi (Dokumen 9): 10 CPL · 34 CPMK · 49 MK · 549 Sub-CPMK.</td></tr>
<tr><td class="label">Bobot penilaian</td><td>RPS tiap MK: bobot komponen penilaian dan pemetaannya ke Sub-CPMK (kisi-kisi asesmen, porsi bagi rata).</td></tr>
<tr><td class="label">Nilai</td><td>Skor 0100 per mahasiswa per Sub-CPMK (unggah Excel). Input rata-rata kelas diperbolehkan sebagai transisi dan ditandai <i>bukti agregat</i>.</td></tr>
</table>
<h2 class="bagian">2. RUMUS ROLL-UP (RATA-RATA SEDERHANA / BAGI RATA)</h2>
<table class="grid">
<tr><td class="label">Skor Sub-CPMK</td><td>Rata-rata skor seluruh mahasiswa pada Sub-CPMK tersebut.</td></tr>
<tr><td class="label">Skor CPMK (dalam MK)</td><td>Rata-rata skor Sub-CPMK pendukungnya dalam MK tersebut.</td></tr>
<tr><td class="label">Skor CPMK (prodi)</td><td>Rata-rata skor CPMK tersebut lintas seluruh MK yang mengukurnya{% if p.metode_antar_mk == 'sks' %} — <b>saat ini diatur tertimbang SKS</b>{% endif %}.</td></tr>
<tr><td class="label">Skor CPL</td><td>Rata-rata skor seluruh CPMK pendukung CPL tersebut.</td></tr>
<tr><td class="label">% Kelulusan</td><td>Persentase mahasiswa dengan skor ≥ ambang (hanya tersedia pada data per mahasiswa).</td></tr>
<tr><td class="label">Kelengkapan bukti</td><td>Jumlah titik ukur (pasangan MK×CPMK) yang sudah menyetor nilai, dibanding seluruh titik ukur pemikul.</td></tr>
</table>
<h2 class="bagian">3. PENGATURAN PRODI</h2>
<form method="post" action="/metodologi">
<table class="grid">
<tr>
<td class="label">Tahun ajaran aktif</td>
<td class="isian"><input type="text" name="tahun_aktif" style="width:220px" value="{{ p.tahun_aktif }}" placeholder="mis. 2026/2027 Ganjil">
<span style="color:var(--grey)">— semua form &amp; rekap mengikuti tahun ini; data tahun lama tetap tersimpan</span></td>
</tr>
<tr>
<td class="label">Ambang ketercapaian</td>
<td class="isian"><input type="number" name="ambang" min="0" max="100" step="1" style="width:110px" value="{{ p.ambang }}"> <span style="color:var(--grey)">(skor ≥ ambang = tercapai)</span></td>
</tr>
<tr>
<td class="label">Metode antar-MK</td>
<td class="isian">
<select name="metode_antar_mk" style="width:auto">
<option value="rata" {% if p.metode_antar_mk == 'rata' %}selected{% endif %}>Bagi rata (default — sesuai praktik umum KPT Dikti)</option>
<option value="sks" {% if p.metode_antar_mk == 'sks' %}selected{% endif %}>Tertimbang SKS (aktifkan hanya dengan keputusan resmi prodi)</option>
</select>
</td>
</tr>
<tr>
<td class="label">Skala huruf mutu</td>
<td class="isian"><textarea name="skala_huruf" rows="10" style="font-family:Consolas,monospace;font-size:13px">{{ p.skala_huruf }}</textarea>
<div style="color:var(--grey);font-size:12px;padding:4px 6px">Format per baris: HURUF|MIN|MAX|ANGKA</div>
</td>
</tr>
</table>
<div class="aksi-baris">
{% if boleh_ubah %}
<span class="spacer"></span>
<button class="tombol" type="submit">Simpan Pengaturan</button>
{% else %}
<span style="color:var(--grey);font-size:13px">Pengaturan prodi hanya dapat diubah oleh kaprodi.</span>
{% endif %}
</div>
</form>
<h2 class="bagian">4. SKALA HURUF MUTU BERLAKU</h2>
<table class="grid" style="max-width:420px">
<thead><tr><th>Huruf</th><th class="tengah">Rentang</th><th class="tengah">Angka Mutu</th></tr></thead>
<tbody>
{% for s in skala %}
<tr>
<td class="tengah"><b>{{ s.huruf }}</b></td>
<td class="tengah">{{ '%g'|format(s.lo) }} {{ '%g'|format(s.hi) }}</td>
<td class="tengah">{{ '%.2f'|format(s.angka) if s.angka is not none else '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+192
View File
@@ -0,0 +1,192 @@
{% extends "base.html" %}
{% block judul %}Dasbor Monitoring — Informatika UNTAN{% endblock %}
{% block isi %}
<h1>Dasbor Monitoring Prodi — {{ hasil.tahun }}</h1>
<div class="kpi-baris">
<div><b>{{ hasil.n_mk_setor }}/{{ n_mk }}</b> <span>MK setor nilai</span></div>
<div><b>{{ cpl_urut|selectattr('tercapai')|list|length }}/{{ cpl_urut|length }}</b> <span>CPL tercapai</span></div>
<div><b>{{ cpmk_urut|rejectattr('skor', 'none')|list|length }}/{{ cpmk_urut|length }}</b> <span>CPMK terukur</span></div>
<div><b>{% if rata_cpl is not none %}{{ '%.1f'|format(rata_cpl) }}{% else %}—{% endif %}</b> <span>rata-rata skor CPL</span></div>
<div><b>{{ hasil.ambang|round(0)|int }}</b> <span>ambang ketercapaian</span></div>
</div>
{% if hasil.n_mk_setor == 0 %}
<div class="info">Belum ada MK yang menyetor nilai pada {{ hasil.tahun }} —
grafik ketercapaian akan terisi begitu nilai pertama masuk. Pantau dulu
kelengkapan proses dokumen dan daftar tindak lanjut di bawah.</div>
{% endif %}
{# ── A. Ketercapaian CPL (bar vertikal, 10 CPL) ─────────────────────── #}
<h2 class="bagian">KETERCAPAIAN CPL — SEMESTER BERJALAN</h2>
<p class="keterangan">Skor gabungan tiap CPL vs ambang {{ hasil.ambang|round(0)|int }}.
Arahkan kursor ke batang untuk rincian kelengkapan bukti &amp; penyumbang terlemah.
Tabel rinci: <a href="/rekap">Rekap</a>.</p>
<div class="legenda">
<span><i class="chip" style="background:var(--blue)"></i> tercapai (&ge; ambang)</span>
<span><i class="chip" style="background:var(--red)"></i> di bawah ambang</span>
<span><i class="chip chip-kosong"></i> belum terukur</span>
<span><i class="chip-garis"></i> garis ambang</span>
</div>
<div class="gulir">
<svg viewBox="0 0 760 252" class="grafik" role="img" aria-label="Grafik batang skor tiap CPL dibandingkan ambang">
<defs><clipPath id="plot-cpl"><rect x="46" y="10" width="706" height="206"/></clipPath></defs>
{% for g in [0, 25, 50, 75, 100] %}
<line x1="46" y1="{{ 216 - g * 2 }}" x2="752" y2="{{ 216 - g * 2 }}" stroke="{{ '#bbb' if g == 0 else '#e4e4e4' }}" stroke-width="1"/>
<text x="40" y="{{ 220 - g * 2 }}" class="sumbu" text-anchor="end">{{ g }}</text>
{% endfor %}
<line x1="46" y1="{{ '%.1f'|format(216 - hasil.ambang * 2) }}" x2="752" y2="{{ '%.1f'|format(216 - hasil.ambang * 2) }}"
stroke="var(--red)" stroke-width="1.5" stroke-dasharray="6 4"/>
{% set lebar_slot = 706 / cpl_urut|length %}
{% for c in cpl_urut %}
{% set x_slot = 46 + loop.index0 * lebar_slot %}
{% set x_bar = x_slot + (lebar_slot - 30) / 2 %}
<g class="bar">
<title>{{ c.kode }} — {% if c.skor is not none %}skor {{ '%.2f'|format(c.skor) }} ({{ 'tercapai' if c.tercapai else 'di bawah ambang' }})
bukti: {{ c.n_mk_setor }}/{{ c.n_mk_pemikul }} titik ukur{% if c.ada_agregat %} (ada bukti agregat){% endif %}
{% if c.mk_terlemah %}terlemah: {{ c.cpmk_terlemah }} — {{ nama_mk.get(c.mk_terlemah[0], c.mk_terlemah[0]) }} ({{ '%.1f'|format(c.mk_terlemah[1]) }}){% endif %}{% else %}belum terukur — belum ada MK pemikulnya yang setor nilai{% endif %}</title>
<rect x="{{ '%.1f'|format(x_slot) }}" y="10" width="{{ '%.1f'|format(lebar_slot) }}" height="206" fill="transparent"/>
{% if c.skor is not none %}
<rect clip-path="url(#plot-cpl)" x="{{ '%.1f'|format(x_bar) }}" y="{{ '%.1f'|format(216 - c.skor * 2) }}"
width="30" height="{{ '%.1f'|format(c.skor * 2 + 6) }}" rx="4"
fill="{{ 'var(--blue)' if c.tercapai else 'var(--red)' }}"/>
<text x="{{ '%.1f'|format(x_bar + 15) }}" y="{{ '%.1f'|format(211 - c.skor * 2) }}" class="nilai-bar" text-anchor="middle">{{ '%.1f'|format(c.skor) }}</text>
{% else %}
<rect x="{{ '%.1f'|format(x_bar) }}" y="204" width="30" height="12" rx="4"
fill="none" stroke="var(--grey)" stroke-dasharray="3 3"/>
{% endif %}
<text x="{{ '%.1f'|format(x_slot + lebar_slot / 2) }}" y="232" class="sumbu{{ ' pudar' if c.skor is none }}" text-anchor="middle">{{ c.kode }}</text>
<text x="{{ '%.1f'|format(x_slot + lebar_slot / 2) }}" y="246" class="sumbu pudar" text-anchor="middle">{{ c.n_mk_setor }}/{{ c.n_mk_pemikul }} bukti</text>
</g>
{% endfor %}
</svg>
</div>
{# ── B. Kelengkapan proses dokumen (stacked bar per tahap) ──────────── #}
<h2 class="bagian">KELENGKAPAN PROSES DOKUMEN ({{ n_mk }} MK)</h2>
<p class="keterangan">Alur per MK: RPS → Nilai → Portofolio. Batang menunjukkan berapa MK yang sudah menuntaskan tiap tahap.</p>
<div class="legenda">
<span><i class="chip" style="background:var(--blue)"></i> selesai</span>
<span><i class="chip" style="background:var(--blue-fill);border:1px solid #9db8cf"></i> draft</span>
<span><i class="chip chip-kosong"></i> belum</span>
</div>
{% for p in proses %}
<div class="proses-baris">
<span class="proses-label">{{ p.label }}</span>
<span class="proses-jalur">
{% if p.selesai %}<span class="seg" style="width:{{ '%.2f'|format(100 * p.selesai / n_mk) }}%;background:var(--blue);color:#fff" title="{{ p.selesai }} MK selesai {{ p.label }}">{{ p.selesai }}</span>{% endif %}
{% if p.draft %}<span class="seg" style="width:{{ '%.2f'|format(100 * p.draft / n_mk) }}%;background:var(--blue-fill)" title="{{ p.draft }} MK masih draft">{{ p.draft }}</span>{% endif %}
{% if p.belum %}<span class="seg seg-kosong" style="width:{{ '%.2f'|format(100 * p.belum / n_mk) }}%" title="{{ p.belum }} MK belum mulai">{{ p.belum }}</span>{% endif %}
</span>
</div>
{% endfor %}
{# ── C. Ketercapaian CPMK (bar horizontal, tampil bila ada data) ────── #}
{% set cpmk_ternilai = cpmk_urut|rejectattr('skor', 'none')|list %}
{% if cpmk_ternilai %}
<h2 class="bagian">KETERCAPAIAN CPMK — SEMESTER BERJALAN</h2>
<p class="keterangan">Hanya CPMK yang sudah terukur ({{ cpmk_ternilai|length }} dari {{ cpmk_urut|length }}).
Garis putus-putus = ambang. Arahkan kursor untuk melihat MK pengukur terlemah.</p>
{% set tinggi = cpmk_ternilai|length * 22 + 34 %}
<div class="gulir">
<svg viewBox="0 0 760 {{ tinggi }}" class="grafik" role="img" aria-label="Grafik batang horizontal skor tiap CPMK">
<defs><clipPath id="plot-cpmk"><rect x="100" y="0" width="646" height="{{ tinggi - 30 }}"/></clipPath></defs>
{% for g in [0, 25, 50, 75, 100] %}
<line x1="{{ 100 + g * 6.46 }}" y1="0" x2="{{ 100 + g * 6.46 }}" y2="{{ tinggi - 30 }}" stroke="{{ '#bbb' if g == 0 else '#e4e4e4' }}" stroke-width="1"/>
<text x="{{ 100 + g * 6.46 }}" y="{{ tinggi - 16 }}" class="sumbu" text-anchor="middle">{{ g }}</text>
{% endfor %}
<line x1="{{ '%.1f'|format(100 + hasil.ambang * 6.46) }}" y1="0" x2="{{ '%.1f'|format(100 + hasil.ambang * 6.46) }}" y2="{{ tinggi - 30 }}"
stroke="var(--red)" stroke-width="1.5" stroke-dasharray="6 4"/>
{% for c in cpmk_ternilai %}
{% set y = loop.index0 * 22 %}
<g class="bar">
<title>{{ c.kode }} (mendukung {{ c.cpl|join(', ') }}) — skor {{ '%.2f'|format(c.skor) }}
setor: {{ c.n_setor }}/{{ c.n_pemikul }} MK pemikul{% if c.ada_agregat %} (ada bukti agregat){% endif %}
{% if c.mk_terlemah %}terlemah: {{ nama_mk.get(c.mk_terlemah[0], c.mk_terlemah[0]) }} ({{ '%.1f'|format(c.mk_terlemah[1]) }}){% endif %}</title>
<rect x="0" y="{{ y }}" width="760" height="22" fill="transparent"/>
<text x="94" y="{{ y + 15 }}" class="sumbu" text-anchor="end">{{ c.kode }}</text>
<rect clip-path="url(#plot-cpmk)" x="94" y="{{ y + 4 }}" width="{{ '%.1f'|format(c.skor * 6.46 + 6) }}" height="14" rx="4"
fill="{{ 'var(--blue)' if c.skor >= hasil.ambang else 'var(--red)' }}"/>
<text x="{{ '%.1f'|format(104 + c.skor * 6.46) }}" y="{{ y + 15 }}" class="nilai-bar">{{ '%.1f'|format(c.skor) }}</text>
</g>
{% endfor %}
</svg>
</div>
{% endif %}
{# ── D. Tren lintas semester (sparkline per CPL, bila >1 tahun) ─────── #}
{% if tren %}
<h2 class="bagian">TREN SKOR CPL LINTAS TAHUN AJARAN</h2>
<p class="keterangan">Garis kecil per CPL; titik terakhir = {{ hasil.tahun }}. Rincian angka: <a href="/rekap/riwayat">Riwayat Lintas Semester</a>.</p>
<div class="tren-kisi">
{% for kode, titik in tren.items() %}
{% set ada = titik|selectattr(1, 'ne', none)|list %}
<div class="tren-kartu" title="{{ kode }}: {% for t, s in titik %}{{ t }} = {{ '%.1f'|format(s) if s is not none else '—' }}{{ ' · ' if not loop.last }}{% endfor %}">
<b>{{ kode }}</b>
<svg viewBox="0 0 150 46" class="tren-svg" role="img" aria-label="Tren {{ kode }}">
{% set lebar_x = 130 / (titik|length - 1) if titik|length > 1 else 0 %}
{# skala vertikal mengikuti rentang data (± 2 poin) agar tren terlihat #}
{% set nilai2 = ada|map(attribute=1)|list %}
{% set lo = [nilai2|min - 2, 0]|max %}
{% set span = [nilai2|max + 2 - lo, 4]|max %}
{% if lo <= hasil.ambang <= lo + span %}
<line x1="6" y1="{{ '%.1f'|format(42 - (hasil.ambang - lo) * 36 / span) }}" x2="136" y2="{{ '%.1f'|format(42 - (hasil.ambang - lo) * 36 / span) }}" stroke="var(--red)" stroke-width="1" stroke-dasharray="3 3"/>
{% endif %}
<polyline fill="none" stroke="var(--blue)" stroke-width="2" stroke-linejoin="round"
points="{% for t, s in titik %}{% if s is not none %}{{ '%.1f'|format(6 + loop.index0 * lebar_x) }},{{ '%.1f'|format(42 - (s - lo) * 36 / span) }} {% endif %}{% endfor %}"/>
{% for t, s in titik %}{% if s is not none %}
<circle cx="{{ '%.1f'|format(6 + loop.index0 * lebar_x) }}" cy="{{ '%.1f'|format(42 - (s - lo) * 36 / span) }}" r="3" fill="var(--blue)" stroke="#fff" stroke-width="1.5"/>
{% endif %}{% endfor %}
</svg>
<span class="tren-akhir">{% if ada %}{{ '%.1f'|format(ada[-1][1]) }}{% else %}—{% endif %}</span>
</div>
{% endfor %}
</div>
{% endif %}
{# ── E. Perlu perhatian ─────────────────────────────────────────────── #}
<h2 class="bagian">PERLU PERHATIAN</h2>
<div class="perhatian-kisi">
<div>
<p class="keterangan" style="margin-top:0">CPL di bawah ambang &amp; penyumbang terlemahnya — mulai perbaikan dari sini.</p>
{% set cpl_rendah = cpl_urut|rejectattr('skor', 'none')|rejectattr('tercapai')|list %}
{% if cpl_rendah %}
<table class="grid">
<thead><tr><th style="width:70px">CPL</th><th class="tengah" style="width:64px">Skor</th><th>CPMK &amp; MK Terlemah</th></tr></thead>
<tbody>
{% for c in cpl_rendah %}
<tr>
<td class="tengah"><b>{{ c.kode }}</b></td>
<td class="tengah">{{ '%.2f'|format(c.skor) }}</td>
<td>{% if c.mk_terlemah %}{{ c.cpmk_terlemah }} — <a href="/nilai/{{ c.mk_terlemah[0] }}">{{ nama_mk.get(c.mk_terlemah[0], c.mk_terlemah[0]) }}</a> ({{ '%.1f'|format(c.mk_terlemah[1]) }}){% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="info">Tidak ada CPL terukur yang berada di bawah ambang. ✓</div>
{% endif %}
</div>
<div>
<p class="keterangan" style="margin-top:0">MK belum setor nilai ({{ belum_nilai|length }}) — hubungi pengampunya.</p>
{% if belum_nilai %}
<div class="gulir" style="max-height:320px;overflow-y:auto">
<table class="grid">
<thead><tr><th style="width:100px">Kode</th><th>Mata Kuliah</th><th style="width:180px">Pengampu</th></tr></thead>
<tbody>
{% for m in belum_nilai %}
<tr>
<td>{{ m.kode }}</td>
<td><a href="/nilai/{{ m.kode }}">{{ m.nama }}</a></td>
<td style="font-size:13px">{{ m.pengampu|join(', ') or '— belum ditugaskan' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="info">Semua MK sudah menyetor nilai. ✓</div>
{% endif %}
</div>
</div>
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block judul %}Rekap Ketercapaian CPL/CPMK — Informatika UNTAN{% endblock %}
{% block isi %}
<h1>Rekap Ketercapaian CPL &amp; CPMK — {{ hasil.tahun }}</h1>
<div class="kpi-baris">
<div><b>{{ hasil.n_mk_setor }}</b> <span>MK sudah setor nilai</span></div>
<div><b>{{ hasil.ambang|round(0)|int }}</b> <span>ambang ketercapaian</span></div>
<div><b>{{ 'SKS' if hasil.metode == 'sks' else 'Bagi rata' }}</b> <span>metode antar-MK</span></div>
<div><b>{{ cpl_urut|selectattr('tercapai')|list|length }}</b> <span>CPL tercapai</span></div>
<div><b>{{ cpl_urut|rejectattr('skor', 'none')|rejectattr('tercapai')|list|length }}</b> <span>CPL di bawah ambang</span></div>
</div>
{% if hasil.n_mk_setor == 0 %}
<div class="peringatan">Belum ada MK yang menyetor nilai — seluruh CPL berstatus "belum terukur". Mulai dari halaman <b>Input Nilai</b> tiap MK di beranda.</div>
{% endif %}
<h2 class="bagian">KETERCAPAIAN CPL</h2>
<p class="keterangan">Kelengkapan bukti = berapa dari seluruh titik ukur (MK × CPMK pemikul) yang sudah menyetor nilai. Skor dari bukti yang belum lengkap harus dibaca hati-hati.</p>
<div class="gulir">
<table class="grid">
<thead>
<tr>
<th style="width:80px">CPL</th><th>Deskripsi</th>
<th class="tengah" style="width:80px">Skor</th>
<th class="tengah" style="width:130px">Status</th>
<th class="tengah" style="width:120px">Kelengkapan Bukti</th>
<th style="width:210px">Penyumbang Terlemah</th>
</tr>
</thead>
<tbody>
{% for c in cpl_urut %}
<tr>
<td class="tengah"><b>{{ c.kode }}</b></td>
<td>{{ c.desk[:130] }}{% if c.desk|length > 130 %}…{% endif %}</td>
<td class="tengah">{% if c.skor is not none %}<b>{{ '%.2f'|format(c.skor) }}</b>{% else %}—{% endif %}</td>
<td class="tengah">
{% if c.skor is none %}<span class="stamp stamp-draft">BELUM TERUKUR</span>
{% elif c.tercapai %}<span class="stamp stamp-ok">TERCAPAI</span>
{% else %}<span class="stamp stamp-no">DI BAWAH AMBANG</span>{% endif %}
</td>
<td class="tengah">{{ c.n_mk_setor }}/{{ c.n_mk_pemikul }} titik ukur{% if c.ada_agregat %}<br><span style="color:var(--red);font-size:11px">ada bukti agregat</span>{% endif %}</td>
<td>
{% if c.mk_terlemah %}
{{ c.cpmk_terlemah }} — {{ nama_mk.get(c.mk_terlemah[0], c.mk_terlemah[0]) }}
({{ '%.1f'|format(c.mk_terlemah[1]) }})
{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<h2 class="bagian">KETERCAPAIAN CPMK (TINGKAT PRODI)</h2>
<div class="gulir">
<table class="grid">
<thead>
<tr>
<th style="width:110px">CPMK</th>
<th style="width:120px">CPL Didukung</th>
<th class="tengah" style="width:80px">Skor</th>
<th class="tengah" style="width:130px">MK Setor / Pemikul</th>
<th style="width:230px">MK Terlemah</th>
</tr>
</thead>
<tbody>
{% for c in cpmk_urut %}
<tr>
<td class="tengah">{{ c.kode }}</td>
<td class="tengah">{{ c.cpl|join(', ') }}</td>
<td class="tengah">{% if c.skor is not none %}<b>{{ '%.2f'|format(c.skor) }}</b>{% else %}—{% endif %}</td>
<td class="tengah">{{ c.n_setor }}/{{ c.n_pemikul }}</td>
<td>{% if c.mk_terlemah %}{{ nama_mk.get(c.mk_terlemah[0], c.mk_terlemah[0]) }} ({{ '%.1f'|format(c.mk_terlemah[1]) }}){% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+42
View File
@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block judul %}Riwayat Ketercapaian Lintas Semester — Informatika UNTAN{% endblock %}
{% block isi %}
<h1>Riwayat Ketercapaian CPL Lintas Semester</h1>
<p class="keterangan">Skor CPL tingkat prodi per tahun ajaran — bahan evaluasi tren untuk rapat kurikulum dan akreditasi. Sel kosong = belum ada nilai yang masuk pada periode itu.</p>
<div class="gulir">
<table class="grid">
<thead>
<tr>
<th style="width:90px">CPL</th>
{% for t in daftar_tahun %}
<th class="tengah">{{ t }}{% if t == tahun %}<br><span style="font-size:10px">(aktif)</span>{% endif %}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for cp in kode_cpl %}
<tr>
<td class="tengah"><b>{{ cp }}</b></td>
{% for t in daftar_tahun %}
{% set v = per_tahun[t].cpl.get(cp) %}
<td class="tengah">
{% if v and v.skor is not none %}
<b>{{ '%.1f'|format(v.skor) }}</b>
{% if v.skor < per_tahun[t].ambang %}<span style="color:var(--red)"></span>{% endif %}
<br><span style="font-size:11px;color:var(--grey)">{{ v.n_mk_setor }}/{{ v.n_mk_pemikul }} bukti</span>
{% else %}—{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="kpi-baris" style="margin-top:14px">
{% for t in daftar_tahun %}
<div><b>{{ per_tahun[t].n_mk_setor }}</b> <span>MK setor nilai · {{ t }}</span></div>
{% endfor %}
</div>
{% endblock %}