diff --git a/METODOLOGI_ASESMEN_KOMPONEN.md b/METODOLOGI_ASESMEN_KOMPONEN.md new file mode 100644 index 0000000..ebefbca --- /dev/null +++ b/METODOLOGI_ASESMEN_KOMPONEN.md @@ -0,0 +1,234 @@ +# Metodologi Asesmen: Sub-CPMK vs Komponen + +Dokumen ini menjelaskan **dua sudut pandang nilai** di halaman Input Nilai +(`/nilai/`) — bagaimana keduanya dihitung, bagaimana keduanya berbeda, +dan bagaimana pembagian bobot bekerja ketika satu komponen (mis. Tugas) diisi +lewat beberapa sesi yang masing-masing menyasar sebagian Sub-CPMK saja. + +Pelengkap `RANCANGAN.md §7` (roll-up CPMK/CPL tingkat prodi) dan halaman +`/metodologi` (dokumentasi resmi untuk asesor) — dokumen ini fokus ke lapisan +**di dalam satu MK**: dari nilai mentah yang diketik dosen, sampai ke skor +Sub-CPMK yang jadi bahan roll-up CPMK/CPL. + +## 1. Dua tampilan, satu sumber data + +| | **Nilai per Mahasiswa** (berdasarkan Sub-CPMK) | **Nilai per Komponen** (berdasarkan komponen) | +|---|---|---| +| Kolom tabel | Tiap Sub-CPMK (Sub-051-1, Sub-051-2, ...) | Tiap komponen (Kuis, Tugas, UTS, ...) | +| Isi sel | Skor Sub-CPMK — presisi, boleh beda-beda dalam satu komponen | Rata-rata skor **mentah** tiap sesi/instrumen komponen itu yang diikuti mahasiswa | +| Dipakai untuk roll-up CPMK/CPL? | **Ya** — ini sumber kebenaran satu-satunya | **Tidak** — murni ringkasan & alat edit cepat | +| Efek saat diedit | Menulis persis ke Sub-CPMK yang diketik | Menulis nilai yang sama ke **semua** Sub-CPMK komponen itu (menimpa presisi bila sebelumnya beda-beda) | + +Intinya: **Sub-CPMK adalah satuan ukur OBE yang sesungguhnya.** "Komponen" (Kuis, +Tugas, UTS, dst.) hanyalah *label pengelompokan instrumen penilaian* — satu +komponen bisa mengukur beberapa Sub-CPMK sekaligus, dan satu Sub-CPMK bisa +diukur oleh lebih dari satu komponen. "Nilai per Komponen" cuma jendela lain +untuk melihat/mengedit data yang sama, dari sudut instrumen bukan dari sudut +capaian. + +## 2. Alur data lengkap + +```mermaid +flowchart TD + ISI["Isi Nilai per komponen\n(sesi bernama; Tugas boleh pilih\nsubset Sub-CPMK per sesi)"] --> SESI + GRID["Nilai per Mahasiswa\n(ketik langsung per Sub-CPMK)"] --> SK + SESI["sesi[komponen]\n= daftar sesi mentah tiap komponen\n(nim → skor, subset Sub-CPMK opsional)"] + SESI <--> KOMP["Nilai per Komponen\n(tampil = rata-rata sesi;\nketik = sesi baru 'Input Cepat')"] + + SESI --> H["skor_dari_sesi()\nbagi kontribusi per Sub-CPMK,\ntertimbang bobot_komponen ÷ n_subs_komponen"] + H --> SK["Skor Sub-CPMK per mahasiswa\n(mahasiswa[].skor — SUMBER KEBENARAN)"] + + SK --> CPMK["rollup_mk(): Skor CPMK\n= rata-rata Sub-CPMK pendukung"] + CPMK --> CPL["Skor CPL (dalam MK)\n= rata-rata CPMK pendukung"] + CPMK --> PRODI["rollup_prodi(): CPMK/CPL tingkat prodi\n(lintas semua MK pemikul)"] + SK --> AKHIR["Nilai Akhir mahasiswa\n= Σ(bobot_sub × skor_sub) / Σ bobot_sub\n(bobot dari RPS, bagi rata per komponen)"] +``` + +Dua hal penting dari diagram ini: + +1. **"Nilai per Komponen" bukan jalur data terpisah — ia baca/tulis ke sesi + yang sama** yang dipakai `skor_dari_sesi()`. Angka yang **ditampilkan** di + situ (rata-rata sesi) tidak dipakai langsung oleh rumus manapun — ia + sekadar ringkasan. Tapi begitu diedit (diketik ulang), perubahan itu + tercatat sebagai sesi baru "Input Cepat" dan **ikut dihitung** lewat jalur + yang sama seperti sesi bernama lain — jadi tetap satu sumber kebenaran, + bukan data ganda yang bisa tidak sinkron. +2. **"Nilai Akhir" (nilai huruf/angka mahasiswa) dan "Ketercapaian CPMK/CPL" + (OBE) adalah dua rumus berbeda** yang sama-sama diturunkan dari skor + Sub-CPMK, tapi dengan cara menimbang yang berbeda (lihat §6). + +## 3. Komponen berulang vs sekali-jalan + +| Tipe | Contoh | Bisa berapa sesi? | Sub-CPMK per sesi | +|---|---|---|---| +| **Berulang** | Kuis, Presentasi, Tes Lisan | Banyak — tiap sesi dirata-rata sederhana | Selalu semua Sub-CPMK komponen itu | +| **Berulang + subset** | **Tugas** (Observasi/Praktik) | Banyak, tiap sesi = satu tugas bernama | **Bisa dipilih sebagian** — lihat §4 | +| **Sekali-jalan** | UTS, UAS | Satu — mengisi lagi menimpa sesi yang sama | Selalu semua Sub-CPMK komponen itu | + +Komponen **Tugas** adalah satu-satunya yang mendukung penyasaran Sub-CPMK per +sesi (checklist di form "Isi Nilai"), karena secara alami satu MK sering punya +beberapa tugas berbeda yang masing-masing menguji kemampuan (Sub-CPMK) yang +berbeda pula — beda dari Kuis/Presentasi yang biasanya satu instrumen menguji +semua Sub-CPMK yang sama tiap kali. + +## 4. Sub-CPMK per sesi — bagaimana pembagiannya + +Saat RPS menetapkan komponen Tugas mengukur, misalnya, **4 Sub-CPMK** dengan +bobot **20%**, checklist di form "Isi Nilai" **selalu dibatasi ke 4 Sub-CPMK +itu saja** (tak bisa menambah Sub-CPMK di luar yang RPS tetapkan) — bawaannya +semua tercentang, dosen tinggal **mengurangi** centang sesuai Sub-CPMK mana +yang benar-benar diuji tugas tsb. + +**Aturan penggabungan kontribusi** (fungsi `skor_dari_sesi`): + +- Untuk tiap Sub-CPMK di dalam komponen itu, sistem mencari **semua sesi** + yang menyasarnya (baik yang eksplisit memilihnya, atau sesi lama yang tak + punya pilihan subset sama sekali → dianggap menyasar semua Sub-CPMK + komponen). +- Sesi-sesi yang relevan itu dirata-rata sederhana dulu **per Sub-CPMK** + (bukan rata dulu antar-tugas lalu disebar rata — tiap Sub-CPMK punya rata- + ratanya sendiri dari tugas yang benar-benar mengukurnya). +- **Bobot komponen (20%) dibagi rata ke SEMUA 4 Sub-CPMK milik komponen itu** + (5% tiap Sub-CPMK) — pembagian ini memakai jumlah Sub-CPMK **komponen**, + bukan jumlah Sub-CPMK **sesi tertentu**. Bobot per-Sub-CPMK inilah yang + dipakai untuk menimbang kalau Sub-CPMK yang sama juga diukur komponen lain + (mis. Presentasi turut menilai Sub-052). + +```mermaid +flowchart LR + subgraph RPS["RPS: Observasi (Praktik/Tugas) — bobot 20%, 4 Sub-CPMK"] + direction TB + RSub1["Sub-051-1"] + RSub2["Sub-051-2"] + RSub3["Sub-052-1"] + RSub4["Sub-052-2"] + end + T1["Tugas 1: Analisis Kasus\n(centang: Sub-051-1, Sub-051-2)"] --> RSub1 + T1 --> RSub2 + T2["Tugas 2: Proyek Mini\n(centang: Sub-052-1, Sub-052-2)"] --> RSub3 + T2 --> RSub4 + T3["Tugas 3: Presentasi Studi Kasus\n(centang: semua 4)"] --> RSub1 + T3 --> RSub2 + T3 --> RSub3 + T3 --> RSub4 +``` + +## 5. Skenario nyata: tugas menyasar sebagian Sub-CPMK + +Studi kasus dari MK Logika Komputasional — komponen **Observasi (Praktik/ +Tugas)**, bobot RPS **20%**, mencakup 4 Sub-CPMK: `Sub-051-1`, `Sub-051-2`, +`Sub-052-1`, `Sub-052-2`. + +> **Penyederhanaan untuk ilustrasi:** contoh di bawah mengasumsikan keempat +> Sub-CPMK itu **hanya** diukur komponen Tugas, supaya mekanisme pembagiannya +> kelihatan jelas. Di data MK ini yang sebenarnya, `Sub-051-1/2` juga ikut +> diukur komponen **Tes Tulis (UTS)** — lihat §5.1 untuk contoh gabungan +> lintas-komponen dengan angka riil. + +**Tiga tugas dibuat sepanjang semester:** + +| Sesi | Sub-CPMK disasar | Nilai per mahasiswa | +|---|---|---| +| Tugas 1: Analisis Kasus | Sub-051-1, Sub-051-2 | Andi 85 · Budi 92 | +| Tugas 2: Proyek Mini | Sub-052-1, Sub-052-2 | Citra 78 · Dedi 88 | +| Tugas 3: Presentasi Studi Kasus | Semua 4 Sub-CPMK | Andi 88 · Budi 90 · Citra 82 · Dedi 86 · Eka 79 | + +**Hasil skor per Sub-CPMK** (rata-rata sederhana dari sesi yang relevan tiap +Sub-CPMK): + +| Mahasiswa | Sub-051-1 / -2 | Sub-052-1 / -2 | Keterangan | +|---|---|---|---| +| Andi | (85 + 88) / 2 = **86,5** | 88 (dari Tugas 3 saja) | Ikut Tugas 1 & 3 | +| Budi | (92 + 90) / 2 = **91** | 90 (dari Tugas 3 saja) | Ikut Tugas 1 & 3 | +| Citra | 82 (dari Tugas 3 saja) | (78 + 82) / 2 = **80** | Ikut Tugas 2 & 3 | +| Dedi | 86 (dari Tugas 3 saja) | (88 + 86) / 2 = **87** | Ikut Tugas 2 & 3 | +| Eka | 79 (dari Tugas 3 saja) | 79 (dari Tugas 3 saja) | Cuma ikut Tugas 3 | + +Perhatikan: **Sub-051 dan Sub-052 milik satu mahasiswa boleh berbeda** — itu +tepat sesuai fakta bahwa Tugas 1 dan Tugas 2 mengukur kemampuan yang berbeda. +Ini yang dipakai untuk ketercapaian **CPMK051** dan **CPMK052** — masing- +masing presisi sesuai tugas yang benar-benar mengukurnya, bukan dicampur rata. + +**Tampilan "Nilai per Komponen"** (rata-rata mentah tiap tugas yang diikuti, +independen dari Sub-CPMK): + +| Mahasiswa | Tugas 1 | Tugas 2 | Tugas 3 | Rata-rata tampil | +|---|---|---|---|---| +| Andi | 85 | — | 88 | (85+88)/2 = **86,5** | +| Budi | 92 | — | 90 | (92+90)/2 = **91** | +| Citra | — | 78 | 82 | (78+82)/2 = **80** | +| Dedi | — | 88 | 86 | (88+86)/2 = **87** | +| Eka | — | — | 79 | **79** | + +Perhatikan Andi: "Nilai per Komponen"-nya **86,5** kebetulan sama dengan skor +Sub-051 (86,5), tapi **berbeda** dari skor Sub-052-nya (88) — karena kolom +"Nilai per Komponen" cuma satu angka gabungan dari *seluruh* tugas yang +diikuti (Tugas 1 + Tugas 3), tanpa mempedulikan Sub-CPMK mana yang disasar +tiap tugas. Pola yang sama berlaku untuk Budi, Citra, dan Dedi. Kalau kolom +ini **diketik ulang** (dijadikan override manual), nilai baru itu akan +ditulis **rata ke keempat Sub-CPMK** sekaligus — jadi presisi Sub-051 vs +Sub-052 akan hilang untuk mahasiswa itu kalau memang sengaja ditimpa. + +### 5.1 Saat Sub-CPMK yang sama diukur >1 komponen (angka riil) + +Di RPS MK ini, `Sub-051-1`/`Sub-051-2` sebenarnya diukur **dua komponen**: +Observasi (Praktik/Tugas) — bobot 20%, 4 Sub-CPMK — **dan** Tes Tulis (UTS) +— bobot 25%, 6 Sub-CPMK. Andi punya nilai UTS = 80 (sesi tunggal, berlaku ke +keenam Sub-CPMK UTS) selain nilai Tugas-nya di atas. + +**Bobot kontribusi tiap komponen ke Sub-051** = bobot komponen ÷ jumlah +Sub-CPMK komponen itu (bukan jumlah Sub-CPMK yang disasar sesi tertentu): + +``` +w(Tugas) = 20% ÷ 4 Sub-CPMK = 5,0% +w(UTS) = 25% ÷ 6 Sub-CPMK = 4,1667% +``` + +**Skor Sub-051 Andi** = rata-rata tertimbang kedua kontribusi itu: + +``` +rata Tugas (Tugas 1 & 3) = (85 + 88) / 2 = 86,5 +rata UTS = 80 + +skor Sub-051 = (5,0% × 86,5) + (4,1667% × 80) + ──────────────────────────────── + 5,0% + 4,1667% + + = (4,325 + 3,3333) / 0,091667 = 83,55 +``` + +Ini **persis** angka yang muncul di grid Sub-CPMK aplikasi (83,5455 — beda +tipis karena pembulatan). Jadi kalau sebuah Sub-CPMK diukur lebih dari satu +komponen, komponen dengan **bobot lebih besar relatif terhadap jumlah +Sub-CPMK yang ditanggungnya** akan lebih dominan menentukan skor akhir +Sub-CPMK itu — bukan sekadar rata-rata polos antar komponen. + +## 6. Nilai Akhir (huruf mutu) vs Ketercapaian CPL/CPMK (OBE) + +Dua angka yang **kelihatannya mirip** tapi rumusnya beda tujuan: + +| | **Nilai Akhir mahasiswa** | **Ketercapaian CPMK/CPL** | +|---|---|---| +| Untuk apa | Nilai/huruf mutu mahasiswa (transkrip) | Evaluasi kurikulum (OBE) | +| Rumus | Σ(bobot Sub-CPMK × skor mahasiswa) / Σ bobot — bobot dari RPS, dibagi rata per Sub-CPMK dalam tiap komponen | Rata-rata sederhana Sub-CPMK → CPMK → CPL (bagi rata, tanpa bobot RPS) | +| Level | Per mahasiswa, per MK | Per angkatan/kelas, lintas MK (tingkat prodi) | +| Halaman | Kolom "Nilai Akhir" di form Nilai | Halaman Rekap CPL/CPMK, Dasbor Monitoring | + +Keduanya bersumber dari **skor Sub-CPMK yang sama** — bedanya cuma di cara +menimbang saat digulung ke atas. + +## 7. Ringkasan rumus + +``` +skor Sub-CPMK = rata-rata sesi yang menyasarnya (lintas komponen, + tertimbang bobot_komponen ÷ n_subs_komponen) +Nilai Akhir = Σ(bobot_sub × skor_sub) / Σ bobot_sub +Skor CPMK (dlm MK) = rata-rata skor Sub-CPMK pendukung +Skor CPL (dlm MK) = rata-rata skor CPMK pendukung +Skor CPMK (prodi) = rata-rata skor CPMK itu lintas semua MK pemikul +Skor CPL (prodi) = rata-rata skor CPMK pendukung, lintas prodi +Nilai per Komponen = rata-rata skor MENTAH sesi komponen itu yang diikuti + mahasiswa (tampilan/edit cepat — tidak dipakai di + rumus manapun di atas secara langsung; edit di sini + menulis balik ke skor Sub-CPMK secara rata) +``` diff --git a/app/hitung.py b/app/hitung.py index 7acbf52..8fd239c 100644 --- a/app/hitung.py +++ b/app/hitung.py @@ -98,26 +98,20 @@ def skor_dari_sesi(komponen_daftar: list[dict], sesi: dict) -> dict[str, dict[st 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). + + Tiap sesi boleh punya "subs" sendiri (subset Sub-CPMK komponen itu — dipakai + komponen Observasi/Tugas untuk rincian ala RTM per tugas bernama); sesi + tanpa "subs" (atau kosong) dianggap menyasar semua Sub-CPMK komponen itu + (perilaku lama). Dengan begitu satu komponen boleh diisi lewat beberapa + sesi yang masing-masing hanya menyasar sebagian Sub-CPMK, tanpa + menyamaratakan nilainya ke Sub-CPMK lain yang tak disasar sesi itu. """ 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_nim: dict[str, list[float]] = {} - for s in daftar_sesi: - for nim, v in (s.get("nilai") or {}).items(): - if v is not None: - per_nim.setdefault(nim, []).append(float(v)) - per_komponen_avg[i] = {nim: _rata(xs) for nim, xs in per_nim.items()} - kontribusi_per_sub: dict[str, list[tuple[float, dict]]] = {} - for i, avg_map in per_komponen_avg.items(): - k = komponen_by_i[i] + for i, k in komponen_by_i.items(): + daftar_sesi = (sesi or {}).get(i) or [] + if not daftar_sesi: + continue try: bobot = float(k.get("bobot") or 0) except (TypeError, ValueError): @@ -125,6 +119,15 @@ def skor_dari_sesi(komponen_daftar: list[dict], sesi: dict) -> dict[str, dict[st n_subs = len(k["subs"]) or 1 w = bobot / n_subs if bobot else 1.0 for sub in k["subs"]: + relevan = [s for s in daftar_sesi if sub in (s.get("subs") or k["subs"])] + if not relevan: + continue + per_nim: dict[str, list[float]] = {} + for s in relevan: + for nim, v in (s.get("nilai") or {}).items(): + if v is not None: + per_nim.setdefault(nim, []).append(float(v)) + avg_map = {nim: _rata(xs) for nim, xs in per_nim.items()} kontribusi_per_sub.setdefault(sub, []).append((w, avg_map)) out: dict[str, dict[str, float]] = {} @@ -192,9 +195,11 @@ def rollup_mk(peta: dict, nilai_data: dict, ambang: float) -> dict: cpl: dict[str, dict] = {} for cp in peta["cpls"]: - skor = _rata([v["skor"] for v in cpmk.values() - if cp["kode"] in v["cpl"] and v["skor"] is not None]) - cpl[cp["kode"]] = {"skor": skor} + pendukung = [v for v in cpmk.values() if cp["kode"] in v["cpl"]] + ternilai = [v for v in pendukung if v["skor"] is not None] + skor = _rata([v["skor"] for v in ternilai]) + cpl[cp["kode"]] = {"skor": skor, "n_cpmk": len(pendukung), + "n_cpmk_ternilai": len(ternilai)} return {"sub": sub, "cpmk": cpmk, "cpl": cpl, "mode": nilai_data.get("mode", "agregat")} diff --git a/app/main.py b/app/main.py index 67c874a..edd24bb 100644 --- a/app/main.py +++ b/app/main.py @@ -525,8 +525,29 @@ async def simpan_rps(request: Request, kode: str): } aksi = form.get("aksi") or "draft" status = "selesai" if aksi == "selesai" else "draft" + + # Komponen boleh kosong (MK tak memakainya, mis. tak ada proyek/Tugas), + # tapi total bobot komponen yang DIISI wajib tepat 100% sebelum RPS bisa + # ditandai selesai — kalau belum, tetap disimpan (tak menghapus isian) + # tapi statusnya dipaksa draft. + total_bobot_g = 0.0 + for k in komponen: + if k["bobot"]: + try: + total_bobot_g += float(k["bobot"]) + except ValueError: + pass + pesan_tambahan = "" + if status == "selesai" and abs(total_bobot_g - 100) > 0.01: + status = "draft" + pesan_tambahan = ( + f"+—+disimpan+sebagai+draft:+total+bobot+komponen+G+harus+tepat+100%25+" + f"(saat+ini+{total_bobot_g:g}%25)+sebelum+bisa+ditandai+selesai" + ) + db.simpan_rps(kode, data, status, request.state.user["nama"]) - return RedirectResponse(f"/rps/{kode}?pesan=Tersimpan+sebagai+{status}.", status_code=303) + return RedirectResponse( + f"/rps/{kode}?pesan=Tersimpan+sebagai+{status}{pesan_tambahan}", status_code=303) @app.get("/rps/{kode}/unduh") @@ -602,12 +623,23 @@ def _grid_mahasiswa(tersimpan: dict | None, terdaftar: list[dict], return grid +IDX_TUGAS = next(i for i, k in enumerate(KOMPONEN_DEFAULT) + if k["nama"] == "Observasi (Praktik/Tugas)") + + 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).""" + komponen sekali-jalan (UTS/UAS). 'tugas' menandai komponen Observasi + (Praktik/Tugas) — satu-satunya yang menawarkan rincian ala RTM (deskripsi + + checklist Sub-CPMK per sesi) di form isi nilainya. + + Komponen dengan bobot kosong dianggap TAK DIPAKAI MK ini (mis. tak ada + proyek/Tugas) dan disembunyikan sepenuhnya dari halaman nilai — bukan + error, itu memang boleh terjadi (lihat validasi 100% saat RPS ditandai + selesai di simpan_rps).""" rps = db.ambil_rps(kode) if not rps: return [] @@ -615,27 +647,62 @@ def _komponen_daftar(kode: str) -> list[dict]: 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"]: + if not k["subs"] or not k["bobot"]: continue - out.append({"i": idx, "nama": k["nama"], "bobot": k["bobot"], + key = str(idx) + out.append({"i": key, "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), []))}) + "tugas": idx == IDX_TUGAS, + "n_sesi": len(sesi_semua.get(key, []))}) return out -def _ambil_komponen(kode: str, i: int) -> dict | None: +def _ambil_komponen(kode: str, i: str) -> 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"]: + try: + idx = int(i) + except ValueError: return None - k = dict(komponen_list[i]) - k["ulang"] = KOMPONEN_DEFAULT[i]["ulang"] + if (idx < 0 or idx >= len(komponen_list) + or not komponen_list[idx]["subs"] or not komponen_list[idx]["bobot"]): + return None + k = dict(komponen_list[idx]) + k["ulang"] = KOMPONEN_DEFAULT[idx]["ulang"] + k["tugas"] = idx == IDX_TUGAS return k +def _tabel_komponen(grid: list[dict], komponen_daftar: list[dict], + sesi_semua: dict) -> list[dict]: + """Ringkasan nilai per KOMPONEN (mis. Tugas, UTS, UAS) per mahasiswa — + rata-rata skor MENTAH tiap sesi/tugas komponen itu yang mahasiswa ybs + ikuti (bukan lewat Sub-CPMK), sehingga mis. "Tugas" tetap tampil sebagai + satu angka gabungan meski tiap tugas menyasar Sub-CPMK berbeda-beda — + berbeda dari grid Sub-CPMK di bawah yang presisi per Sub-CPMK. Bisa + diedit langsung dari sini (lihat aksi='komponen' di simpan_nilai); angka + yang diketik menimpa SEMUA Sub-CPMK komponen itu untuk mahasiswa tsb + (override manual, terpisah dari presisi per-tugas per Sub-CPMK).""" + out = [] + for m in grid: + nilai = {} + for k in komponen_daftar: + daftar_sesi = sesi_semua.get(k["i"]) or [] + xs = [s["nilai"][m["nim"]] for s in daftar_sesi + if m["nim"] in (s.get("nilai") or {})] + # tak ada sesi tercatat untuk komponen ini (mis. Sub-CPMK-nya + # diisi langsung lewat grid Sub-CPMK, bukan per komponen) — + # dikosongkan, bukan ditebak dari skor Sub-CPMK (subnya bisa + # dipakai bersama komponen lain, jadi tak bisa dipastikan sumbernya) + nilai[k["i"]] = (sum(xs) / len(xs)) if xs else None + out.append({"nim": m["nim"], "nama": m["nama"], "kelas": m.get("kelas"), + "nilai": nilai}) + return out + + def _konteks_nilai(request, kode, pesan="", peringatan=None): peta = db.peta_mk(kode) tersimpan = db.ambil_dok("nilai", kode) @@ -652,12 +719,15 @@ def _konteks_nilai(request, kode, pesan="", peringatan=None): kelas_dosen = _kelas_dosen(request.state.user, kode) terdaftar = db.mahasiswa_mk(kode) grid = _grid_mahasiswa(tersimpan, terdaftar, kelas_dosen) + komponen_daftar = _komponen_daftar(kode) + sesi_semua = (tersimpan["data"].get("sesi") or {}) if tersimpan else {} return render( request, "form_nilai.html", {"peta": peta, "tersimpan": tersimpan, "hasil": hasil, "ambang": ambang, "akhir": akhir, "tahun": db.tahun_aktif(), "pesan": pesan, "grid": grid, "kelas_dosen": kelas_dosen, - "komponen_daftar": _komponen_daftar(kode), + "komponen_daftar": komponen_daftar, + "tabel_komponen": _tabel_komponen(grid, komponen_daftar, sesi_semua) if komponen_daftar else [], "peringatan": peringatan or []}, ) @@ -674,7 +744,7 @@ def form_nilai(request: Request, kode: str, pesan: 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 = ""): +def form_nilai_komponen(request: Request, kode: str, i: str, sesi_id: int = -1, pesan: str = ""): peta = db.peta_mk(kode) if not peta: return RedirectResponse("/", status_code=303) @@ -685,10 +755,11 @@ def form_nilai_komponen(request: Request, kode: str, i: int, sesi_id: int = -1, komponen = _ambil_komponen(kode, i) if not komponen: return RedirectResponse( - f"/nilai/{kode}?pesan=Komponen+belum+dikaitkan+ke+Sub-CPMK+di+RPS+bagian+G.", + f"/nilai/{kode}?pesan=Komponen+ini+belum+dipakai+MK+ini+" + "(Sub-CPMK+dan/atau+bobotnya+belum+diisi+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), []) + daftar_sesi = ((tersimpan["data"].get("sesi") or {}) if tersimpan else {}).get(i, []) edit = None if not komponen["ulang"] and daftar_sesi: @@ -703,6 +774,13 @@ def form_nilai_komponen(request: Request, kode: str, i: int, sesi_id: int = -1, label_awal = (edit or {}).get("label") or ( komponen["nama"] if not komponen["ulang"] else f"{komponen['nama']} {len(daftar_sesi) + 1}") + subs_terpilih = set((edit or {}).get("subs") or komponen["subs"]) + uraian = {s["kode"]: s["uraian"] for s in peta["subs"]} + subs_pilihan = [{"kode": s, "uraian": uraian.get(s, s)} for s in komponen["subs"]] + # Rincian ala RTM (khusus komponen Tugas) — semua opsional, per sesi/tugas + rtm = {kunci: (edit or {}).get(kunci, "") for kunci in + ("bentuk_tugas", "deskripsi", "metode_pengerjaan", "luaran", + "indikator_bobot", "jadwal", "lain_lain", "rujukan")} kelas_dosen = _kelas_dosen(request.state.user, kode) grid = _grid_mahasiswa(tersimpan, db.mahasiswa_mk(kode), kelas_dosen) @@ -713,12 +791,13 @@ def form_nilai_komponen(request: Request, kode: str, i: int, sesi_id: int = -1, 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, + "rtm": rtm, "subs_pilihan": subs_pilihan, "subs_terpilih": subs_terpilih, "tahun": db.tahun_aktif(), "pesan": pesan}, ) @app.post("/nilai/{kode}/komponen/{i}") -async def simpan_nilai_komponen(request: Request, kode: str, i: int): +async def simpan_nilai_komponen(request: Request, kode: str, i: str): peta = db.peta_mk(kode) if not peta: return RedirectResponse("/", status_code=303) @@ -761,17 +840,29 @@ async def simpan_nilai_komponen(request: Request, kode: str, i: int): 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), [])) + daftar_sesi = list(sesi_semua.get(i, [])) + + subs_dipilih = form.getlist("subs") entri = {"label": label, "at": datetime.now(db.WIB).strftime("%Y-%m-%d %H:%M"), "by": updated_by, "nilai": nilai} + # rincian ala RTM (khusus komponen Tugas) — semua opsional, dokumentasi saja + for kunci in ("bentuk_tugas", "deskripsi", "metode_pengerjaan", "luaran", + "indikator_bobot", "jadwal", "lain_lain", "rujukan"): + nilai_kunci = (form.get(kunci) or "").strip() + if nilai_kunci: + entri[kunci] = nilai_kunci + if subs_dipilih and set(subs_dipilih) != set(komponen["subs"]): + # subset Sub-CPMK khusus sesi ini (ala RTM) — dibiarkan kosong (berarti + # menyasar semua Sub-CPMK komponen, perilaku lama) bila semua tercentang + entri["subs"] = [s for s in subs_dipilih if s in komponen["subs"]] 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 + sesi_semua[i] = daftar_sesi komponen_daftar = _komponen_daftar(kode) skor_terhitung = hitung.skor_dari_sesi(komponen_daftar, sesi_semua) @@ -821,6 +912,70 @@ async def simpan_nilai(request: Request, kode: str): request, kode, pesan="Seluruh nilai tersimpan MK ini dihapus — silakan mulai dari awal.") + if aksi == "komponen": + kelas_dosen = _kelas_dosen(request.state.user, kode) + nim_diizinkan = _nim_diizinkan(kode, kelas_dosen) + komponen_daftar = _komponen_daftar(kode) + 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()} + nama_map = {mm["nim"]: mm["nama"] for mm in db.mahasiswa_mk(kode)} + n_diubah = 0 + i = 0 + while f"nim_{i}" in form: + nim = (form.get(f"nim_{i}") or "").strip() + if nim and (nim_diizinkan is None or nim in nim_diizinkan): + for k in komponen_daftar: + v = (form.get(f"komp_{i}_{k['i']}") or "").strip() + if not v: + continue + try: + x = float(v) + except ValueError: + continue + if not (0 <= x <= 100): + continue + # Simpan sebagai sesi "Input Cepat" (satu per komponen, + # ditimpa/diperbarui tiap kali) — supaya sumbernya sama + # dengan alur "Isi Nilai" dan bisa dihitung ulang seragam + # lewat skor_dari_sesi (lihat _tabel_komponen). + daftar_sesi = sesi_semua.setdefault(k["i"], []) + cepat = next((s for s in daftar_sesi if s.get("cepat")), None) + if cepat is None: + cepat = {"label": "Input Cepat", "cepat": True, "nilai": {}} + daftar_sesi.append(cepat) + cepat["nilai"][nim] = x + cepat["at"] = datetime.now(db.WIB).strftime("%Y-%m-%d %H:%M") + cepat["by"] = updated_by + n_diubah += 1 + i += 1 + if not n_diubah: + return _konteks_nilai(request, kode, + peringatan=["Tidak ada nilai komponen yang diubah."]) + + skor_terhitung = hitung.skor_dari_sesi(komponen_daftar, sesi_semua) + urut = [dict(mm, skor=dict(mm.get("skor") or {})) for mm in lama_data.get("mahasiswa", [])] + per_nim = {mm["nim"]: mm for mm in urut if mm.get("nim")} + for nim, subskor in skor_terhitung.items(): + mm = per_nim.get(nim) + if mm is None: + mm = {"nim": nim, "nama": nama_map.get(nim, ""), "skor": {}} + urut.append(mm) + per_nim[nim] = mm + mm["skor"].update(subskor) + + riwayat = lama_data.get("sumber") or [] + if isinstance(riwayat, str): + riwayat = [riwayat] + riwayat.append(f"input per komponen ({updated_by})") + data = {"mode": "per_mahasiswa", "mahasiswa": urut, "sesi": sesi_semua, + "sumber": riwayat[-8:]} + db.simpan_dok("nilai", kode, data, "selesai", updated_by) + return _konteks_nilai( + request, kode, + pesan=f"{n_diubah} nilai komponen tersimpan.") + if aksi == "grid": kelas_dosen = _kelas_dosen(request.state.user, kode) nim_diizinkan = _nim_diizinkan(kode, kelas_dosen) diff --git a/app/templates/admin.html b/app/templates/admin.html index f80f83e..12c3629 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -48,6 +48,11 @@ {% if mk.kode in p.mk %}checked{% endif %}> {{ mk.kode }} — {{ mk.nama }} + {% if mhs.get(mk.kode) and mhs[mk.kode].kelas %} + kelas tersedia (dari data mahasiswa): {{ mhs[mk.kode].kelas|join(', ') }} + {% else %} + belum ada data mahasiswa/kelas untuk MK ini — impor dulu di bagian C + {% endif %} {% endfor %} diff --git a/app/templates/form_nilai.html b/app/templates/form_nilai.html index 8ee36ee..5e869b2 100644 --- a/app/templates/form_nilai.html +++ b/app/templates/form_nilai.html @@ -7,9 +7,7 @@

Input Nilai — {{ peta.nama }} ({{ peta.kode }})

-

Daftar mahasiswa mengikuti impor kaprodi. Apa pun cara - inputnya, pengukuran memakai satu metodologi yang sama: skor per Sub-CPMK - digulung ke CPMK lalu CPL (rincian di halaman Metodologi).

+

Skor per Sub-CPMK digulung ke CPMK lalu CPL (rincian di halaman Metodologi).

{% if kelas_dosen is not none %}
Anda hanya mengampu kelas {{ kelas_dosen|join(', ') }} pada MK ini — @@ -20,13 +18,7 @@ {% for w in peringatan %}
{{ w }}
{% endfor %} {% if komponen_daftar %} -

ISI PER KOMPONEN PENILAIAN (cara cepat — nilai per mahasiswa digabung ke Sub-CPMK yang diukur komponen itu)

-

- Komponen berulang (Kuis, Tugas, Presentasi, Tes Lisan) boleh diisi berkali-kali - sepanjang semester — tiap sesi dirata-rata otomatis. Komponen sekali-jalan - (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.

+

ISI PER KOMPONEN

@@ -76,14 +68,54 @@ {% endif %} + {% if grid and tabel_komponen and komponen_daftar %} +

NILAI PER KOMPONEN — nilai mahasiswa berdasarkan komponen

+

Angka = rata-rata skor mentah tiap sesi/tugas komponen itu yang diikuti mahasiswa (bukan lewat Sub-CPMK). Ketik untuk menimpa — + ditulis rata ke semua Sub-CPMK komponen itu; rincian per Sub-CPMK tetap presisi di grid bawah.

+ + +
+
+ + + + + + {% for k in komponen_daftar %}{% endfor %} + + + + + {% for r in tabel_komponen %} + {% set ri = loop.index0 %} + + + + + {% for k in komponen_daftar %} + + {% endfor %} + + + {% endfor %} + +
No.NIMNama{{ k.nama }}Nilai Akhir
{{ loop.index }} + + {{ r.nim }}{{ r.nama }}{{ '%.1f'|format(akhir.get(r.nim)) if akhir.get(r.nim) is not none else '—' }}
+
+
+ Disimpan atas nama {{ user.nama }} + + +
+ + {% endif %} + {% if grid %} {% set per_mhs = tersimpan and tersimpan.data.mode == 'per_mahasiswa' %} -

NILAI PER MAHASISWA (ketik langsung di sel, 0–100; kolom Nilai Akhir & baris rata-rata dihitung dari data tersimpan)

-

- Daftar mahasiswa dari impor kaprodi. Kosongkan sel yang belum dinilai; - Simpan Nilai menyimpan persis isi tabel. Nilai Akhir = tertimbang bobot - komponen RPS; angkanya diperbarui setelah disimpan. -

+

NILAI PER MAHASISWA — nilai mahasiswa berdasarkan Sub-CPMK

{% set ada_kelas = grid|selectattr('kelas')|list|length > 0 %} @@ -111,7 +143,7 @@ {% if ada_kelas %}{{ m.kelas or '—' }}{% endif %} {% for s in peta.subs %} {% endfor %} {{ '%.1f'|format(akhir.get(m.nim)) if akhir.get(m.nim) is not none else '—' }} @@ -147,16 +179,17 @@ {% endif %} {% if hasil %} -

HASIL KETERCAPAIAN MK INI (ambang {{ ambang|round(0)|int }}; roll-up bagi rata)

+

HASIL KETERCAPAIAN MK (ambang {{ ambang|round(0)|int }})

- + {% for kode_c, v in hasil.cpmk.items() %} + {% endfor %} @@ -165,12 +198,13 @@
CPMKSkorSub TernilaiCPL Didukung
CPMKSkorSub Ternilai% TernilaiCPL Didukung
{{ kode_c }} {% if v.skor is not none %}{{ '%.2f'|format(v.skor) }}{% else %}—{% endif %} {{ v.n_ternilai }}/{{ v.n_sub }}{{ '%.0f'|format(100 * v.n_ternilai / v.n_sub) ~ '%' if v.n_sub else '—' }} {{ v.cpl|join(', ') }}
- + {% for kode_c, v in hasil.cpl.items() %} +
CPL (dalam MK ini)SkorStatus
CPLSkor% CPMK TernilaiStatus
{{ kode_c }} {% if v.skor is not none %}{{ '%.2f'|format(v.skor) }}{% else %}—{% endif %}{{ '%.0f'|format(100 * v.n_cpmk_ternilai / v.n_cpmk) ~ '%' if v.n_cpmk else '—' }} {% if v.skor is none %}BELUM TERUKUR {% elif v.skor >= ambang %}TERCAPAI diff --git a/app/templates/form_nilai_komponen.html b/app/templates/form_nilai_komponen.html index 4f1bfaf..0dbb5ea 100644 --- a/app/templates/form_nilai_komponen.html +++ b/app/templates/form_nilai_komponen.html @@ -28,12 +28,13 @@ {% if komponen.ulang and daftar_sesi %}

SESI TERSIMPAN

- - +
SesiMahasiswa Terisi
+ {% if komponen.tugas %}{% endif %} {% for s in daftar_sesi %} - + + {% if komponen.tugas %}{% endif %} @@ -46,12 +47,55 @@ {% if komponen.ulang %} -
SesiSub-CPMKJadwalMahasiswa Terisi
{{ s.label }}{{ s.label }}{% if komponen.tugas and s.deskripsi %}
{{ s.deskripsi }}{% endif %}
{{ (s.subs or komponen.subs)|join(', ') }}{{ s.jadwal or '—' }}{{ (s.nilai or {})|length }} Edit
@@ -71,7 +115,7 @@ {% endfor %} @@ -84,4 +128,67 @@ + + {% if komponen.tugas %} + + {% endif %} {% endblock %} diff --git a/app/templates/form_rps.html b/app/templates/form_rps.html index ea1e530..681969b 100644 --- a/app/templates/form_rps.html +++ b/app/templates/form_rps.html @@ -4,10 +4,12 @@ @@ -34,7 +36,7 @@ {% endif %} - +

A. IDENTITAS MATA KULIAH

@@ -213,6 +215,7 @@
{% if boleh_edit %} Disimpan atas nama {{ user.nama }} + @@ -224,6 +227,10 @@
+
+ +
+ {% endblock %}
{{ m.nim }} {{ m.nama }}