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:
@@ -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:
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
+250
-111
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
<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 & portofolio
|
||||
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 & menilai
|
||||
mahasiswa kelas tertentu — cocok untuk MK dengan beberapa kelas paralel yang
|
||||
diampu dosen berbeda.</p>
|
||||
<div class="gulir">
|
||||
<table class="grid">
|
||||
<thead>
|
||||
@@ -30,7 +33,9 @@
|
||||
{% if p.role == 'kaprodi' %}
|
||||
<i style="color:var(--grey)">semua MK</i>
|
||||
{% 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">
|
||||
<summary style="cursor:pointer;color:var(--blue);font-size:13px">Ubah penugasan…</summary>
|
||||
<form method="post" action="/admin/pengguna/{{ p.id }}/tugas">
|
||||
@@ -38,6 +43,8 @@
|
||||
{% for mk in daftar %}
|
||||
<label><input type="checkbox" name="mk" value="{{ mk.kode }}"
|
||||
{% if mk.kode in p.mk %}checked{% endif %}> {{ mk.kode }} — {{ mk.nama }}</label>
|
||||
<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 %}
|
||||
</div>
|
||||
<button class="tombol garis" type="submit" style="padding:4px 12px;font-size:13px">Simpan Penugasan</button>
|
||||
|
||||
@@ -28,9 +28,11 @@
|
||||
<nav class="menu-baris">
|
||||
<span class="menu-kiri">
|
||||
{{ tautan('/', 'Beranda') }}
|
||||
{{ tautan('/pantau', 'Dasbor Monitoring') }}
|
||||
{{ tautan('/rekap', 'Rekap CPL/CPMK') }}
|
||||
{{ tautan('/rekap/riwayat', 'Riwayat') }}
|
||||
{% if user.role == 'kaprodi' %}
|
||||
{{ tautan('/pantau', 'Dasbor Monitoring') }}
|
||||
{{ tautan('/rekap', 'Rekap CPL/CPMK') }}
|
||||
{{ tautan('/rekap/riwayat', 'Riwayat') }}
|
||||
{% endif %}
|
||||
{{ tautan('/metodologi', 'Metodologi') }}
|
||||
{{ tautan('/dashboard', 'Pemetaan CPMK') }}
|
||||
{% if user.role == 'kaprodi' %}{{ tautan('/admin', 'Kelola Akun & Mahasiswa') }}{% endif %}
|
||||
|
||||
@@ -7,20 +7,66 @@
|
||||
|
||||
<h1>Input Nilai — {{ peta.nama }} <span style="color:var(--grey)">({{ peta.kode }})</span></h1>
|
||||
|
||||
<p class="keterangan">Apa pun cara inputnya, pengukuran memakai <b>satu metodologi
|
||||
yang sama</b>: skor per Sub-CPMK digulung ke CPMK lalu CPL
|
||||
(rincian di halaman <a href="/metodologi">Metodologi</a>). Pilih <b>satu</b> cara
|
||||
di bawah — menyimpan lewat cara mana pun menggantikan data nilai MK ini.</p>
|
||||
<p class="keterangan">Daftar mahasiswa mengikuti impor kaprodi. Apa pun cara
|
||||
inputnya, pengukuran memakai <b>satu metodologi yang sama</b>: skor per Sub-CPMK
|
||||
digulung ke CPMK lalu CPL (rincian di halaman <a href="/metodologi">Metodologi</a>).</p>
|
||||
|
||||
{% if kelas_dosen is not none %}
|
||||
<div class="info">Anda hanya mengampu kelas <b>{{ kelas_dosen|join(', ') }}</b> pada MK ini —
|
||||
daftar & tombol di bawah cuma menampilkan mahasiswa kelas tersebut.</div>
|
||||
{% endif %}
|
||||
|
||||
{% if pesan %}<div class="info">{{ pesan }}</div>{% endif %}
|
||||
{% 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 %}
|
||||
<div class="info">
|
||||
Data tersimpan: mode
|
||||
<b>{% if tersimpan.data.mode == 'per_mahasiswa' %}PER MAHASISWA ({{ tersimpan.data.mahasiswa|length }} mahasiswa){% else %}BUKTI AGREGAT (rata-rata kelas){% endif %}</b>
|
||||
— terakhir disimpan {{ tersimpan.updated_at }}{% if tersimpan.updated_by %} oleh {{ tersimpan.updated_by }}{% endif %}.
|
||||
{% if tersimpan.data.sumber %}<br>Riwayat unggahan:
|
||||
{% if tersimpan.data.sumber %}<br>Riwayat perubahan:
|
||||
{{ tersimpan.data.sumber if tersimpan.data.sumber is string else tersimpan.data.sumber|join(' · ') }}.{% endif %}
|
||||
<form method="post" action="/nilai/{{ peta.kode }}" style="display:inline;margin-left:8px"
|
||||
onsubmit="return confirm('Hapus SELURUH nilai tersimpan MK ini untuk {{ tahun }}? Tindakan ini tidak bisa dibatalkan.')">
|
||||
@@ -100,56 +146,6 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<details class="cara-alt"{% if not grid %} open{% endif %}>
|
||||
<summary>{{ 'ALTERNATIF — ' if grid }}UNGGAH EXCEL PER MAHASISWA <span style="font-weight:normal;font-style:italic;color:var(--grey)">(cocok bila nilai sudah direkap di Excel, atau team teaching mengisi terpisah)</span></summary>
|
||||
<p class="keterangan">
|
||||
Unduh template berkolom Sub-CPMK MK ini, isi nilai 0–100 per mahasiswa, lalu unggah kembali.
|
||||
{% if terdaftar %}Template sudah berisi <b>{{ terdaftar|length }} mahasiswa</b> yang
|
||||
mengontrak MK ini (dari daftar impor kaprodi).
|
||||
{% else %}<b>Daftar mahasiswa MK ini belum diimpor kaprodi</b> — template kosong,
|
||||
isi NPM & nama secara manual.{% endif %}
|
||||
<br><b>Team teaching:</b> tiap dosen cukup mengunggah bagiannya sendiri — kolom
|
||||
Sub-CPMK yang diampunya (mis. sebelum/sesudah UTS) atau baris mahasiswa kelasnya.
|
||||
Unggahan <b>digabung</b>: sel terisi memperbarui nilai, sel kosong tidak menghapus
|
||||
nilai yang sudah diunggah dosen lain.
|
||||
</p>
|
||||
<div class="aksi-baris" style="border-top:none;margin-top:0;padding-top:0">
|
||||
<a class="tombol garis" href="/nilai/{{ peta.kode }}/template.xlsx">Unduh Template Excel</a>
|
||||
</div>
|
||||
<form method="post" action="/nilai/{{ peta.kode }}" enctype="multipart/form-data">
|
||||
<div class="aksi-baris" style="border-top:none">
|
||||
<input type="file" name="berkas" accept=".xlsx" style="width:auto">
|
||||
<button class="tombol" type="submit" name="aksi" value="unggah">Unggah & Hitung</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<details class="cara-alt"{% if tersimpan and tersimpan.data.mode == 'agregat' %} open{% endif %}>
|
||||
<summary>ALTERNATIF — KETIK RATA-RATA KELAS PER SUB-CPMK <span style="font-weight:normal;font-style:italic;color:var(--grey)">(hanya bila nilai per mahasiswa tidak tersedia; ditandai "bukti agregat" pada rekap)</span></summary>
|
||||
<form method="post" action="/nilai/{{ peta.kode }}">
|
||||
<div class="gulir" style="max-height:420px;overflow-y:auto">
|
||||
<table class="grid">
|
||||
<thead><tr><th style="width:120px">Sub-CPMK</th><th>Uraian</th><th class="tengah" style="width:130px">Rata-rata (0–100)</th></tr></thead>
|
||||
<tbody>
|
||||
{% for s in peta.subs %}
|
||||
<tr>
|
||||
<td class="tengah">{{ s.kode }}</td>
|
||||
<td>{{ s.uraian[:110] }}{% if s.uraian|length > 110 %}…{% endif %}</td>
|
||||
<td class="isian"><input type="number" name="rata_{{ s.kode }}" min="0" max="100" step="0.01"
|
||||
value="{% if tersimpan and tersimpan.data.mode == 'agregat' %}{{ tersimpan.data.agregat.get(s.kode, '') }}{% endif %}"></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="aksi-baris">
|
||||
<span style="color:var(--grey);font-size:13px">Disimpan atas nama <b>{{ user.nama }}</b></span>
|
||||
<span class="spacer"></span>
|
||||
<button class="tombol garis" type="submit" name="aksi" value="agregat">Simpan Rata-rata</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{% if hasil %}
|
||||
<h2 class="bagian">HASIL KETERCAPAIAN MK INI <span style="font-weight:normal;font-style:italic;color:var(--grey)">(ambang {{ ambang|round(0)|int }}; roll-up bagi rata)</span></h2>
|
||||
<div class="gulir">
|
||||
|
||||
@@ -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 (0–100)</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 %}
|
||||
Reference in New Issue
Block a user