# -*- 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 _migrasi_role_pengguna(conn: sqlite3.Connection) -> None: """Skema lama hanya kenal role 'dosen'; pecah jadi dosen_mk/dosen_rps_mk. Akun 'dosen' lama dipetakan ke 'dosen_rps_mk' agar hak akses (termasuk edit RPS) yang sudah mereka miliki tidak berkurang oleh migrasi ini. """ row = conn.execute( "SELECT sql FROM sqlite_master WHERE type='table' AND name='pengguna'" ).fetchone() if not row or "dosen_rps_mk" in row["sql"]: return conn.execute("ALTER TABLE pengguna RENAME TO pengguna_lama") conn.execute( """CREATE TABLE pengguna ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, nama TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('kaprodi','dosen_mk','dosen_rps_mk')), pw_hash TEXT NOT NULL, dibuat TEXT NOT NULL )""" ) conn.execute( """INSERT INTO pengguna (id, username, nama, role, pw_hash, dibuat) SELECT id, username, nama, CASE WHEN role='dosen' THEN 'dosen_rps_mk' ELSE role END, pw_hash, dibuat FROM pengguna_lama""" ) conn.execute("DROP TABLE pengguna_lama") 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_mk','dosen_rps_mk')), pw_hash TEXT NOT NULL, dibuat TEXT NOT NULL )""" ) _migrasi_role_pengguna(conn) 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)