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:
@@ -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)
|
||||
Reference in New Issue
Block a user