From afb9e72ba2a101611ed5f3af592f7c69c1b896ae Mon Sep 17 00:00:00 2001
From: Power BI Dev
Date: Tue, 21 Jul 2026 12:44:57 +0700
Subject: [PATCH] Tambah asesmen per komponen, batasi Dasbor/Rekap ke kaprodi,
penugasan per kelas
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Halaman nilai: tombol isi-cepat per komponen penilaian (bagian G RPS) yang
menyalin nilai ke seluruh Sub-CPMK terkait; komponen berulang (Kuis, Tugas,
Presentasi, Tes Lisan) mendukung banyak sesi dengan rata-rata otomatis,
komponen sekali-jalan (UTS/UAS) mengedit sesi yang sama. Sub-CPMK yang
diukur >1 komponen digulung rata tertimbang bobot (hitung.skor_dari_sesi).
- Hapus jalur unggah Excel & mode "ketik rata-rata kelas" (agregat) dari
input nilai — proses diseragamkan: hanya grid manual + tombol komponen,
keduanya bersumber dari daftar mahasiswa impor kaprodi.
- Dasbor Monitoring, Rekap CPL/CPMK, dan Riwayat kini khusus kaprodi (dosen
tidak lagi melihat rollup lintas-MK yang bukan miliknya).
- Penugasan dosen ke MK bisa dibatasi per kelas (kolom kelas baru di tabel
penugasan, migrasi otomatis & aman untuk DB yang sudah berjalan) — dosen
MK dengan banyak kelas paralel hanya melihat & menilai kelasnya sendiri;
hapus nilai MK penuh dikunci untuk dosen yang dibatasi kelas.
---
app/db.py | 45 ++-
app/hitung.py | 50 ++++
app/main.py | 361 +++++++++++++++++--------
app/nilai_xlsx.py | 96 -------
app/templates/admin.html | 11 +-
app/templates/base.html | 8 +-
app/templates/form_nilai.html | 106 ++++----
app/templates/form_nilai_komponen.html | 87 ++++++
8 files changed, 490 insertions(+), 274 deletions(-)
create mode 100644 app/templates/form_nilai_komponen.html
diff --git a/app/db.py b/app/db.py
index a2e0626..12b11a8 100644
--- a/app/db.py
+++ b/app/db.py
@@ -138,6 +138,15 @@ def _migrasi_role_pengguna(conn: sqlite3.Connection) -> None:
conn.execute("DROP TABLE pengguna_lama")
+def _migrasi_kelas_penugasan(conn: sqlite3.Connection) -> None:
+ """Skema lama penugasan hanya (user_id, mk_kode) — tambah kolom kelas
+ (kosong = seluruh kelas MK ini, dipakai dosen_rps_mk; terisi = dosen_mk
+ yang hanya mengampu kelas tertentu)."""
+ kolom = {r["name"] for r in conn.execute("PRAGMA table_info(penugasan)")}
+ if "kelas" not in kolom:
+ conn.execute("ALTER TABLE penugasan ADD COLUMN kelas TEXT NOT NULL DEFAULT ''")
+
+
def init_db() -> None:
with get_conn() as conn:
for tabel in ("rps", "portofolio", "nilai"):
@@ -195,6 +204,7 @@ def init_db() -> None:
PRIMARY KEY (user_id, mk_kode)
)"""
)
+ _migrasi_kelas_penugasan(conn)
conn.execute(
"""CREATE TABLE IF NOT EXISTS mahasiswa (
npm TEXT NOT NULL,
@@ -391,12 +401,15 @@ def daftar_pengguna() -> list[dict]:
"ORDER BY role, nama"
).fetchall()
tugas = conn.execute(
- "SELECT user_id, mk_kode FROM penugasan ORDER BY mk_kode"
+ "SELECT user_id, mk_kode, kelas FROM penugasan ORDER BY mk_kode"
).fetchall()
- per_user: dict[int, list[str]] = {}
+ per_user_kode: dict[int, list[str]] = {}
+ per_user_kelas: dict[int, dict[str, 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]
+ per_user_kode.setdefault(t["user_id"], []).append(t["mk_kode"])
+ per_user_kelas.setdefault(t["user_id"], {})[t["mk_kode"]] = t["kelas"] or ""
+ return [{**dict(r), "mk": per_user_kode.get(r["id"], []),
+ "kelas_map": per_user_kelas.get(r["id"], {})} for r in rows]
def hapus_pengguna(user_id: int) -> None:
@@ -413,12 +426,14 @@ def ubah_password(user_id: int, pw_hash: str) -> None:
conn.execute("DELETE FROM sesi WHERE user_id=?", (user_id,))
-def set_penugasan(user_id: int, kode_list: list[str]) -> None:
+def set_penugasan(user_id: int, penugasan: list[tuple[str, str]]) -> None:
+ """penugasan = [(mk_kode, kelas), ...] — kelas '' berarti seluruh kelas MK
+ ini; boleh CSV ('A,B') bila dosen mengampu beberapa kelas MK yang sama."""
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],
+ "INSERT OR IGNORE INTO penugasan (user_id, mk_kode, kelas) VALUES (?,?,?)",
+ [(user_id, k, kelas) for k, kelas in penugasan],
)
@@ -430,6 +445,22 @@ def mk_diampu(user_id: int) -> set[str]:
return {r["mk_kode"] for r in rows}
+def kelas_diampu(user_id: int, mk_kode: str) -> set[str] | None:
+ """Kelas yang diizinkan dosen ini untuk MK tsb. None = seluruh kelas
+ (tak dibatasi); set kosong = tidak ditugaskan sama sekali ke MK ini."""
+ with get_conn() as conn:
+ row = conn.execute(
+ "SELECT kelas FROM penugasan WHERE user_id=? AND mk_kode=?",
+ (user_id, mk_kode),
+ ).fetchone()
+ if not row:
+ return set()
+ kelas = (row["kelas"] or "").strip()
+ if not kelas:
+ return None
+ return {k.strip() for k in kelas.split(",") if k.strip()}
+
+
def pengampu_per_mk() -> dict[str, list[str]]:
"""{mk_kode: [nama dosen, ...]} — untuk kolom pengampu di beranda."""
with get_conn() as conn:
diff --git a/app/hitung.py b/app/hitung.py
index 49eefd7..bf5f92b 100644
--- a/app/hitung.py
+++ b/app/hitung.py
@@ -91,6 +91,56 @@ def bobot_sub_dari_rps(rps_data: dict | None) -> dict[str, float]:
return bobot
+def skor_dari_sesi(komponen_daftar: list[dict], sesi: dict) -> dict[str, dict[str, float]]:
+ """npm -> {sub: skor}, dihitung dari log sesi input per-komponen (tombol
+ 'isi per komponen' di halaman nilai): rata sederhana antar sesi dalam
+ komponen yang sama (mis. beberapa kali Kuis), lalu rata tertimbang bobot
+ antar-komponen bila satu Sub-CPMK diukur oleh >1 komponen (mis. Kuis dan
+ Presentasi sama-sama menilai satu Sub-CPMK) — bobot mengikuti alokasi
+ yang sama dengan bobot_sub_dari_rps (bobot komponen dibagi rata ke subnya).
+ """
+ komponen_by_i = {k["i"]: k for k in komponen_daftar}
+ per_komponen_avg: dict[int, dict[str, float]] = {}
+ for i_str, daftar_sesi in (sesi or {}).items():
+ try:
+ i = int(i_str)
+ except (TypeError, ValueError):
+ continue
+ if i not in komponen_by_i or not daftar_sesi:
+ continue
+ per_npm: dict[str, list[float]] = {}
+ for s in daftar_sesi:
+ for npm, v in (s.get("nilai") or {}).items():
+ if v is not None:
+ per_npm.setdefault(npm, []).append(float(v))
+ per_komponen_avg[i] = {npm: _rata(xs) for npm, xs in per_npm.items()}
+
+ kontribusi_per_sub: dict[str, list[tuple[float, dict]]] = {}
+ for i, avg_map in per_komponen_avg.items():
+ k = komponen_by_i[i]
+ try:
+ bobot = float(k.get("bobot") or 0)
+ except (TypeError, ValueError):
+ bobot = 0
+ n_subs = len(k["subs"]) or 1
+ w = bobot / n_subs if bobot else 1.0
+ for sub in k["subs"]:
+ kontribusi_per_sub.setdefault(sub, []).append((w, avg_map))
+
+ out: dict[str, dict[str, float]] = {}
+ for sub, kontribusi in kontribusi_per_sub.items():
+ npms: set[str] = set()
+ for _, avg_map in kontribusi:
+ npms |= avg_map.keys()
+ for npm in npms:
+ pasangan = [(w, avg_map[npm]) for w, avg_map in kontribusi if npm in avg_map]
+ total_w = sum(w for w, _ in pasangan)
+ nilai = (sum(w * v for w, v in pasangan) / total_w) if total_w \
+ else _rata([v for _, v in pasangan])
+ out.setdefault(npm, {})[sub] = nilai
+ return out
+
+
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 = []
diff --git a/app/main.py b/app/main.py
index 75d6f4b..b4b7255 100644
--- a/app/main.py
+++ b/app/main.py
@@ -3,6 +3,7 @@
import json
import pathlib
import re
+from datetime import datetime
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
@@ -65,6 +66,21 @@ def boleh_mk(user: dict, kode: str) -> bool:
return user["role"] in ("dosen_mk", "dosen_rps_mk") and kode in db.mk_diampu(user["id"])
+def _kelas_dosen(user: dict, kode: str) -> set[str] | None:
+ """Kelas yang dibatasi untuk dosen ini pada MK ini. None = tak dibatasi
+ (kaprodi, atau penugasan tanpa kelas spesifik — seluruh kelas MK)."""
+ if user["role"] == "kaprodi":
+ return None
+ return db.kelas_diampu(user["id"], kode)
+
+
+def _npm_diizinkan(kode: str, kelas_dosen: set[str] | None) -> set[str] | None:
+ """NPM yang boleh ditulis dosen ini. None = tak dibatasi (semua NPM MK ini)."""
+ if kelas_dosen is None:
+ return None
+ return {m["npm"] for m in db.mahasiswa_mk(kode) if m.get("kelas") in kelas_dosen}
+
+
def _masuk(user_id: int) -> RedirectResponse:
token = auth.token_baru()
db.buat_sesi(user_id, token)
@@ -208,7 +224,8 @@ async def admin_penugasan(request: Request, uid: int):
return tolak
form = await request.form()
kode_valid = {m["kode"] for m in db.daftar_mk()}
- pilihan = [k for k in form.getlist("mk") if k in kode_valid]
+ dipilih = [k for k in form.getlist("mk") if k in kode_valid]
+ pilihan = [(k, (form.get(f"kelas_{k}") or "").strip()) for k in dipilih]
db.set_penugasan(uid, pilihan)
p = db.pengguna_by_id(uid)
return RedirectResponse(
@@ -261,12 +278,12 @@ async def admin_impor_mahasiswa(request: Request):
# komponen penilaian tetap — teknik asesmen baku prodi; bobot boleh 0 (opsional,
# tak dipakai MK ybs) tapi total seluruh komponen tak boleh melebihi 100%.
KOMPONEN_DEFAULT = [
- {"nama": "Partisipasi (Kuis)", "subs": [], "bobot": 10},
- {"nama": "Observasi (Praktik/Tugas)", "subs": [], "bobot": 20},
- {"nama": "Unjuk Kerja (Presentasi)", "subs": [], "bobot": 10},
- {"nama": "Tes Tulis (UTS)", "subs": [], "bobot": 25},
- {"nama": "Tes Tulis (UAS)", "subs": [], "bobot": 25},
- {"nama": "Tes Lisan (Tugas Kelompok)", "subs": [], "bobot": 10},
+ {"nama": "Partisipasi (Kuis)", "subs": [], "bobot": 10, "ulang": True},
+ {"nama": "Observasi (Praktik/Tugas)", "subs": [], "bobot": 20, "ulang": True},
+ {"nama": "Unjuk Kerja (Presentasi)", "subs": [], "bobot": 10, "ulang": True},
+ {"nama": "Tes Tulis (UTS)", "subs": [], "bobot": 25, "ulang": False},
+ {"nama": "Tes Tulis (UAS)", "subs": [], "bobot": 25, "ulang": False},
+ {"nama": "Tes Lisan (Tugas Kelompok)", "subs": [], "bobot": 10, "ulang": True},
]
@@ -508,21 +525,13 @@ def unduh_rps(kode: str):
# ═════════════════════════ TAHAP 2: NILAI & PENGUKURAN ═════════════════════════
-def _konteks_nilai(request, kode, pesan="", peringatan=None):
- peta = db.peta_mk(kode)
- tersimpan = db.ambil_dok("nilai", kode)
- pengaturan = db.ambil_pengaturan()
- ambang = float(pengaturan["ambang"])
- hasil, akhir = None, {}
- if tersimpan:
- hasil = hitung.rollup_mk(peta, tersimpan["data"], ambang)
- if tersimpan["data"].get("mode") == "per_mahasiswa":
- rps = db.ambil_rps(kode)
- bobot_sub = hitung.bobot_sub_dari_rps(rps["data"] if rps else None)
- akhir = {a["npm"]: a["akhir"]
- for a in hitung.nilai_akhir_mahasiswa(tersimpan["data"], bobot_sub)}
- # grid input langsung: gabungan daftar impor kaprodi + nilai tersimpan
- terdaftar = db.mahasiswa_mk(kode)
+def _grid_mahasiswa(tersimpan: dict | None, terdaftar: list[dict],
+ kelas_dosen: set[str] | None = None) -> list[dict]:
+ """Gabungan daftar impor kaprodi + nilai tersimpan (mahasiswa yang sudah
+ punya nilai tapi tak lagi terdaftar tetap ditampilkan, tak hilang).
+ kelas_dosen membatasi hasil ke kelas yang diampu dosen ini (None = semua
+ kelas); mahasiswa dari kelas lain, termasuk entri tak-terdaftar yang
+ kelasnya tak diketahui, disaring keluar demi privasi nilai kelas lain."""
skor_map, nama_map = {}, {}
if tersimpan and tersimpan["data"].get("mode") == "per_mahasiswa":
for m in tersimpan["data"]["mahasiswa"]:
@@ -537,39 +546,71 @@ def _konteks_nilai(request, kode, pesan="", peringatan=None):
if npm not in ada:
grid.append({"npm": npm, "nama": nama_map.get(npm, ""), "kelas": "",
"skor": sk})
+ if kelas_dosen is not None:
+ grid = [g for g in grid if g["kelas"] in kelas_dosen]
+ return grid
+
+
+def _komponen_daftar(kode: str) -> list[dict]:
+ """Komponen penilaian RPS yang sudah dikaitkan ke Sub-CPMK (bagian G) —
+ dipakai untuk tombol isi-cepat per komponen di halaman nilai. 'ulang'
+ menandai komponen yang boleh diisi berkali-kali sepanjang semester (Kuis,
+ Tugas, Presentasi, Tes Lisan) — rata-rata otomatis antar sesi — versus
+ komponen sekali-jalan (UTS/UAS)."""
+ rps = db.ambil_rps(kode)
+ if not rps:
+ return []
+ tersimpan = db.ambil_dok("nilai", kode)
+ sesi_semua = ((tersimpan["data"].get("sesi") or {}) if tersimpan else {})
+ out = []
+ for idx, k in enumerate(_normalisasi_komponen(rps["data"].get("komponen", []))):
+ if not k["subs"]:
+ continue
+ out.append({"i": idx, "nama": k["nama"], "bobot": k["bobot"],
+ "subs": k["subs"], "n_subs": len(k["subs"]),
+ "ulang": KOMPONEN_DEFAULT[idx]["ulang"],
+ "n_sesi": len(sesi_semua.get(str(idx), []))})
+ return out
+
+
+def _ambil_komponen(kode: str, i: int) -> dict | None:
+ rps = db.ambil_rps(kode)
+ if not rps:
+ return None
+ komponen_list = _normalisasi_komponen(rps["data"].get("komponen", []))
+ if i < 0 or i >= len(komponen_list) or not komponen_list[i]["subs"]:
+ return None
+ k = dict(komponen_list[i])
+ k["ulang"] = KOMPONEN_DEFAULT[i]["ulang"]
+ return k
+
+
+def _konteks_nilai(request, kode, pesan="", peringatan=None):
+ peta = db.peta_mk(kode)
+ tersimpan = db.ambil_dok("nilai", kode)
+ pengaturan = db.ambil_pengaturan()
+ ambang = float(pengaturan["ambang"])
+ hasil, akhir = None, {}
+ if tersimpan:
+ hasil = hitung.rollup_mk(peta, tersimpan["data"], ambang)
+ if tersimpan["data"].get("mode") == "per_mahasiswa":
+ rps = db.ambil_rps(kode)
+ bobot_sub = hitung.bobot_sub_dari_rps(rps["data"] if rps else None)
+ akhir = {a["npm"]: a["akhir"]
+ for a in hitung.nilai_akhir_mahasiswa(tersimpan["data"], bobot_sub)}
+ kelas_dosen = _kelas_dosen(request.state.user, kode)
+ terdaftar = db.mahasiswa_mk(kode)
+ grid = _grid_mahasiswa(tersimpan, terdaftar, kelas_dosen)
return render(
request, "form_nilai.html",
{"peta": peta, "tersimpan": tersimpan, "hasil": hasil, "ambang": ambang,
"akhir": akhir, "tahun": db.tahun_aktif(), "pesan": pesan,
- "terdaftar": terdaftar, "grid": grid,
+ "grid": grid, "kelas_dosen": kelas_dosen,
+ "komponen_daftar": _komponen_daftar(kode),
"peringatan": peringatan or []},
)
-def _gabung_nilai(lama: dict, baru: list[dict], sumber: str, oleh: str) -> dict:
- """Gabungkan unggahan ke data per-mahasiswa tersimpan (team teaching):
- sel terisi memperbarui, sel kosong tidak menghapus nilai dosen lain,
- mahasiswa yang tidak ada di file dibiarkan utuh."""
- urut = [dict(m, skor=dict(m.get("skor") or {}))
- for m in lama.get("mahasiswa", [])]
- per_npm = {m["npm"]: m for m in urut if m.get("npm")}
- for m in baru:
- ada = per_npm.get(m.get("npm"))
- if ada is None:
- urut.append(m)
- if m.get("npm"):
- per_npm[m["npm"]] = m
- else:
- if m.get("nama"):
- ada["nama"] = m["nama"]
- ada["skor"].update(m.get("skor") or {})
- riwayat = lama.get("sumber") or []
- if isinstance(riwayat, str):
- riwayat = [riwayat]
- riwayat.append(f"{sumber} ({oleh})")
- return {"mode": "per_mahasiswa", "mahasiswa": urut, "sumber": riwayat[-8:]}
-
-
@app.get("/nilai/{kode}", response_class=HTMLResponse)
def form_nilai(request: Request, kode: str, pesan: str = ""):
if not db.peta_mk(kode):
@@ -581,22 +622,131 @@ def form_nilai(request: Request, kode: str, pesan: str = ""):
return _konteks_nilai(request, kode, pesan)
-@app.get("/nilai/{kode}/template.xlsx")
-def template_nilai(request: Request, kode: str):
+@app.get("/nilai/{kode}/komponen/{i}", response_class=HTMLResponse)
+def form_nilai_komponen(request: Request, kode: str, i: int, sesi_id: int = -1, pesan: str = ""):
peta = db.peta_mk(kode)
if not peta:
return RedirectResponse("/", status_code=303)
if not boleh_mk(request.state.user, kode):
- return RedirectResponse(f"/rps/{kode}", status_code=303)
- isi = nilai_xlsx.buat_template(peta, db.tahun_aktif(), db.mahasiswa_mk(kode))
- return Response(
- content=isi,
- media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- headers={"Content-Disposition":
- f'attachment; filename="Template_Nilai_{kode}.xlsx"'},
+ return RedirectResponse(
+ f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.",
+ status_code=303)
+ komponen = _ambil_komponen(kode, i)
+ if not komponen:
+ return RedirectResponse(
+ f"/nilai/{kode}?pesan=Komponen+belum+dikaitkan+ke+Sub-CPMK+di+RPS+bagian+G.",
+ status_code=303)
+ tersimpan = db.ambil_dok("nilai", kode)
+ daftar_sesi = ((tersimpan["data"].get("sesi") or {}) if tersimpan else {}).get(str(i), [])
+
+ edit = None
+ if not komponen["ulang"] and daftar_sesi:
+ # komponen sekali-jalan (UTS/UAS) sudah punya sesi — selalu edit sesi itu
+ edit, edit_id = daftar_sesi[0], 0
+ elif 0 <= sesi_id < len(daftar_sesi):
+ edit, edit_id = daftar_sesi[sesi_id], sesi_id
+ else:
+ edit_id = len(daftar_sesi) # sesi baru
+
+ nilai_awal = (edit or {}).get("nilai", {})
+ label_awal = (edit or {}).get("label") or (
+ komponen["nama"] if not komponen["ulang"]
+ else f"{komponen['nama']} {len(daftar_sesi) + 1}")
+
+ kelas_dosen = _kelas_dosen(request.state.user, kode)
+ grid = _grid_mahasiswa(tersimpan, db.mahasiswa_mk(kode), kelas_dosen)
+ for m in grid:
+ m["nilai_awal"] = nilai_awal.get(m["npm"])
+
+ return render(
+ request, "form_nilai_komponen.html",
+ {"peta": peta, "komponen": komponen, "i": i, "grid": grid,
+ "daftar_sesi": daftar_sesi, "edit_id": edit_id, "label_awal": label_awal,
+ "tahun": db.tahun_aktif(), "pesan": pesan},
)
+@app.post("/nilai/{kode}/komponen/{i}")
+async def simpan_nilai_komponen(request: Request, kode: str, i: int):
+ peta = db.peta_mk(kode)
+ if not peta:
+ return RedirectResponse("/", status_code=303)
+ if not boleh_mk(request.state.user, kode):
+ return RedirectResponse(
+ f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.",
+ status_code=303)
+ komponen = _ambil_komponen(kode, i)
+ if not komponen:
+ return RedirectResponse(f"/nilai/{kode}", status_code=303)
+ form = await request.form()
+ updated_by = request.state.user["nama"]
+ label = (form.get("label") or komponen["nama"]).strip()
+ try:
+ edit_id = int(form.get("edit_id") or -1)
+ except ValueError:
+ edit_id = -1
+
+ npm_diizinkan = _npm_diizinkan(kode, _kelas_dosen(request.state.user, kode))
+ nilai = {}
+ j = 0
+ while f"npm_{j}" in form:
+ npm = (form.get(f"npm_{j}") or "").strip()
+ v = (form.get(f"nilai_{j}") or "").strip()
+ if npm and v and (npm_diizinkan is None or npm in npm_diizinkan):
+ try:
+ x = float(v)
+ except ValueError:
+ x = None
+ if x is not None and 0 <= x <= 100:
+ nilai[npm] = x
+ j += 1
+
+ if not nilai:
+ return RedirectResponse(
+ f"/nilai/{kode}/komponen/{i}?pesan=Tidak+ada+nilai+yang+diisi.",
+ status_code=303)
+
+ lama = db.ambil_dok("nilai", kode)
+ lama_data = (dict(lama["data"]) if lama and lama["data"].get("mode") == "per_mahasiswa"
+ else {"mode": "per_mahasiswa", "mahasiswa": []})
+ sesi_semua = {k: list(v) for k, v in (lama_data.get("sesi") or {}).items()}
+ daftar_sesi = list(sesi_semua.get(str(i), []))
+
+ entri = {"label": label, "at": datetime.now(db.WIB).strftime("%Y-%m-%d %H:%M"),
+ "by": updated_by, "nilai": nilai}
+ if not komponen["ulang"] and daftar_sesi:
+ daftar_sesi[0] = entri # sekali-jalan: selalu menimpa sesi yang sama
+ elif 0 <= edit_id < len(daftar_sesi):
+ daftar_sesi[edit_id] = entri # mengedit sesi tertentu
+ else:
+ daftar_sesi.append(entri) # sesi baru
+ sesi_semua[str(i)] = daftar_sesi
+
+ komponen_daftar = _komponen_daftar(kode)
+ skor_terhitung = hitung.skor_dari_sesi(komponen_daftar, sesi_semua)
+ nama_map = {npm: m["nama"] for m in db.mahasiswa_mk(kode) for npm in [m["npm"]]}
+
+ urut = [dict(m, skor=dict(m.get("skor") or {})) for m in lama_data.get("mahasiswa", [])]
+ per_npm = {m["npm"]: m for m in urut if m.get("npm")}
+ for npm, subskor in skor_terhitung.items():
+ m = per_npm.get(npm)
+ if m is None:
+ m = {"npm": npm, "nama": nama_map.get(npm, ""), "skor": {}}
+ urut.append(m)
+ per_npm[npm] = m
+ m["skor"].update(subskor)
+
+ riwayat = lama_data.get("sumber") or []
+ if isinstance(riwayat, str):
+ riwayat = [riwayat]
+ riwayat.append(f"komponen {komponen['nama']} — {label} ({updated_by})")
+ data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sesi": sesi_semua,
+ "sumber": riwayat[-8:]}
+ db.simpan_dok("nilai", kode, data, "selesai", updated_by)
+ pesan = f"Nilai+{label.replace(' ', '+')}+tersimpan+untuk+{len(nilai)}+mahasiswa."
+ return RedirectResponse(f"/nilai/{kode}?pesan={pesan}", status_code=303)
+
+
@app.post("/nilai/{kode}")
async def simpan_nilai(request: Request, kode: str):
peta = db.peta_mk(kode)
@@ -609,21 +759,28 @@ async def simpan_nilai(request: Request, kode: str):
form = await request.form()
aksi = form.get("aksi")
updated_by = request.state.user["nama"]
- kode_sub = {s["kode"] for s in peta["subs"]}
if aksi == "hapus":
+ if _kelas_dosen(request.state.user, kode) is not None:
+ return _konteks_nilai(request, kode, peringatan=[
+ "Anda hanya diampukan sebagian kelas MK ini — menghapus seluruh nilai "
+ "hanya bisa dilakukan kaprodi atau dosen yang mengampu seluruh kelas."])
db.hapus_dok("nilai", kode)
return _konteks_nilai(
request, kode,
pesan="Seluruh nilai tersimpan MK ini dihapus — silakan mulai dari awal.")
if aksi == "grid":
- # input langsung: menyimpan persis isi tabel yang tampil
+ kelas_dosen = _kelas_dosen(request.state.user, kode)
+ npm_diizinkan = _npm_diizinkan(kode, kelas_dosen)
mahasiswa = []
i = 0
while f"npm_{i}" in form:
npm = (form.get(f"npm_{i}") or "").strip()
nama = (form.get(f"nama_{i}") or "").strip()
+ if npm_diizinkan is not None and npm not in npm_diizinkan:
+ i += 1
+ continue
skor = {}
for s in peta["subs"]:
v = (form.get(f"skor_{i}_{s['kode']}") or "").strip()
@@ -642,67 +799,40 @@ async def simpan_nilai(request: Request, kode: str):
return _konteks_nilai(request, kode,
peringatan=["Tidak ada nilai yang diisi."])
lama = db.ambil_dok("nilai", kode)
- riwayat = (lama["data"].get("sumber") if lama else None) or []
+ lama_data = lama["data"] if lama else {}
+ riwayat = lama_data.get("sumber") or []
if isinstance(riwayat, str):
riwayat = [riwayat]
riwayat.append(f"input langsung ({updated_by})")
- data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa,
- "sumber": riwayat[-8:]}
+ if kelas_dosen is None:
+ # dosen tak dibatasi kelas: tabel yang tampil = seluruh MK,
+ # jadi menyimpan persis isi tabel memang dimaksudkan sebagai pengganti
+ data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa, "sumber": riwayat[-8:]}
+ else:
+ # dosen dibatasi kelas: tabel yang tampil cuma kelasnya — gabungkan
+ # ke data lama, jangan sampai menghapus nilai kelas lain
+ urut = [dict(m, skor=dict(m.get("skor") or {}))
+ for m in lama_data.get("mahasiswa", [])]
+ per_npm = {m["npm"]: m for m in urut if m.get("npm")}
+ for m in mahasiswa:
+ ada = per_npm.get(m["npm"])
+ if ada is None:
+ urut.append(m)
+ per_npm[m["npm"]] = m
+ else:
+ ada["nama"] = m["nama"] or ada.get("nama", "")
+ ada["skor"] = m["skor"]
+ data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sumber": riwayat[-8:]}
+ if lama_data.get("sesi"):
+ # pertahankan log sesi per-komponen — grid ini hanya koreksi manual
+ # di atasnya, bukan pengganti sumber datanya
+ data["sesi"] = lama_data["sesi"]
db.simpan_dok("nilai", kode, data, "selesai", updated_by)
return _konteks_nilai(
request, kode,
pesan=f"Nilai {len(mahasiswa)} mahasiswa tersimpan (input langsung).")
- if aksi == "unggah":
- berkas = form.get("berkas")
- if berkas is None or not getattr(berkas, "filename", ""):
- return _konteks_nilai(request, kode,
- peringatan=["Pilih file .xlsx terlebih dahulu."])
- isi = await berkas.read()
- try:
- mahasiswa, peringatan = nilai_xlsx.parse_unggahan(isi, kode_sub)
- except Exception:
- return _konteks_nilai(request, kode,
- peringatan=["File tidak terbaca sebagai .xlsx."])
- if not mahasiswa:
- return _konteks_nilai(request, kode, peringatan=peringatan)
- lama = db.ambil_dok("nilai", kode)
- if lama and lama["data"].get("mode") == "per_mahasiswa":
- data = _gabung_nilai(lama["data"], mahasiswa, berkas.filename, updated_by)
- pesan = (f"Unggahan {berkas.filename} digabung: {len(mahasiswa)} mahasiswa "
- f"diperbarui, total {len(data['mahasiswa'])} mahasiswa tersimpan.")
- else:
- data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa,
- "sumber": [f"{berkas.filename} ({updated_by})"]}
- pesan = f"Nilai {len(mahasiswa)} mahasiswa tersimpan dari {berkas.filename}."
- db.simpan_dok("nilai", kode, data, "selesai", updated_by)
- return _konteks_nilai(request, kode, pesan=pesan, peringatan=peringatan)
-
- # aksi == 'agregat' — ketik rata-rata per Sub-CPMK
- lama = db.ambil_dok("nilai", kode)
- if lama and lama["data"].get("mode") == "per_mahasiswa":
- return _konteks_nilai(request, kode, peringatan=[
- "Data per-mahasiswa sudah tersimpan — menyimpan rata-rata akan membuang "
- 'rinciannya. Bila memang ingin beralih, hapus dulu lewat tombol "Mulai Ulang".'])
- agregat = {}
- for s in peta["subs"]:
- v = (form.get(f"rata_{s['kode']}") or "").strip()
- if v:
- try:
- x = float(v)
- except ValueError:
- continue
- if 0 <= x <= 100:
- agregat[s["kode"]] = x
- if not agregat:
- return _konteks_nilai(request, kode,
- peringatan=["Tidak ada rata-rata yang diisi."])
- data = {"mode": "agregat", "agregat": agregat}
- db.simpan_dok("nilai", kode, data, "selesai", updated_by)
- return _konteks_nilai(
- request, kode,
- pesan=f"Rata-rata {len(agregat)} Sub-CPMK tersimpan (mode BUKTI AGREGAT).",
- )
+ return _konteks_nilai(request, kode, peringatan=["Aksi tidak dikenali."])
# ═════════════════════════ TAHAP 2: PORTOFOLIO ═════════════════════════
@@ -866,6 +996,9 @@ def unduh_porto(request: Request, kode: str):
@app.get("/rekap", response_class=HTMLResponse)
def rekap(request: Request):
+ tolak = _bukan_kaprodi(request)
+ if tolak:
+ return tolak
hasil = hitung.rollup_prodi()
cpl_urut = sorted(hasil["cpl"].values(), key=lambda c: c["kode"])
cpmk_urut = sorted(hasil["cpmk"].values(), key=lambda c: c["kode"])
@@ -881,6 +1014,9 @@ def rekap(request: Request):
def pantau(request: Request):
"""Dasbor monitoring prodi: ketercapaian CPL/CPMK semester berjalan,
kelengkapan proses dokumen, dan daftar tindak lanjut untuk kaprodi."""
+ tolak = _bukan_kaprodi(request)
+ if tolak:
+ return tolak
hasil = hitung.rollup_prodi()
daftar = db.daftar_mk()
nama_mk = {m["kode"]: m["nama"] for m in daftar}
@@ -962,6 +1098,9 @@ async def simpan_metodologi(request: Request):
@app.get("/rekap/riwayat", response_class=HTMLResponse)
def rekap_riwayat(request: Request):
"""Tren ketercapaian CPL lintas tahun ajaran."""
+ tolak = _bukan_kaprodi(request)
+ if tolak:
+ return tolak
daftar_tahun = db.daftar_tahun()
per_tahun = {t: hitung.rollup_prodi(t) for t in daftar_tahun}
kode_cpl = sorted(db.kurikulum()["cpl"].keys())
diff --git a/app/nilai_xlsx.py b/app/nilai_xlsx.py
index 43b9025..a89c3c1 100644
--- a/app/nilai_xlsx.py
+++ b/app/nilai_xlsx.py
@@ -10,45 +10,6 @@ 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()
@@ -148,60 +109,3 @@ def parse_mahasiswa(isi: bytes, kode_mk_valid: set[str]) -> tuple[list[dict], li
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
diff --git a/app/templates/admin.html b/app/templates/admin.html
index a819390..3a9fca0 100644
--- a/app/templates/admin.html
+++ b/app/templates/admin.html
@@ -8,7 +8,10 @@
Dosen RPS + MK dapat mengedit RPS, nilai, dan portofolio
pada MK yang ditugaskan. Dosen MK hanya dapat mengedit nilai & portofolio
pada MK yang ditugaskan — halaman RPS terbuka hanya untuk dibaca. Kaprodi dapat
- mengakses semuanya.
+ mengakses semuanya. Kosongkan kolom kelas bila dosen mengampu seluruh kelas
+ MK itu; isi (mis. "A" atau "A,B") bila dosen hanya boleh melihat & menilai
+ mahasiswa kelas tertentu — cocok untuk MK dengan beberapa kelas paralel yang
+ diampu dosen berbeda.
@@ -30,7 +33,9 @@
{% if p.role == 'kaprodi' %}
semua MK
{% else %}
- {{ p.mk|join(', ') if p.mk else '—' }}
+ {% if p.mk %}
+ {% for kode in p.mk %}{{ kode }}{% if p.kelas_map.get(kode) %} (kelas {{ p.kelas_map[kode] }}){% endif %}{{ ', ' if not loop.last }}{% endfor %}
+ {% else %}—{% endif %}
Ubah penugasan…