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:
+28
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 CPL–Sub-CPMK
|
||||
· D Rencana Penilaian & Tugas · E Realisasi Pembelajaran · F Penilaian
|
||||
· G Evaluasi Ketercapaian · H Simpulan & Tindak Lanjut.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
from docx import Document
|
||||
from docx.enum.section import WD_ORIENT
|
||||
from docx.enum.table import WD_ALIGN_VERTICAL
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Cm, Pt, RGBColor
|
||||
|
||||
DARK_BLUE = "1F4E79"
|
||||
LIGHT_BLUE = "D6E4F0"
|
||||
RED = "9A3324"
|
||||
WHITE = "FFFFFF"
|
||||
|
||||
LOGO = pathlib.Path(__file__).resolve().parent.parent / "static" / "logo-untan.png"
|
||||
FAKULTAS = os.environ.get("FAKULTAS", "FAKULTAS TEKNIK")
|
||||
PRODI = os.environ.get("PRODI", "PROGRAM STUDI INFORMATIKA")
|
||||
|
||||
|
||||
def _shd(cell, hex_color):
|
||||
tc_pr = cell._tc.get_or_add_tcPr()
|
||||
el = OxmlElement("w:shd")
|
||||
el.set(qn("w:val"), "clear")
|
||||
el.set(qn("w:color"), "auto")
|
||||
el.set(qn("w:fill"), hex_color)
|
||||
tc_pr.append(el)
|
||||
|
||||
|
||||
def _ct(cell, text, bold=False, italic=False, size=9, color=None,
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT):
|
||||
p = cell.paragraphs[0]
|
||||
p.alignment = align
|
||||
p.paragraph_format.space_before = Pt(1)
|
||||
p.paragraph_format.space_after = Pt(1)
|
||||
run = p.add_run()
|
||||
for i, line in enumerate(str(text or "").split("\n")):
|
||||
if i:
|
||||
run.add_break()
|
||||
run.add_text(line)
|
||||
run.bold = bold
|
||||
run.italic = italic
|
||||
run.font.name = "Times New Roman"
|
||||
run.font.size = Pt(size)
|
||||
if color:
|
||||
run.font.color.rgb = RGBColor.from_string(color)
|
||||
cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
|
||||
|
||||
|
||||
def _hdr(cell, text, size=9):
|
||||
_shd(cell, DARK_BLUE)
|
||||
_ct(cell, text, bold=True, color=WHITE, size=size, align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
|
||||
|
||||
def _borders(table):
|
||||
tbl_pr = table._tbl.tblPr
|
||||
el = OxmlElement("w:tblBorders")
|
||||
for sisi in ("top", "left", "bottom", "right", "insideH", "insideV"):
|
||||
b = OxmlElement(f"w:{sisi}")
|
||||
b.set(qn("w:val"), "single")
|
||||
b.set(qn("w:sz"), "4")
|
||||
b.set(qn("w:color"), "000000")
|
||||
el.append(b)
|
||||
tbl_pr.append(el)
|
||||
|
||||
|
||||
def _f(x, nd=2):
|
||||
return f"{x:.{nd}f}" if isinstance(x, (int, float)) else "—"
|
||||
|
||||
|
||||
def build_portofolio(d: dict) -> bytes:
|
||||
doc = Document()
|
||||
for sec in doc.sections:
|
||||
sec.orientation = WD_ORIENT.LANDSCAPE
|
||||
sec.page_width, sec.page_height = Cm(29.7), Cm(21.0)
|
||||
sec.top_margin = sec.bottom_margin = Cm(2)
|
||||
sec.left_margin = sec.right_margin = Cm(2)
|
||||
|
||||
ident = d.get("identitas", {})
|
||||
dok = d.get("dokumen", {})
|
||||
|
||||
def para(text, size=11, bold=False, italic=False, align=WD_ALIGN_PARAGRAPH.LEFT,
|
||||
before=6, after=3):
|
||||
p = doc.add_paragraph()
|
||||
p.alignment = align
|
||||
p.paragraph_format.space_before = Pt(before)
|
||||
p.paragraph_format.space_after = Pt(after)
|
||||
r = p.add_run(text)
|
||||
r.bold, r.italic = bold, italic
|
||||
r.font.name = "Times New Roman"
|
||||
r.font.size = Pt(size)
|
||||
return p
|
||||
|
||||
def bagian(judul):
|
||||
para(judul, size=12, bold=True, before=14)
|
||||
|
||||
# ── COVER ──
|
||||
for _ in range(3):
|
||||
doc.add_paragraph()
|
||||
para("PORTOFOLIO MATA KULIAH", size=22, bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
para(f"{ident.get('nama', '')} ({ident.get('kode', '')})", size=16, bold=True,
|
||||
align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
doc.add_paragraph()
|
||||
if LOGO.exists():
|
||||
p = doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
p.add_run().add_picture(str(LOGO), width=Cm(4.5))
|
||||
doc.add_paragraph()
|
||||
para(PRODI, size=13, bold=True, align=WD_ALIGN_PARAGRAPH.CENTER, after=0)
|
||||
para(FAKULTAS, size=12, align=WD_ALIGN_PARAGRAPH.CENTER, before=0, after=0)
|
||||
para("UNIVERSITAS TANJUNGPURA", size=12, align=WD_ALIGN_PARAGRAPH.CENTER,
|
||||
before=0, after=0)
|
||||
para(f"TAHUN AJARAN {d.get('tahun', '')}", size=12, align=WD_ALIGN_PARAGRAPH.CENTER,
|
||||
before=0)
|
||||
doc.paragraphs[-1].runs[-1].add_break(WD_BREAK.PAGE)
|
||||
|
||||
# ── LEMBAR PENGESAHAN ──
|
||||
para("LEMBAR PENGESAHAN", size=14, bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
para(f"PORTOFOLIO MATA KULIAH — KURIKULUM OBE {dok.get('kurikulum_tahun', '')}",
|
||||
size=11, align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
doc.add_paragraph()
|
||||
ti = doc.add_table(rows=5, cols=3)
|
||||
_borders(ti)
|
||||
isian = [
|
||||
("Nama Dokumen", f"Portofolio MK {ident.get('nama', '')}"),
|
||||
("Nomor Dokumen", dok.get("nomor", "") or "—"),
|
||||
("No. Revisi", dok.get("revisi", "") or "—"),
|
||||
("Tanggal Terbit", dok.get("tgl_terbit", "") or "—"),
|
||||
("Status Dokumen", dok.get("status_dokumen", "Master")),
|
||||
]
|
||||
for i, (k, v) in enumerate(isian):
|
||||
_shd(ti.cell(i, 0), LIGHT_BLUE)
|
||||
_ct(ti.cell(i, 0), k, bold=True, size=10)
|
||||
_ct(ti.cell(i, 1), ":", size=10, align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
_ct(ti.cell(i, 2), v, size=10)
|
||||
ti.rows[i].cells[0].width = Cm(5)
|
||||
ti.rows[i].cells[1].width = Cm(0.7)
|
||||
ti.rows[i].cells[2].width = Cm(12)
|
||||
doc.add_paragraph()
|
||||
tt = doc.add_table(rows=4, cols=3)
|
||||
_borders(tt)
|
||||
for ci, (peran, nama) in enumerate([
|
||||
("DISUSUN OLEH\nKoordinator Mata Kuliah", ident.get("koordinator", "")),
|
||||
("DIPERIKSA OLEH\nKetua Program Studi", ident.get("kaprodi", "")),
|
||||
("DISAHKAN OLEH\nDekan", dok.get("dekan", "")),
|
||||
]):
|
||||
_hdr(tt.cell(0, ci), peran, size=10)
|
||||
_ct(tt.cell(1, ci), "\n\n\n")
|
||||
_ct(tt.cell(2, ci), f"( {nama or '______________________'} )",
|
||||
align=WD_ALIGN_PARAGRAPH.CENTER, size=10)
|
||||
_ct(tt.cell(3, ci), "NIP. ......................................",
|
||||
align=WD_ALIGN_PARAGRAPH.CENTER, size=10)
|
||||
doc.paragraphs[-1].runs[-1].add_break(WD_BREAK.PAGE) if doc.paragraphs[-1].runs else doc.add_page_break()
|
||||
|
||||
# ── A. CPL ──
|
||||
bagian("A. CAPAIAN PEMBELAJARAN LULUSAN (CPL) YANG DIBEBANKAN PADA MK")
|
||||
cpls = d.get("cpl", [])
|
||||
ta = doc.add_table(rows=1 + len(cpls), cols=2)
|
||||
_borders(ta)
|
||||
_hdr(ta.cell(0, 0), "Kode CPL")
|
||||
_hdr(ta.cell(0, 1), "Uraian CPL")
|
||||
for i, c in enumerate(cpls, 1):
|
||||
_ct(ta.cell(i, 0), c["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
_ct(ta.cell(i, 1), c["desk"])
|
||||
ta.rows[i].cells[0].width = Cm(3)
|
||||
ta.rows[i].cells[1].width = Cm(22)
|
||||
|
||||
# ── B. CPMK & Sub-CPMK ──
|
||||
bagian("B. CPMK DAN SUB-CPMK")
|
||||
cpmks = d.get("cpmk", [])
|
||||
tb = doc.add_table(rows=1 + len(cpmks), cols=3)
|
||||
_borders(tb)
|
||||
_hdr(tb.cell(0, 0), "Kode CPMK")
|
||||
_hdr(tb.cell(0, 1), "Uraian CPMK")
|
||||
_hdr(tb.cell(0, 2), "CPL Didukung")
|
||||
for i, c in enumerate(cpmks, 1):
|
||||
_ct(tb.cell(i, 0), c["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
_ct(tb.cell(i, 1), c["desk"])
|
||||
_ct(tb.cell(i, 2), ", ".join(c.get("cpl", [])), align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
doc.add_paragraph()
|
||||
subs = d.get("sub", [])
|
||||
ts = doc.add_table(rows=1 + len(subs), cols=3)
|
||||
_borders(ts)
|
||||
_hdr(ts.cell(0, 0), "Kode Sub-CPMK")
|
||||
_hdr(ts.cell(0, 1), "Kemampuan Akhir Tiap Tahapan Belajar")
|
||||
_hdr(ts.cell(0, 2), "CPMK")
|
||||
for i, s in enumerate(subs, 1):
|
||||
_ct(ts.cell(i, 0), s["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
_ct(ts.cell(i, 1), s["uraian"])
|
||||
_ct(ts.cell(i, 2), s.get("cpmk", ""), align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
|
||||
# ── C. korelasi CPL–Sub-CPMK ──
|
||||
bagian("C. KORELASI CPL TERHADAP SUB-CPMK (BOBOT PENILAIAN, %)")
|
||||
korelasi = d.get("korelasi", {}) # {sub: {cpl: porsi}}
|
||||
bobot_sub = d.get("bobot_sub", {})
|
||||
minggu_sub = d.get("minggu_sub", {})
|
||||
kode_cpl = [c["kode"] for c in cpls]
|
||||
tc = doc.add_table(rows=1 + len(subs) + 1, cols=3 + len(kode_cpl))
|
||||
_borders(tc)
|
||||
_hdr(tc.cell(0, 0), "Sub-CPMK")
|
||||
for j, cp in enumerate(kode_cpl):
|
||||
_hdr(tc.cell(0, 1 + j), f"{cp}\n(%)")
|
||||
_hdr(tc.cell(0, 1 + len(kode_cpl)), "Bobot\nPenilaian (%)")
|
||||
_hdr(tc.cell(0, 2 + len(kode_cpl)), "Jumlah\nMinggu")
|
||||
tot_cpl = {cp: 0.0 for cp in kode_cpl}
|
||||
tot_bobot = 0.0
|
||||
for i, s in enumerate(subs, 1):
|
||||
_ct(tc.cell(i, 0), s["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
for j, cp in enumerate(kode_cpl):
|
||||
v = korelasi.get(s["kode"], {}).get(cp)
|
||||
_ct(tc.cell(i, 1 + j), _f(v, 1) if v else "", align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
tot_cpl[cp] += v or 0
|
||||
b = bobot_sub.get(s["kode"])
|
||||
tot_bobot += b or 0
|
||||
_ct(tc.cell(i, 1 + len(kode_cpl)), _f(b, 1) if b else "—",
|
||||
align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
_ct(tc.cell(i, 2 + len(kode_cpl)), str(minggu_sub.get(s["kode"], "")),
|
||||
align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
last = len(subs) + 1
|
||||
_shd(tc.cell(last, 0), LIGHT_BLUE)
|
||||
_ct(tc.cell(last, 0), "Total", bold=True, align=WD_ALIGN_PARAGRAPH.RIGHT)
|
||||
for j, cp in enumerate(kode_cpl):
|
||||
_shd(tc.cell(last, 1 + j), LIGHT_BLUE)
|
||||
_ct(tc.cell(last, 1 + j), _f(tot_cpl[cp], 1), bold=True,
|
||||
align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
_shd(tc.cell(last, 1 + len(kode_cpl)), LIGHT_BLUE)
|
||||
_ct(tc.cell(last, 1 + len(kode_cpl)), _f(tot_bobot, 1), bold=True,
|
||||
align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
_shd(tc.cell(last, 2 + len(kode_cpl)), LIGHT_BLUE)
|
||||
|
||||
# ── D. rencana penilaian & tugas (dari RPS) ──
|
||||
bagian("D. RENCANA PENILAIAN DAN RENCANA TUGAS (DARI RPS)")
|
||||
rencana = d.get("rencana", [])
|
||||
td = doc.add_table(rows=1 + max(len(rencana), 1), cols=4)
|
||||
_borders(td)
|
||||
for ci, h in enumerate(["Minggu ke-", "Sub-CPMK", "Bentuk Asesmen", "Bobot (%)"]):
|
||||
_hdr(td.cell(0, ci), h)
|
||||
for i, r in enumerate(rencana, 1):
|
||||
_ct(td.cell(i, 0), str(r["minggu"]), align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
_ct(td.cell(i, 1), r["sub"])
|
||||
_ct(td.cell(i, 2), r["asesmen"])
|
||||
_ct(td.cell(i, 3), str(r["bobot"]), align=WD_ALIGN_PARAGRAPH.CENTER)
|
||||
para("RPS lengkap diterbitkan sebagai dokumen terpisah dari aplikasi yang sama.",
|
||||
size=9, italic=True)
|
||||
|
||||
# ── E. realisasi ──
|
||||
bagian("E. REALISASI PEMBELAJARAN")
|
||||
te = doc.add_table(rows=1, cols=1)
|
||||
_borders(te)
|
||||
_ct(te.cell(0, 0), d.get("realisasi", "") or "—", size=10)
|
||||
|
||||
# ── F. penilaian ──
|
||||
bagian("F. PENILAIAN MATA KULIAH")
|
||||
nilai = d.get("nilai", {})
|
||||
skor_sub = d.get("skor_sub", {})
|
||||
if nilai.get("mode") == "per_mahasiswa":
|
||||
mhs = nilai.get("mahasiswa", [])
|
||||
akhir = {a["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()
|
||||
@@ -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()
|
||||
@@ -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 CPL–CPMK & 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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 0–100 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 0–100, 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 |
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,121 @@
|
||||
{% extends "base.html" %}
|
||||
{% block judul %}Kelola Akun & Mahasiswa — RPS & Portofolio UNTAN{% endblock %}
|
||||
{% block isi %}
|
||||
<h1>Kelola Akun & Mahasiswa</h1>
|
||||
{% if pesan %}<div class="info">{{ pesan }}</div>{% endif %}
|
||||
|
||||
<h2 class="bagian">A. Akun Pengguna & 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 & 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 %}
|
||||
@@ -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 & 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 & 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>
|
||||
@@ -0,0 +1,140 @@
|
||||
{% extends "base.html" %}
|
||||
{% block isi %}
|
||||
<div class="kpi-baris" title="Persentase dihitung dari MK pada semester terpilih — hijau ≥ 75 %, kuning 40–74 %, 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 & bobot) → <b>Nilai</b> (unggah Excel / rata-rata) → <b>Portofolio</b> (refleksi & dokumen akhir). Rekap prodi menggulung otomatis dari nilai yang masuk.</p>
|
||||
{% endblock %}
|
||||
@@ -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, 0–100; kolom Nilai Akhir & 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 0–100 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 & 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 & 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 (0–100)</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 %}
|
||||
@@ -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 →</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 →</a></div>
|
||||
{% endif %}
|
||||
{% if ada_rps and ada_nilai %}
|
||||
<div class="info">RPS ✓ dan Nilai ✓ 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 & 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 & 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 %}
|
||||
@@ -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
|
||||
(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 & 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 1–7 (UTS)</option>
|
||||
<option value="__uas__" {% if m.sub == '__uas__' %}selected{% endif %}>Sub-CPMK minggu 9–15 (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 & 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 %}
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
{% block judul %}{{ 'Penyiapan Awal' if mode == 'setup' else 'Masuk' }} — RPS & 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 %}
|
||||
@@ -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 CPL–CPMK–MK–Sub-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 0–100 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 & 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 %}
|
||||
@@ -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 & penyumbang terlemah.
|
||||
Tabel rinci: <a href="/rekap">Rekap</a>.</p>
|
||||
<div class="legenda">
|
||||
<span><i class="chip" style="background:var(--blue)"></i> tercapai (≥ 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 & 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 & 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 %}
|
||||
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
{% block judul %}Rekap Ketercapaian CPL/CPMK — Informatika UNTAN{% endblock %}
|
||||
{% block isi %}
|
||||
<h1>Rekap Ketercapaian CPL & 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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user