9be6bd03e3
Dashboard pemetaan statis kini dilayani aplikasi di /dashboard (app/static/dashboard.html). Fitur lengkap Tahap 1-4 + dasbor monitoring, menu global, filter semester, input nilai langsung, penggabungan nilai team teaching, tabel bisa urut. Dockerfile berubah dari nginx statis ke uvicorn/FastAPI: healthcheck, proxy-headers, volume /data (SQLite). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
208 lines
7.7 KiB
Python
208 lines
7.7 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Template & parser Excel nilai per mahasiswa per Sub-CPMK."""
|
||
import io
|
||
|
||
import openpyxl
|
||
from openpyxl.styles import Alignment, Font, PatternFill
|
||
from openpyxl.utils import get_column_letter
|
||
|
||
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()
|
||
ws = wb.active
|
||
ws.title = "Mahasiswa"
|
||
ws.append([f"DAFTAR MAHASISWA MENGONTRAK MK — {tahun}"])
|
||
ws.append(["Petunjuk: satu baris per kontrak (mahasiswa yang mengambil 5 MK "
|
||
"berarti 5 baris). Kode MK harus sesuai sheet 'Kode MK'."])
|
||
ws.append([])
|
||
ws.append(["NPM", "Nama Mahasiswa", "Kode MK", "Kelas"])
|
||
for ci, lebar in enumerate((16, 30, 14, 10), 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")
|
||
ws.column_dimensions[get_column_letter(ci)].width = lebar
|
||
ws.freeze_panes = "A5"
|
||
|
||
rujukan = wb.create_sheet("Kode MK")
|
||
rujukan.append(["Kode MK", "Nama Mata Kuliah"])
|
||
for ci in (1, 2):
|
||
c = rujukan.cell(row=1, column=ci)
|
||
c.font = Font(bold=True, color="FFFFFF")
|
||
c.fill = PatternFill("solid", fgColor=BIRU)
|
||
for mk in daftar_mk:
|
||
rujukan.append([mk["kode"], mk["nama"]])
|
||
rujukan.column_dimensions["A"].width = 14
|
||
rujukan.column_dimensions["B"].width = 46
|
||
|
||
buf = io.BytesIO()
|
||
wb.save(buf)
|
||
return buf.getvalue()
|
||
|
||
|
||
def parse_mahasiswa(isi: bytes, kode_mk_valid: set[str]) -> tuple[list[dict], list[str]]:
|
||
"""Baca xlsx daftar mahasiswa → (baris kontrak, peringatan).
|
||
|
||
Header dicari otomatis (baris yang memuat 'NPM'); kolom dikenali dari
|
||
judulnya: NPM, Nama, Kode MK, Kelas.
|
||
"""
|
||
wb = openpyxl.load_workbook(io.BytesIO(isi), read_only=True, data_only=True)
|
||
ws = wb["Mahasiswa"] if "Mahasiswa" in wb.sheetnames else wb.active
|
||
rows = list(ws.iter_rows(values_only=True))
|
||
|
||
kol = {}
|
||
header_i = None
|
||
for i, row in enumerate(rows):
|
||
vals = [str(v).strip().lower() 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 == "npm":
|
||
kol["npm"] = ci
|
||
elif "nama" in v:
|
||
kol.setdefault("nama", ci)
|
||
elif "kode" in v or v == "mk":
|
||
kol.setdefault("mk", ci)
|
||
elif "kelas" in v:
|
||
kol.setdefault("kelas", ci)
|
||
break
|
||
if header_i is None or "npm" not in kol or "mk" not in kol:
|
||
return [], ["Header tidak dikenali: pastikan ada kolom NPM, Nama, "
|
||
"Kode MK, dan Kelas (pakai template dari aplikasi)."]
|
||
|
||
def ambil(vals, kunci):
|
||
ci = kol.get(kunci)
|
||
if ci is None or ci >= len(vals) or vals[ci] is None:
|
||
return ""
|
||
v = vals[ci]
|
||
# NPM/kelas numerik dari Excel → buang '.0'
|
||
if isinstance(v, float) and v.is_integer():
|
||
v = int(v)
|
||
return str(v).strip()
|
||
|
||
baris, peringatan, tak_dikenal = [], [], set()
|
||
terlihat = set()
|
||
for row in rows[header_i + 1:]:
|
||
vals = list(row)
|
||
npm = ambil(vals, "npm")
|
||
mk = ambil(vals, "mk").upper()
|
||
if not npm or not mk:
|
||
continue
|
||
if mk not in kode_mk_valid:
|
||
tak_dikenal.add(mk)
|
||
continue
|
||
if (npm, mk) in terlihat:
|
||
continue
|
||
terlihat.add((npm, mk))
|
||
baris.append({"npm": npm, "nama": ambil(vals, "nama"),
|
||
"mk_kode": mk, "kelas": ambil(vals, "kelas")})
|
||
|
||
if tak_dikenal:
|
||
peringatan.append(
|
||
f"{len(tak_dikenal)} kode MK tidak dikenal dan dilewati: "
|
||
+ ", ".join(sorted(tak_dikenal)[:6])
|
||
+ ("…" if len(tak_dikenal) > 6 else ""))
|
||
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
|