Tambah asesmen per komponen, batasi Dasbor/Rekap ke kaprodi, penugasan per kelas

- 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.
This commit is contained in:
Power BI Dev
2026-07-21 12:44:57 +07:00
parent a45b08b00e
commit afb9e72ba2
8 changed files with 490 additions and 274 deletions
+38 -7
View File
@@ -138,6 +138,15 @@ def _migrasi_role_pengguna(conn: sqlite3.Connection) -> None:
conn.execute("DROP TABLE pengguna_lama") 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: def init_db() -> None:
with get_conn() as conn: with get_conn() as conn:
for tabel in ("rps", "portofolio", "nilai"): for tabel in ("rps", "portofolio", "nilai"):
@@ -195,6 +204,7 @@ def init_db() -> None:
PRIMARY KEY (user_id, mk_kode) PRIMARY KEY (user_id, mk_kode)
)""" )"""
) )
_migrasi_kelas_penugasan(conn)
conn.execute( conn.execute(
"""CREATE TABLE IF NOT EXISTS mahasiswa ( """CREATE TABLE IF NOT EXISTS mahasiswa (
npm TEXT NOT NULL, npm TEXT NOT NULL,
@@ -391,12 +401,15 @@ def daftar_pengguna() -> list[dict]:
"ORDER BY role, nama" "ORDER BY role, nama"
).fetchall() ).fetchall()
tugas = conn.execute( 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() ).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: for t in tugas:
per_user.setdefault(t["user_id"], []).append(t["mk_kode"]) per_user_kode.setdefault(t["user_id"], []).append(t["mk_kode"])
return [{**dict(r), "mk": per_user.get(r["id"], [])} for r in rows] 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: 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,)) 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: with get_conn() as conn:
conn.execute("DELETE FROM penugasan WHERE user_id=?", (user_id,)) conn.execute("DELETE FROM penugasan WHERE user_id=?", (user_id,))
conn.executemany( conn.executemany(
"INSERT OR IGNORE INTO penugasan (user_id, mk_kode) VALUES (?,?)", "INSERT OR IGNORE INTO penugasan (user_id, mk_kode, kelas) VALUES (?,?,?)",
[(user_id, k) for k in kode_list], [(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} 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]]: def pengampu_per_mk() -> dict[str, list[str]]:
"""{mk_kode: [nama dosen, ...]} — untuk kolom pengampu di beranda.""" """{mk_kode: [nama dosen, ...]} — untuk kolom pengampu di beranda."""
with get_conn() as conn: with get_conn() as conn:
+50
View File
@@ -91,6 +91,56 @@ def bobot_sub_dari_rps(rps_data: dict | None) -> dict[str, float]:
return bobot 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]: 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).""" """Nilai akhir tertimbang bobot Sub-CPMK per mahasiswa (mode per_mahasiswa)."""
out = [] out = []
+250 -111
View File
@@ -3,6 +3,7 @@
import json import json
import pathlib import pathlib
import re import re
from datetime import datetime
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response 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"]) 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: def _masuk(user_id: int) -> RedirectResponse:
token = auth.token_baru() token = auth.token_baru()
db.buat_sesi(user_id, token) db.buat_sesi(user_id, token)
@@ -208,7 +224,8 @@ async def admin_penugasan(request: Request, uid: int):
return tolak return tolak
form = await request.form() form = await request.form()
kode_valid = {m["kode"] for m in db.daftar_mk()} 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) db.set_penugasan(uid, pilihan)
p = db.pengguna_by_id(uid) p = db.pengguna_by_id(uid)
return RedirectResponse( return RedirectResponse(
@@ -261,12 +278,12 @@ async def admin_impor_mahasiswa(request: Request):
# komponen penilaian tetap — teknik asesmen baku prodi; bobot boleh 0 (opsional, # komponen penilaian tetap — teknik asesmen baku prodi; bobot boleh 0 (opsional,
# tak dipakai MK ybs) tapi total seluruh komponen tak boleh melebihi 100%. # tak dipakai MK ybs) tapi total seluruh komponen tak boleh melebihi 100%.
KOMPONEN_DEFAULT = [ KOMPONEN_DEFAULT = [
{"nama": "Partisipasi (Kuis)", "subs": [], "bobot": 10}, {"nama": "Partisipasi (Kuis)", "subs": [], "bobot": 10, "ulang": True},
{"nama": "Observasi (Praktik/Tugas)", "subs": [], "bobot": 20}, {"nama": "Observasi (Praktik/Tugas)", "subs": [], "bobot": 20, "ulang": True},
{"nama": "Unjuk Kerja (Presentasi)", "subs": [], "bobot": 10}, {"nama": "Unjuk Kerja (Presentasi)", "subs": [], "bobot": 10, "ulang": True},
{"nama": "Tes Tulis (UTS)", "subs": [], "bobot": 25}, {"nama": "Tes Tulis (UTS)", "subs": [], "bobot": 25, "ulang": False},
{"nama": "Tes Tulis (UAS)", "subs": [], "bobot": 25}, {"nama": "Tes Tulis (UAS)", "subs": [], "bobot": 25, "ulang": False},
{"nama": "Tes Lisan (Tugas Kelompok)", "subs": [], "bobot": 10}, {"nama": "Tes Lisan (Tugas Kelompok)", "subs": [], "bobot": 10, "ulang": True},
] ]
@@ -508,21 +525,13 @@ def unduh_rps(kode: str):
# ═════════════════════════ TAHAP 2: NILAI & PENGUKURAN ═════════════════════════ # ═════════════════════════ TAHAP 2: NILAI & PENGUKURAN ═════════════════════════
def _konteks_nilai(request, kode, pesan="", peringatan=None): def _grid_mahasiswa(tersimpan: dict | None, terdaftar: list[dict],
peta = db.peta_mk(kode) kelas_dosen: set[str] | None = None) -> list[dict]:
tersimpan = db.ambil_dok("nilai", kode) """Gabungan daftar impor kaprodi + nilai tersimpan (mahasiswa yang sudah
pengaturan = db.ambil_pengaturan() punya nilai tapi tak lagi terdaftar tetap ditampilkan, tak hilang).
ambang = float(pengaturan["ambang"]) kelas_dosen membatasi hasil ke kelas yang diampu dosen ini (None = semua
hasil, akhir = None, {} kelas); mahasiswa dari kelas lain, termasuk entri tak-terdaftar yang
if tersimpan: kelasnya tak diketahui, disaring keluar demi privasi nilai kelas lain."""
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)
skor_map, nama_map = {}, {} skor_map, nama_map = {}, {}
if tersimpan and tersimpan["data"].get("mode") == "per_mahasiswa": if tersimpan and tersimpan["data"].get("mode") == "per_mahasiswa":
for m in tersimpan["data"]["mahasiswa"]: for m in tersimpan["data"]["mahasiswa"]:
@@ -537,39 +546,71 @@ def _konteks_nilai(request, kode, pesan="", peringatan=None):
if npm not in ada: if npm not in ada:
grid.append({"npm": npm, "nama": nama_map.get(npm, ""), "kelas": "", grid.append({"npm": npm, "nama": nama_map.get(npm, ""), "kelas": "",
"skor": sk}) "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( return render(
request, "form_nilai.html", request, "form_nilai.html",
{"peta": peta, "tersimpan": tersimpan, "hasil": hasil, "ambang": ambang, {"peta": peta, "tersimpan": tersimpan, "hasil": hasil, "ambang": ambang,
"akhir": akhir, "tahun": db.tahun_aktif(), "pesan": pesan, "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 []}, "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) @app.get("/nilai/{kode}", response_class=HTMLResponse)
def form_nilai(request: Request, kode: str, pesan: str = ""): def form_nilai(request: Request, kode: str, pesan: str = ""):
if not db.peta_mk(kode): 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) return _konteks_nilai(request, kode, pesan)
@app.get("/nilai/{kode}/template.xlsx") @app.get("/nilai/{kode}/komponen/{i}", response_class=HTMLResponse)
def template_nilai(request: Request, kode: str): def form_nilai_komponen(request: Request, kode: str, i: int, sesi_id: int = -1, pesan: str = ""):
peta = db.peta_mk(kode) peta = db.peta_mk(kode)
if not peta: if not peta:
return RedirectResponse("/", status_code=303) return RedirectResponse("/", status_code=303)
if not boleh_mk(request.state.user, kode): if not boleh_mk(request.state.user, kode):
return RedirectResponse(f"/rps/{kode}", status_code=303) return RedirectResponse(
isi = nilai_xlsx.buat_template(peta, db.tahun_aktif(), db.mahasiswa_mk(kode)) f"/rps/{kode}?pesan=Nilai+mahasiswa+hanya+dibuka+untuk+pengampu+MK+dan+kaprodi.",
return Response( status_code=303)
content=isi, komponen = _ambil_komponen(kode, i)
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", if not komponen:
headers={"Content-Disposition": return RedirectResponse(
f'attachment; filename="Template_Nilai_{kode}.xlsx"'}, 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}") @app.post("/nilai/{kode}")
async def simpan_nilai(request: Request, kode: str): async def simpan_nilai(request: Request, kode: str):
peta = db.peta_mk(kode) peta = db.peta_mk(kode)
@@ -609,21 +759,28 @@ async def simpan_nilai(request: Request, kode: str):
form = await request.form() form = await request.form()
aksi = form.get("aksi") aksi = form.get("aksi")
updated_by = request.state.user["nama"] updated_by = request.state.user["nama"]
kode_sub = {s["kode"] for s in peta["subs"]}
if aksi == "hapus": 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) db.hapus_dok("nilai", kode)
return _konteks_nilai( return _konteks_nilai(
request, kode, request, kode,
pesan="Seluruh nilai tersimpan MK ini dihapus — silakan mulai dari awal.") pesan="Seluruh nilai tersimpan MK ini dihapus — silakan mulai dari awal.")
if aksi == "grid": 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 = [] mahasiswa = []
i = 0 i = 0
while f"npm_{i}" in form: while f"npm_{i}" in form:
npm = (form.get(f"npm_{i}") or "").strip() npm = (form.get(f"npm_{i}") or "").strip()
nama = (form.get(f"nama_{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 = {} skor = {}
for s in peta["subs"]: for s in peta["subs"]:
v = (form.get(f"skor_{i}_{s['kode']}") or "").strip() 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, return _konteks_nilai(request, kode,
peringatan=["Tidak ada nilai yang diisi."]) peringatan=["Tidak ada nilai yang diisi."])
lama = db.ambil_dok("nilai", kode) 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): if isinstance(riwayat, str):
riwayat = [riwayat] riwayat = [riwayat]
riwayat.append(f"input langsung ({updated_by})") riwayat.append(f"input langsung ({updated_by})")
data = {"mode": "per_mahasiswa", "mahasiswa": mahasiswa, if kelas_dosen is None:
"sumber": riwayat[-8:]} # 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) db.simpan_dok("nilai", kode, data, "selesai", updated_by)
return _konteks_nilai( return _konteks_nilai(
request, kode, request, kode,
pesan=f"Nilai {len(mahasiswa)} mahasiswa tersimpan (input langsung).") pesan=f"Nilai {len(mahasiswa)} mahasiswa tersimpan (input langsung).")
if aksi == "unggah": return _konteks_nilai(request, kode, peringatan=["Aksi tidak dikenali."])
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).",
)
# ═════════════════════════ TAHAP 2: PORTOFOLIO ═════════════════════════ # ═════════════════════════ TAHAP 2: PORTOFOLIO ═════════════════════════
@@ -866,6 +996,9 @@ def unduh_porto(request: Request, kode: str):
@app.get("/rekap", response_class=HTMLResponse) @app.get("/rekap", response_class=HTMLResponse)
def rekap(request: Request): def rekap(request: Request):
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
hasil = hitung.rollup_prodi() hasil = hitung.rollup_prodi()
cpl_urut = sorted(hasil["cpl"].values(), key=lambda c: c["kode"]) cpl_urut = sorted(hasil["cpl"].values(), key=lambda c: c["kode"])
cpmk_urut = sorted(hasil["cpmk"].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): def pantau(request: Request):
"""Dasbor monitoring prodi: ketercapaian CPL/CPMK semester berjalan, """Dasbor monitoring prodi: ketercapaian CPL/CPMK semester berjalan,
kelengkapan proses dokumen, dan daftar tindak lanjut untuk kaprodi.""" kelengkapan proses dokumen, dan daftar tindak lanjut untuk kaprodi."""
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
hasil = hitung.rollup_prodi() hasil = hitung.rollup_prodi()
daftar = db.daftar_mk() daftar = db.daftar_mk()
nama_mk = {m["kode"]: m["nama"] for m in daftar} 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) @app.get("/rekap/riwayat", response_class=HTMLResponse)
def rekap_riwayat(request: Request): def rekap_riwayat(request: Request):
"""Tren ketercapaian CPL lintas tahun ajaran.""" """Tren ketercapaian CPL lintas tahun ajaran."""
tolak = _bukan_kaprodi(request)
if tolak:
return tolak
daftar_tahun = db.daftar_tahun() daftar_tahun = db.daftar_tahun()
per_tahun = {t: hitung.rollup_prodi(t) for t in daftar_tahun} per_tahun = {t: hitung.rollup_prodi(t) for t in daftar_tahun}
kode_cpl = sorted(db.kurikulum()["cpl"].keys()) kode_cpl = sorted(db.kurikulum()["cpl"].keys())
-96
View File
@@ -10,45 +10,6 @@ BIRU = "1F4E79"
BIRU_MUDA = "D6E4F0" BIRU_MUDA = "D6E4F0"
def buat_template(peta: dict, tahun: str, mahasiswa: list[dict] | None = None) -> bytes:
"""Template xlsx: kolom NPM, Nama, Kelas, lalu satu kolom per Sub-CPMK.
Bila daftar mahasiswa (impor kaprodi) tersedia, baris NPM/Nama/Kelas
sudah terisi — dosen tinggal mengisi skornya.
"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Nilai"
subs = peta["subs"]
ws.append([f"NILAI PER SUB-CPMK — {peta['nama']} ({peta['kode']}) — {tahun}"])
ws.append(["Petunjuk: isi skor 0100 per mahasiswa per Sub-CPMK. "
"Jangan mengubah baris kode Sub-CPMK."])
ws.append([])
header = ["NPM", "Nama Mahasiswa", "Kelas"] + [s["kode"] for s in subs]
ws.append(header)
uraian = ["", "", ""] + [s["uraian"][:80] for s in subs]
ws.append(uraian)
for m in mahasiswa or []:
ws.append([m["npm"], m["nama"], m.get("kelas", "")])
for ci, _ in enumerate(header, 1):
c = ws.cell(row=4, column=ci)
c.font = Font(bold=True, color="FFFFFF")
c.fill = PatternFill("solid", fgColor=BIRU)
c.alignment = Alignment(horizontal="center")
u = ws.cell(row=5, column=ci)
u.font = Font(italic=True, size=8)
u.alignment = Alignment(wrap_text=True, vertical="top")
ws.column_dimensions[get_column_letter(ci)].width = (
14 if ci == 1 else 28 if ci == 2 else 9 if ci == 3 else 14)
ws.freeze_panes = "D6"
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def template_mahasiswa(daftar_mk: list[dict], tahun: str) -> bytes: def template_mahasiswa(daftar_mk: list[dict], tahun: str) -> bytes:
"""Template impor daftar mahasiswa: NPM | Nama | Kode MK | Kelas.""" """Template impor daftar mahasiswa: NPM | Nama | Kode MK | Kelas."""
wb = openpyxl.Workbook() wb = openpyxl.Workbook()
@@ -148,60 +109,3 @@ def parse_mahasiswa(isi: bytes, kode_mk_valid: set[str]) -> tuple[list[dict], li
if not baris: if not baris:
peringatan.append("Tidak ada baris kontrak yang terbaca.") peringatan.append("Tidak ada baris kontrak yang terbaca.")
return baris, peringatan return baris, peringatan
def parse_unggahan(isi: bytes, kode_sub_valid: set[str]) -> tuple[list[dict], list[str]]:
"""Baca xlsx nilai → (mahasiswa, peringatan).
Baris header dicari otomatis: baris yang memuat 'NPM'. Kolom Sub-CPMK
dicocokkan dengan kode yang valid untuk MK tsb.
"""
wb = openpyxl.load_workbook(io.BytesIO(isi), read_only=True, data_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
header_i, kolom_sub = None, {}
for i, row in enumerate(rows):
vals = [str(v).strip() if v is not None else "" for v in row]
if "NPM" in vals:
header_i = i
for ci, v in enumerate(vals):
if v in kode_sub_valid:
kolom_sub[ci] = v
npm_i = vals.index("NPM")
nama_i = npm_i + 1
break
if header_i is None or not kolom_sub:
return [], ["Header tidak dikenali: pastikan memakai template dari aplikasi "
"(ada kolom NPM dan kode Sub-CPMK)."]
mahasiswa, peringatan = [], []
for row in rows[header_i + 1:]:
vals = list(row)
npm = str(vals[npm_i]).strip() if npm_i < len(vals) and vals[npm_i] is not None else ""
nama = str(vals[nama_i]).strip() if nama_i < len(vals) and vals[nama_i] is not None else ""
skor = {}
for ci, sub in kolom_sub.items():
v = vals[ci] if ci < len(vals) else None
if v is None or v == "":
continue
try:
x = float(v)
except (TypeError, ValueError):
continue
if 0 <= x <= 100:
skor[sub] = x
else:
peringatan.append(f"{npm or nama}: nilai {sub} = {x} di luar 0100, dilewati.")
if skor and (npm or nama):
mahasiswa.append({"npm": npm, "nama": nama, "skor": skor})
if not mahasiswa:
peringatan.append("Tidak ada baris nilai yang terbaca.")
hilang = kode_sub_valid - {s for m in mahasiswa for s in m["skor"]}
if mahasiswa and hilang:
peringatan.append(
f"{len(hilang)} Sub-CPMK tanpa nilai sama sekali: "
+ ", ".join(sorted(hilang)[:8]) + ("" if len(hilang) > 8 else "")
)
return mahasiswa, peringatan
+9 -2
View File
@@ -8,7 +8,10 @@
<p class="keterangan"><b>Dosen RPS + MK</b> dapat mengedit RPS, nilai, dan portofolio <p class="keterangan"><b>Dosen RPS + MK</b> dapat mengedit RPS, nilai, dan portofolio
pada MK yang ditugaskan. <b>Dosen MK</b> hanya dapat mengedit nilai &amp; portofolio pada MK yang ditugaskan. <b>Dosen MK</b> hanya dapat mengedit nilai &amp; portofolio
pada MK yang ditugaskan — halaman RPS terbuka hanya untuk dibaca. Kaprodi dapat pada MK yang ditugaskan — halaman RPS terbuka hanya untuk dibaca. Kaprodi dapat
mengakses semuanya.</p> mengakses semuanya. Kosongkan kolom kelas bila dosen mengampu <b>seluruh kelas</b>
MK itu; isi (mis. "A" atau "A,B") bila dosen hanya boleh melihat &amp; menilai
mahasiswa kelas tertentu — cocok untuk MK dengan beberapa kelas paralel yang
diampu dosen berbeda.</p>
<div class="gulir"> <div class="gulir">
<table class="grid"> <table class="grid">
<thead> <thead>
@@ -30,7 +33,9 @@
{% if p.role == 'kaprodi' %} {% if p.role == 'kaprodi' %}
<i style="color:var(--grey)">semua MK</i> <i style="color:var(--grey)">semua MK</i>
{% else %} {% 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 %}
<details style="margin-top:6px"> <details style="margin-top:6px">
<summary style="cursor:pointer;color:var(--blue);font-size:13px">Ubah penugasan…</summary> <summary style="cursor:pointer;color:var(--blue);font-size:13px">Ubah penugasan…</summary>
<form method="post" action="/admin/pengguna/{{ p.id }}/tugas"> <form method="post" action="/admin/pengguna/{{ p.id }}/tugas">
@@ -38,6 +43,8 @@
{% for mk in daftar %} {% for mk in daftar %}
<label><input type="checkbox" name="mk" value="{{ mk.kode }}" <label><input type="checkbox" name="mk" value="{{ mk.kode }}"
{% if mk.kode in p.mk %}checked{% endif %}> {{ mk.kode }} — {{ mk.nama }}</label> {% if mk.kode in p.mk %}checked{% endif %}> {{ mk.kode }} — {{ mk.nama }}</label>
<input type="text" name="kelas_{{ mk.kode }}" placeholder="kelas (kosong = semua)"
value="{{ p.kelas_map.get(mk.kode, '') }}" style="width:150px;margin:0 0 4px 22px;font-size:12px">
{% endfor %} {% endfor %}
</div> </div>
<button class="tombol garis" type="submit" style="padding:4px 12px;font-size:13px">Simpan Penugasan</button> <button class="tombol garis" type="submit" style="padding:4px 12px;font-size:13px">Simpan Penugasan</button>
+5 -3
View File
@@ -28,9 +28,11 @@
<nav class="menu-baris"> <nav class="menu-baris">
<span class="menu-kiri"> <span class="menu-kiri">
{{ tautan('/', 'Beranda') }} {{ tautan('/', 'Beranda') }}
{{ tautan('/pantau', 'Dasbor Monitoring') }} {% if user.role == 'kaprodi' %}
{{ tautan('/rekap', 'Rekap CPL/CPMK') }} {{ tautan('/pantau', 'Dasbor Monitoring') }}
{{ tautan('/rekap/riwayat', 'Riwayat') }} {{ tautan('/rekap', 'Rekap CPL/CPMK') }}
{{ tautan('/rekap/riwayat', 'Riwayat') }}
{% endif %}
{{ tautan('/metodologi', 'Metodologi') }} {{ tautan('/metodologi', 'Metodologi') }}
{{ tautan('/dashboard', 'Pemetaan CPMK') }} {{ tautan('/dashboard', 'Pemetaan CPMK') }}
{% if user.role == 'kaprodi' %}{{ tautan('/admin', 'Kelola Akun & Mahasiswa') }}{% endif %} {% if user.role == 'kaprodi' %}{{ tautan('/admin', 'Kelola Akun & Mahasiswa') }}{% endif %}
+51 -55
View File
@@ -7,20 +7,66 @@
<h1>Input Nilai — {{ peta.nama }} <span style="color:var(--grey)">({{ peta.kode }})</span></h1> <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 <p class="keterangan">Daftar mahasiswa mengikuti impor kaprodi. Apa pun cara
yang sama</b>: skor per Sub-CPMK digulung ke CPMK lalu CPL inputnya, pengukuran memakai <b>satu metodologi yang sama</b>: skor per Sub-CPMK
(rincian di halaman <a href="/metodologi">Metodologi</a>). Pilih <b>satu</b> cara digulung ke CPMK lalu CPL (rincian di halaman <a href="/metodologi">Metodologi</a>).</p>
di bawah — menyimpan lewat cara mana pun menggantikan data nilai MK ini.</p>
{% if kelas_dosen is not none %}
<div class="info">Anda hanya mengampu kelas <b>{{ kelas_dosen|join(', ') }}</b> pada MK ini —
daftar &amp; tombol di bawah cuma menampilkan mahasiswa kelas tersebut.</div>
{% endif %}
{% if pesan %}<div class="info">{{ pesan }}</div>{% endif %} {% if pesan %}<div class="info">{{ pesan }}</div>{% endif %}
{% for w in peringatan %}<div class="peringatan">{{ w }}</div>{% endfor %} {% for w in peringatan %}<div class="peringatan">{{ w }}</div>{% endfor %}
{% if komponen_daftar %}
<h2 class="bagian">ISI PER KOMPONEN PENILAIAN <span style="font-weight:normal;font-style:italic;color:var(--grey)">(cara cepat — nilai per mahasiswa digabung ke Sub-CPMK yang diukur komponen itu)</span></h2>
<p class="keterangan">
Komponen <b>berulang</b> (Kuis, Tugas, Presentasi, Tes Lisan) boleh diisi berkali-kali
sepanjang semester — tiap sesi dirata-rata otomatis. Komponen <b>sekali-jalan</b>
(UTS, UAS) hanya satu sesi; mengisi lagi memperbarui sesi yang sama, bukan menambah.
Bila satu instrumen (mis. UTS dengan beberapa kelompok soal) perlu nilai berbeda
antar Sub-CPMK, isi lewat grid per Sub-CPMK di bawah.</p>
<table class="grid" style="max-width:760px">
<thead>
<tr>
<th>Komponen</th>
<th class="tengah" style="width:90px">Sub-CPMK</th>
<th class="tengah" style="width:70px">Bobot</th>
<th class="tengah" style="width:150px">Status</th>
<th class="tengah" style="width:110px"></th>
</tr>
</thead>
<tbody>
{% for k in komponen_daftar %}
<tr>
<td>{{ k.nama }}<br><span style="font-size:12px;color:var(--grey)">{{ 'boleh diisi berkali-kali' if k.ulang else 'sekali-jalan' }}</span></td>
<td class="tengah">{{ k.n_subs }}</td>
<td class="tengah">{% if k.bobot %}{{ k.bobot }}%{% else %}—{% endif %}</td>
<td class="tengah">
{% if k.ulang %}
{% if k.n_sesi %}<span class="stamp stamp-ok">{{ k.n_sesi }} SESI</span>{% else %}<span class="stamp stamp-draft">BELUM ADA SESI</span>{% endif %}
{% else %}
{% if k.n_sesi %}<span class="stamp stamp-ok">SUDAH DIISI</span>{% else %}<span class="stamp stamp-draft">BELUM DIISI</span>{% endif %}
{% endif %}
</td>
<td class="tengah">
<a class="tombol garis" style="padding:4px 12px;font-size:13px" href="/nilai/{{ peta.kode }}/komponen/{{ k.i }}">
{{ 'Sesi Baru' if k.ulang else ('Edit' if k.n_sesi else 'Isi') }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if tersimpan %} {% if tersimpan %}
<div class="info"> <div class="info">
Data tersimpan: mode 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> <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 %}. — terakhir disimpan {{ tersimpan.updated_at }}{% if tersimpan.updated_by %} oleh {{ tersimpan.updated_by }}{% endif %}.
{% if tersimpan.data.sumber %}<br>Riwayat unggahan: {% if tersimpan.data.sumber %}<br>Riwayat perubahan:
{{ tersimpan.data.sumber if tersimpan.data.sumber is string else tersimpan.data.sumber|join(' · ') }}.{% endif %} {{ 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" <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.')"> onsubmit="return confirm('Hapus SELURUH nilai tersimpan MK ini untuk {{ tahun }}? Tindakan ini tidak bisa dibatalkan.')">
@@ -100,56 +146,6 @@
</form> </form>
{% endif %} {% endif %}
<details class="cara-alt"{% if not grid %} open{% endif %}>
<summary>{{ 'ALTERNATIF — ' if grid }}UNGGAH EXCEL PER MAHASISWA <span style="font-weight:normal;font-style:italic;color:var(--grey)">(cocok bila nilai sudah direkap di Excel, atau team teaching mengisi terpisah)</span></summary>
<p class="keterangan">
Unduh template berkolom Sub-CPMK MK ini, isi nilai 0100 per mahasiswa, lalu unggah kembali.
{% if terdaftar %}Template sudah berisi <b>{{ terdaftar|length }} mahasiswa</b> yang
mengontrak MK ini (dari daftar impor kaprodi).
{% else %}<b>Daftar mahasiswa MK ini belum diimpor kaprodi</b> — template kosong,
isi NPM &amp; nama secara manual.{% endif %}
<br><b>Team teaching:</b> tiap dosen cukup mengunggah bagiannya sendiri — kolom
Sub-CPMK yang diampunya (mis. sebelum/sesudah UTS) atau baris mahasiswa kelasnya.
Unggahan <b>digabung</b>: sel terisi memperbarui nilai, sel kosong tidak menghapus
nilai yang sudah diunggah dosen lain.
</p>
<div class="aksi-baris" style="border-top:none;margin-top:0;padding-top:0">
<a class="tombol garis" href="/nilai/{{ peta.kode }}/template.xlsx">Unduh Template Excel</a>
</div>
<form method="post" action="/nilai/{{ peta.kode }}" enctype="multipart/form-data">
<div class="aksi-baris" style="border-top:none">
<input type="file" name="berkas" accept=".xlsx" style="width:auto">
<button class="tombol" type="submit" name="aksi" value="unggah">Unggah &amp; Hitung</button>
</div>
</form>
</details>
<details class="cara-alt"{% if tersimpan and tersimpan.data.mode == 'agregat' %} open{% endif %}>
<summary>ALTERNATIF — KETIK RATA-RATA KELAS PER SUB-CPMK <span style="font-weight:normal;font-style:italic;color:var(--grey)">(hanya bila nilai per mahasiswa tidak tersedia; ditandai "bukti agregat" pada rekap)</span></summary>
<form method="post" action="/nilai/{{ peta.kode }}">
<div class="gulir" style="max-height:420px;overflow-y:auto">
<table class="grid">
<thead><tr><th style="width:120px">Sub-CPMK</th><th>Uraian</th><th class="tengah" style="width:130px">Rata-rata (0100)</th></tr></thead>
<tbody>
{% for s in peta.subs %}
<tr>
<td class="tengah">{{ s.kode }}</td>
<td>{{ s.uraian[:110] }}{% if s.uraian|length > 110 %}…{% endif %}</td>
<td class="isian"><input type="number" name="rata_{{ s.kode }}" min="0" max="100" step="0.01"
value="{% if tersimpan and tersimpan.data.mode == 'agregat' %}{{ tersimpan.data.agregat.get(s.kode, '') }}{% endif %}"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="aksi-baris">
<span style="color:var(--grey);font-size:13px">Disimpan atas nama <b>{{ user.nama }}</b></span>
<span class="spacer"></span>
<button class="tombol garis" type="submit" name="aksi" value="agregat">Simpan Rata-rata</button>
</div>
</form>
</details>
{% if hasil %} {% 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> <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"> <div class="gulir">
+87
View File
@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block judul %}Nilai {{ komponen.nama }} — {{ 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>{{ komponen.nama }}</b> · <a href="/portofolio/{{ peta.kode }}">Portofolio</a></div>
</div>
<h1>Isi Nilai — {{ komponen.nama }} <span style="color:var(--grey)">({{ peta.nama }})</span></h1>
<p class="keterangan">
Nilai di sini digabung ke Sub-CPMK yang diukur komponen ini:
{% for s in komponen.subs %}<b>{{ s }}</b>{{ ', ' if not loop.last }}{% endfor %}
{% if komponen.bobot %}(bobot komponen {{ komponen.bobot }}%){% endif %}.
{% if komponen.ulang %}
Komponen ini <b>boleh diisi berkali-kali</b> (mis. beberapa kali kuis sepanjang
semester) — nilai tiap sesi dirata-rata otomatis per Sub-CPMK. Bila satu Sub-CPMK
juga diukur komponen lain, hasil akhirnya dirata tertimbang bobot antar-komponen.
{% else %}
Komponen ini <b>hanya satu sesi per semester</b> — menyimpan lagi memperbarui
(menimpa) sesi yang sudah ada, bukan menambah sesi baru.
{% endif %}
Jika mahasiswa perlu nilai berbeda antar Sub-CPMK dalam komponen yang sama
(mis. UTS dengan beberapa kelompok soal), isi lewat
<a href="/nilai/{{ peta.kode }}">grid per Sub-CPMK</a> di halaman utama nilai, bukan di sini.
</p>
{% if pesan %}<div class="info">{{ pesan }}</div>{% endif %}
{% if komponen.ulang and daftar_sesi %}
<h2 class="bagian">SESI TERSIMPAN</h2>
<table class="grid" style="max-width:560px">
<thead><tr><th>Sesi</th><th class="tengah" style="width:120px">Mahasiswa Terisi</th><th style="width:60px"></th></tr></thead>
<tbody>
{% for s in daftar_sesi %}
<tr{% if loop.index0 == edit_id %} style="background:var(--blue-fill)"{% endif %}>
<td>{{ s.label }}</td>
<td class="tengah">{{ (s.nilai or {})|length }}</td>
<td><a href="/nilai/{{ peta.kode }}/komponen/{{ i }}?sesi_id={{ loop.index0 }}">Edit</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h2 class="bagian">{{ 'EDIT SESI' if edit_id < daftar_sesi|length else 'SESI BARU' }} {{ label_awal }}</h2>
<form method="post" action="/nilai/{{ peta.kode }}/komponen/{{ i }}">
<input type="hidden" name="edit_id" value="{{ edit_id }}">
{% if komponen.ulang %}
<label style="display:block;margin-bottom:10px">Nama sesi
<input type="text" name="label" value="{{ label_awal }}" style="display:block;margin-top:4px;max-width:280px">
</label>
{% else %}
<input type="hidden" name="label" value="{{ label_awal }}">
{% endif %}
<div class="gulir" style="max-height:480px;overflow-y:auto">
<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>
<th class="tengah" style="width:140px">Nilai (0100)</th>
</tr>
</thead>
<tbody>
{% for m in grid %}
<tr>
<td class="tengah">{{ loop.index }}
<input type="hidden" name="npm_{{ loop.index0 }}" value="{{ m.npm }}">
<input type="hidden" name="nama_{{ loop.index0 }}" value="{{ m.nama }}"></td>
<td>{{ m.npm }}</td>
<td>{{ m.nama }}</td>
<td class="isian"><input type="number" name="nilai_{{ loop.index0 }}"
min="0" max="100" step="0.01"
value="{{ '%g'|format(m.nilai_awal) if m.nilai_awal is not none else '' }}"></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" type="submit">Simpan Sesi Ini</button>
</div>
</form>
{% endblock %}