dqlab-grader 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dqcheck/__init__.py ADDED
@@ -0,0 +1,296 @@
1
+ """dqcheck — pengecek jawaban latihan Data Analyst with Python.
2
+
3
+ Pemakaian singkat (di Colab / Jupyter)::
4
+
5
+ import dqcheck as dq
6
+ dq.mulai(nama="Budi Santoso", id_peserta="DQ-001", sesi=6)
7
+ ... # kerjakan soal
8
+ dq.cek("2.1") # cek jawaban soal 2.1
9
+ dq.daftar_soal() # soal mana yang belum selesai
10
+ dq.status() # rekap jawabanmu
11
+
12
+ Ketik ``dq.bantuan()`` untuk panduan lengkap.
13
+ """
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ import datetime as _dt
18
+ import inspect as _inspect
19
+ import re as _re
20
+ import uuid as _uuid
21
+
22
+ from . import analisis as _analisis
23
+ from . import soal as _soal
24
+ from . import storage as _storage
25
+ from .tampilan import kartu as _kartu, tabel as _tabel
26
+
27
+ __all__ = ["mulai", "ganti_sesi", "cek", "status", "daftar_soal", "bantuan", "kirim_ulang"]
28
+
29
+ _POLA_ID = _re.compile(r"^[A-Za-z0-9_.\-]{2,40}$")
30
+ _peserta = {}
31
+ _sesi_aktif = None
32
+
33
+
34
+ # ------------------------------------------------------------------ identitas
35
+
36
+ def mulai(nama=None, id_peserta=None, sesi=None):
37
+ """Isi identitas peserta (wajib sebelum ``cek``). Sesi opsional."""
38
+ global _sesi_aktif
39
+ nama = (nama or input("Nama lengkap : ")).strip()
40
+ id_peserta = (id_peserta or input("ID peserta : ")).strip()
41
+ nama = " ".join(nama.split())
42
+
43
+ salah = []
44
+ if not 2 <= len(nama) <= 80:
45
+ salah.append("Nama wajib diisi (2–80 karakter).")
46
+ if not _POLA_ID.match(id_peserta):
47
+ salah.append("ID peserta 2–40 karakter, hanya huruf, angka, titik, `-` atau `_` (tanpa spasi).")
48
+ if sesi is not None and int(sesi) not in _soal.daftar_sesi():
49
+ salah.append(f"Sesi {sesi} belum tersedia. Pilihan: {_soal.daftar_sesi()}.")
50
+ if salah:
51
+ _kartu("salah", "Identitas belum valid", bagian=[("Perbaiki:", salah)],
52
+ catatan='Contoh: `dq.mulai(nama="Budi Santoso", id_peserta="DQ-001", sesi=6)`')
53
+ return
54
+
55
+ _peserta.update(nama=nama, id_peserta=id_peserta)
56
+ if sesi is not None:
57
+ _sesi_aktif = int(sesi)
58
+
59
+ mode = _storage.mode()
60
+ info = [f"Nama: {nama}", f"ID peserta: `{id_peserta}`"]
61
+ if _sesi_aktif:
62
+ info.append(f"Sesi aktif: {_sesi_aktif} — {_soal.judul_sesi(_sesi_aktif)} "
63
+ f"({len(_soal.semua_soal(_sesi_aktif))} soal)")
64
+ info.append("Penyimpanan: server (jawaban tercatat ke instruktur)" if mode == "server"
65
+ else "Penyimpanan: LOKAL saja (belum terhubung ke server instruktur)")
66
+ langkah = [
67
+ "Kerjakan soal di sel jawabanmu, lalu jalankan sel tersebut.",
68
+ "Cek jawaban: `dq.cek(\"2.1\")`" + ("" if _sesi_aktif else " (atau `dq.cek(\"2.1\", sesi=6)`)"),
69
+ "Lihat soal yang belum selesai: `dq.daftar_soal()`",
70
+ "Lihat rekap jawabanmu: `dq.status()`",
71
+ ]
72
+ _kartu("info" if mode == "server" else "peringatan", f"Halo, {nama}! Siap mengecek jawaban.",
73
+ bagian=[("Identitas", info), ("Langkah berikutnya", langkah)],
74
+ catatan="Salah itu wajar — setiap percobaan tercatat dan kamu boleh mencoba lagi sampai benar.")
75
+
76
+
77
+ def ganti_sesi(sesi):
78
+ """Ganti sesi aktif, misal ``dq.ganti_sesi(7)``."""
79
+ global _sesi_aktif
80
+ if int(sesi) not in _soal.daftar_sesi():
81
+ _kartu("salah", f"Sesi {sesi} belum tersedia", sub=f"Pilihan: {_soal.daftar_sesi()}")
82
+ return
83
+ _sesi_aktif = int(sesi)
84
+ _kartu("info", f"Sesi aktif: {sesi} — {_soal.judul_sesi(sesi)}",
85
+ sub=f"{len(_soal.semua_soal(sesi))} soal. Lihat daftarnya: dq.daftar_soal()")
86
+
87
+
88
+ def _butuh_login():
89
+ if _peserta:
90
+ return False
91
+ _kartu("peringatan", "Isi identitas dulu sebelum mengecek jawaban",
92
+ bagian=[("Jalankan:", ['`dq.mulai(nama="Nama Lengkapmu", id_peserta="ID-dari-instruktur", sesi=6)`'])])
93
+ return True
94
+
95
+
96
+ def _tentukan_sesi(sesi):
97
+ s = sesi if sesi is not None else _sesi_aktif
98
+ if s is None:
99
+ _kartu("peringatan", "Sesi belum ditentukan",
100
+ bagian=[("Pilih salah satu:", ["`dq.cek(\"2.1\", sesi=6)`", "`dq.ganti_sesi(6)` lalu `dq.cek(\"2.1\")`"])])
101
+ return None
102
+ if int(s) not in _soal.daftar_sesi():
103
+ _kartu("salah", f"Sesi {s} belum tersedia", sub=f"Pilihan: {_soal.daftar_sesi()}")
104
+ return None
105
+ return int(s)
106
+
107
+
108
+ # ---------------------------------------------------------------------- cek
109
+
110
+ def cek(soal, sesi=None):
111
+ """Cek jawaban satu soal, misal ``dq.cek("2.1")`` atau ``dq.cek("Capstone", sesi=8)``."""
112
+ if _butuh_login():
113
+ return
114
+ s = _tentukan_sesi(sesi)
115
+ if s is None:
116
+ return
117
+ try:
118
+ sid, kode = _soal.cari_soal(s, soal)
119
+ except KeyError:
120
+ _kartu("salah", f"Soal \"{soal}\" tidak ada di Sesi {s}",
121
+ bagian=[("Soal yang tersedia:", ", ".join(_soal.semua_soal(s)))],
122
+ catatan="Perhatikan penulisan, contoh: `dq.cek(\"2.1\")`, `dq.cek(\"Capstone A\")`.")
123
+ return
124
+
125
+ ns = _inspect.currentframe().f_back.f_globals
126
+ hasil = _analisis.jalankan(kode, ns, f"sesi{s} {sid}")
127
+
128
+ rec = {
129
+ "waktu": _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
130
+ "id_peserta": _peserta["id_peserta"],
131
+ "nama": _peserta["nama"],
132
+ "sesi": s,
133
+ "soal": sid,
134
+ "status": "BENAR" if hasil.benar else "SALAH",
135
+ "pesan": hasil.ringkas,
136
+ "uid": _uuid.uuid4().hex,
137
+ "versi": __version__,
138
+ }
139
+ simpan = _storage.simpan(rec)
140
+ ke = simpan["percobaan_ke"]
141
+ sub = f"Sesi {s} · Soal {sid} · {_peserta['nama']} ({_peserta['id_peserta']})" + (f" · percobaan ke-{ke}" if ke else "")
142
+
143
+ if simpan["tempat"] == "server":
144
+ catatan = "Tersimpan di server instruktur ✓"
145
+ elif simpan["tempat"] == "lokal":
146
+ catatan = f"Tersimpan lokal di `{_storage.config.LOCAL_CSV}` (mode lokal)."
147
+ elif simpan["tempat"] == "antrean":
148
+ catatan = ("⚠️ Server tidak terjangkau — hasil disimpan di antrean dan akan dikirim otomatis "
149
+ "saat kamu mengecek lagi (atau jalankan `dq.kirim_ulang()`).")
150
+ else:
151
+ catatan = f"⚠️ Hasil TIDAK tersimpan: {simpan['error']}"
152
+
153
+ if hasil.benar:
154
+ _kartu("benar", "BENAR! Jawabanmu lolos semua pengecekan", sub=sub,
155
+ bagian=[("Output", hasil.output)] if hasil.output.strip() else [], catatan=catatan)
156
+ else:
157
+ bagian = [
158
+ ("Pengecekan yang gagal", f"{hasil.masalah}" if hasil.masalah else ""),
159
+ ("Apa yang belum tepat", hasil.detail),
160
+ ("Nilai variabelmu saat ini", hasil.konteks),
161
+ ("Cara memperbaiki", hasil.saran + [f"Setelah diperbaiki, jalankan ulang sel jawabanmu lalu `dq.cek(\"{sid}\")` lagi."]),
162
+ ]
163
+ _kartu("salah", "Belum tepat — ayo coba lagi!", sub=sub, bagian=bagian, catatan=catatan)
164
+
165
+
166
+ def kirim_ulang():
167
+ """Kirim ulang hasil yang tertunda karena koneksi putus."""
168
+ sisa = _storage.kirim_antrean()
169
+ if sisa:
170
+ _kartu("peringatan", f"Masih ada {sisa} hasil tertunda", sub="Cek koneksi internet lalu coba lagi.")
171
+ else:
172
+ _kartu("benar", "Tidak ada hasil tertunda — semua sudah terkirim.")
173
+
174
+
175
+ # ------------------------------------------------------------------- status
176
+
177
+ def _rekap(rows):
178
+ """Kelompokkan riwayat per (sesi, soal)."""
179
+ rows = sorted(rows, key=lambda r: r["waktu"]) # stabil: urutan asli dipertahankan bila waktu sama
180
+ rek = {}
181
+ for r in rows:
182
+ k = (int(r["sesi"]), r["soal"])
183
+ d = rek.setdefault(k, {"percobaan": 0, "benar_di": None, "terakhir": None, "waktu": None})
184
+ d["percobaan"] += 1
185
+ d["terakhir"] = r["status"]
186
+ d["waktu"] = r["waktu"]
187
+ if r["status"] == "BENAR" and d["benar_di"] is None:
188
+ d["benar_di"] = d["percobaan"]
189
+ return rek
190
+
191
+
192
+ def _ambil(id_peserta, sesi=None):
193
+ try:
194
+ return _storage.ambil_riwayat(id_peserta, sesi)
195
+ except _storage.ServerError as e:
196
+ _kartu("salah", "Gagal mengambil status dari server", sub=str(e))
197
+ return None
198
+
199
+
200
+ def status(id_peserta=None, sesi=None, kembalikan=False):
201
+ """Rekap jawaban (benar/salah + jumlah percobaan) milik peserta.
202
+
203
+ ``dq.status()`` untuk peserta yang sedang login, atau ``dq.status("DQ-001")``
204
+ untuk mengecek ID tertentu tanpa login. Tambahkan ``sesi=6`` untuk satu sesi.
205
+ """
206
+ id_peserta = id_peserta or _peserta.get("id_peserta")
207
+ if not id_peserta:
208
+ _kartu("peringatan", "Masukkan ID peserta", sub='Contoh: dq.status("DQ-001") atau login dulu dengan dq.mulai(...)')
209
+ return None
210
+ rows = _ambil(id_peserta, sesi)
211
+ if rows is None:
212
+ return None
213
+ if not rows:
214
+ _kartu("info", f"Belum ada jawaban tercatat untuk ID `{id_peserta}`" + (f" di Sesi {sesi}" if sesi else ""),
215
+ sub="Pastikan ID-nya benar. Mulai mengecek dengan dq.cek(\"nomor_soal\").")
216
+ return None
217
+
218
+ rek = _rekap(rows)
219
+ nama = sorted(rows, key=lambda r: r["waktu"])[-1]["nama"]
220
+ ringkas = []
221
+ for s in sorted({k[0] for k in rek}):
222
+ total = len(_soal.semua_soal(s)) if s in _soal.daftar_sesi() else "?"
223
+ benar = sum(1 for (ss, _), d in rek.items() if ss == s and d["benar_di"])
224
+ ringkas.append(f"Sesi {s}: {benar}/{total} soal benar")
225
+ _kartu("info", f"Status jawaban {nama} ({id_peserta})", bagian=[("Ringkasan", ringkas)],
226
+ catatan="✅ = sudah pernah benar · ❌ = belum benar. Soal yang belum dikerjakan: dq.daftar_soal()")
227
+
228
+ data = []
229
+ for (s, sid), d in sorted(rek.items(), key=lambda kv: (kv[0][0], _soal.urutan(kv[0][0], kv[0][1]))):
230
+ data.append({
231
+ "Sesi": s, "Soal": sid,
232
+ "Status": "✅ Benar" if d["benar_di"] else "❌ Salah",
233
+ "Jumlah percobaan": d["percobaan"],
234
+ "Benar di percobaan ke-": d["benar_di"] or "-",
235
+ "Hasil terakhir": d["terakhir"],
236
+ "Terakhir dicek (UTC)": d["waktu"].replace("T", " ").replace("Z", ""),
237
+ })
238
+ df = _tabel(data, list(data[0].keys()))
239
+ return df if kembalikan else None
240
+
241
+
242
+ def daftar_soal(sesi=None, hanya_belum=False, kembalikan=False):
243
+ """Daftar soal satu sesi beserta statusmu: ✅ benar, ❌ belum benar, ⬜ belum dikerjakan."""
244
+ s = _tentukan_sesi(sesi)
245
+ if s is None:
246
+ return None
247
+ rek = {}
248
+ if _peserta:
249
+ rows = _ambil(_peserta["id_peserta"], s)
250
+ rek = _rekap(rows or [])
251
+
252
+ data = []
253
+ for sid in _soal.semua_soal(s):
254
+ d = rek.get((s, sid))
255
+ if d and d["benar_di"]:
256
+ st = "✅ Benar"
257
+ elif d:
258
+ st = f"❌ Belum benar ({d['percobaan']}x)"
259
+ else:
260
+ st = "⬜ Belum dikerjakan"
261
+ if hanya_belum and st.startswith("✅"):
262
+ continue
263
+ data.append({"Soal": sid, "Status": st, "Perintah cek": f'dq.cek("{sid}", sesi={s})'})
264
+
265
+ total = len(_soal.semua_soal(s))
266
+ benar = sum(1 for sid in _soal.semua_soal(s) if rek.get((s, sid), {}).get("benar_di"))
267
+ sub = (f"Progres: {benar}/{total} benar · sisa {total - benar} soal" if _peserta
268
+ else "Login dengan dq.mulai(...) untuk melihat statusmu per soal.")
269
+ _kartu("info", f"Sesi {s} — {_soal.judul_sesi(s)}", sub=sub)
270
+ if not data:
271
+ _kartu("benar", "Semua soal sesi ini sudah benar. Mantap! 🏆")
272
+ return None
273
+ df = _tabel(data, ["Soal", "Status", "Perintah cek"])
274
+ return df if kembalikan else None
275
+
276
+
277
+ # ------------------------------------------------------------------ bantuan
278
+
279
+ def bantuan():
280
+ """Tampilkan panduan singkat."""
281
+ _kartu("info", f"Panduan dqcheck v{__version__}", bagian=[
282
+ ("1. Isi identitas (wajib, sekali per notebook)",
283
+ ['`dq.mulai(nama="Budi Santoso", id_peserta="DQ-001", sesi=6)`']),
284
+ ("2. Kerjakan soal, jalankan selnya, lalu cek",
285
+ ['`dq.cek("2.1")` — cek soal 2.1 di sesi aktif',
286
+ '`dq.cek("Capstone A", sesi=7)` — cek soal di sesi lain',
287
+ '`dq.ganti_sesi(8)` — pindah sesi aktif']),
288
+ ("3. Pantau progres",
289
+ ['`dq.daftar_soal()` — semua soal sesi aktif + statusnya',
290
+ '`dq.daftar_soal(hanya_belum=True)` — hanya yang belum benar',
291
+ '`dq.status()` — rekap benar/salah & jumlah percobaan',
292
+ '`dq.status("DQ-001")` — cek status ID tertentu']),
293
+ ("Tips", ["Variabel/function harus bernama persis seperti di soal.",
294
+ "Jalankan ulang sel jawaban setiap kali kamu mengubah kode, baru cek lagi.",
295
+ "Setiap percobaan tercatat; yang dihitung adalah apakah kamu pernah benar."]),
296
+ ], catatan=f"Sesi tersedia: {_soal.daftar_sesi()} · Mode penyimpanan: {_storage.mode()}")
dqcheck/admin.py ADDED
@@ -0,0 +1,176 @@
1
+ import argparse
2
+ import glob
3
+ import io
4
+ import os
5
+ import urllib.request
6
+
7
+ from . import soal as _soal
8
+
9
+ KOLOM_WAJIB = {"waktu", "id_peserta", "nama", "sesi", "soal", "status"}
10
+
11
+
12
+ def _pd():
13
+ try:
14
+ import pandas as pd
15
+ return pd
16
+ except ImportError:
17
+ raise SystemExit("Modul admin butuh pandas: pip install pandas openpyxl") from None
18
+
19
+
20
+ def muat(sumber=None, endpoint=None, admin_key=None, sesi=None):
21
+ """Baca semua percobaan menjadi satu DataFrame.
22
+
23
+ sumber : folder (dicari rekursif file ``percobaan.csv`` / CSV dqcheck) atau satu file CSV
24
+ endpoint : URL Worker; butuh ``admin_key``
25
+ """
26
+ pd = _pd()
27
+ if endpoint:
28
+ url = endpoint.rstrip("/") + "/export" + (f"?sesi={int(sesi)}" if sesi else "")
29
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {admin_key}",
30
+ "User-Agent": "dqcheck-admin"})
31
+ with urllib.request.urlopen(req, timeout=60) as r:
32
+ df = pd.read_csv(io.StringIO(r.read().decode("utf-8")), dtype={"id_peserta": str, "soal": str})
33
+ elif sumber and os.path.isdir(sumber):
34
+ files = sorted(set(glob.glob(os.path.join(sumber, "**", "percobaan.csv"), recursive=True)
35
+ + glob.glob(os.path.join(sumber, "**", "jawaban_dqcheck*.csv"), recursive=True)))
36
+ if not files:
37
+ raise SystemExit(f"Tidak ada file percobaan.csv di {sumber}")
38
+ df = pd.concat([pd.read_csv(f, dtype={"id_peserta": str, "soal": str}) for f in files], ignore_index=True)
39
+ elif sumber:
40
+ df = pd.read_csv(sumber, dtype={"id_peserta": str, "soal": str})
41
+ else:
42
+ raise SystemExit("Isi salah satu: sumber=... atau endpoint=... + admin_key=...")
43
+
44
+ kurang = KOLOM_WAJIB - set(df.columns)
45
+ if kurang:
46
+ raise SystemExit(f"Kolom wajib tidak ada: {sorted(kurang)}")
47
+ if "uid" in df.columns:
48
+ df = df.drop_duplicates("uid")
49
+ df["sesi"] = df["sesi"].astype(int)
50
+ if sesi:
51
+ df = df[df["sesi"] == int(sesi)]
52
+ df = df.sort_values("waktu", kind="stable").reset_index(drop=True)
53
+ df["percobaan_ke"] = df.groupby(["id_peserta", "sesi", "soal"]).cumcount() + 1
54
+ return df
55
+
56
+
57
+ def _urut(sesi, soal):
58
+ try:
59
+ return _soal.urutan(sesi, soal)
60
+ except KeyError:
61
+ return 999
62
+
63
+
64
+ def rekap(df):
65
+ """Hasilkan dict DataFrame: ringkasan, detail, per_soal, matriks per sesi, dan log mentah."""
66
+ pd = _pd()
67
+ nama_terakhir = df.groupby("id_peserta")["nama"].last()
68
+
69
+ g = df.groupby(["id_peserta", "sesi", "soal"], sort=False)
70
+ detail = g.agg(
71
+ jumlah_percobaan=("status", "size"),
72
+ jumlah_benar=("status", lambda s: int((s == "BENAR").sum())),
73
+ hasil_terakhir=("status", "last"),
74
+ pertama=("waktu", "min"),
75
+ terakhir=("waktu", "max"),
76
+ ).reset_index()
77
+ benar_di = (df[df["status"] == "BENAR"].groupby(["id_peserta", "sesi", "soal"])["percobaan_ke"].min()
78
+ .rename("benar_di_percobaan"))
79
+ detail = detail.merge(benar_di, on=["id_peserta", "sesi", "soal"], how="left")
80
+ detail["status"] = detail["benar_di_percobaan"].notna().map({True: "BENAR", False: "SALAH"})
81
+ detail.insert(1, "nama", detail["id_peserta"].map(nama_terakhir))
82
+ detail["_u"] = [_urut(s, q) for s, q in zip(detail["sesi"], detail["soal"])]
83
+ detail = detail.sort_values(["sesi", "id_peserta", "_u"]).drop(columns="_u")
84
+ detail = detail[["id_peserta", "nama", "sesi", "soal", "status", "jumlah_percobaan",
85
+ "benar_di_percobaan", "jumlah_benar", "hasil_terakhir", "pertama", "terakhir"]]
86
+ detail["benar_di_percobaan"] = detail["benar_di_percobaan"].astype("Int64")
87
+
88
+ def _total(s):
89
+ return len(_soal.semua_soal(s)) if s in _soal.daftar_sesi() else None
90
+
91
+ ring = detail.groupby(["id_peserta", "sesi"]).agg(
92
+ dikerjakan=("soal", "size"),
93
+ benar=("status", lambda s: int((s == "BENAR").sum())),
94
+ total_percobaan=("jumlah_percobaan", "sum"),
95
+ rata2_percobaan_sampai_benar=("benar_di_percobaan", "mean"),
96
+ terakhir=("terakhir", "max"),
97
+ ).reset_index()
98
+ ring.insert(1, "nama", ring["id_peserta"].map(nama_terakhir))
99
+ ring["total_soal"] = ring["sesi"].map(_total)
100
+ ring["belum_benar"] = ring["dikerjakan"] - ring["benar"]
101
+ ring["belum_dikerjakan"] = ring["total_soal"] - ring["dikerjakan"]
102
+ ring["persen_benar"] = (100 * ring["benar"] / ring["total_soal"]).round(1)
103
+ ring["rata2_percobaan_sampai_benar"] = ring["rata2_percobaan_sampai_benar"].astype(float).round(2)
104
+ ring = ring[["id_peserta", "nama", "sesi", "total_soal", "benar", "belum_benar", "belum_dikerjakan",
105
+ "persen_benar", "total_percobaan", "rata2_percobaan_sampai_benar", "terakhir"]]
106
+ ring = ring.sort_values(["sesi", "persen_benar", "id_peserta"], ascending=[True, False, True])
107
+
108
+ per_soal = detail.groupby(["sesi", "soal"]).agg(
109
+ peserta_mencoba=("id_peserta", "nunique"),
110
+ peserta_benar=("status", lambda s: int((s == "BENAR").sum())),
111
+ total_percobaan=("jumlah_percobaan", "sum"),
112
+ rata2_percobaan_sampai_benar=("benar_di_percobaan", "mean"),
113
+ ).reset_index()
114
+ per_soal["persen_benar"] = (100 * per_soal["peserta_benar"] / per_soal["peserta_mencoba"]).round(1)
115
+ per_soal["rata2_percobaan_sampai_benar"] = per_soal["rata2_percobaan_sampai_benar"].astype(float).round(2)
116
+ per_soal["_u"] = [_urut(s, q) for s, q in zip(per_soal["sesi"], per_soal["soal"])]
117
+ per_soal = per_soal.sort_values(["sesi", "_u"]).drop(columns="_u")
118
+
119
+ hasil = {"ringkasan": ring, "detail": detail, "per_soal": per_soal}
120
+
121
+ # matriks per sesi: baris = peserta, kolom = soal, isi = ✔/✘ (jumlah percobaan)
122
+ for s in sorted(detail["sesi"].unique()):
123
+ d = detail[detail["sesi"] == s].copy()
124
+ d["sel"] = [("✔" if st == "BENAR" else "✘") + f" ({n})"
125
+ for st, n in zip(d["status"], d["jumlah_percobaan"])]
126
+ m = d.pivot_table(index=["id_peserta", "nama"], columns="soal", values="sel", aggfunc="first")
127
+ urutan_kolom = list(_soal.semua_soal(s)) if s in _soal.daftar_sesi() else sorted(m.columns)
128
+ m = m.reindex(columns=[c for c in urutan_kolom] + [c for c in m.columns if c not in urutan_kolom])
129
+ m = m.fillna("").reset_index()
130
+ m.columns.name = None
131
+ hasil[f"sesi-{int(s):02d}"] = m
132
+
133
+ hasil["log_percobaan"] = df
134
+ return hasil
135
+
136
+
137
+ def ekspor(hasil, keluar="rekap_dqcheck.xlsx"):
138
+ """Simpan hasil ``rekap`` ke Excel (satu sheet per tabel) atau folder CSV."""
139
+ if keluar.lower().endswith(".xlsx"):
140
+ pd = _pd()
141
+ try:
142
+ with pd.ExcelWriter(keluar) as w:
143
+ for nama, tabel in hasil.items():
144
+ tabel.to_excel(w, sheet_name=nama[:31], index=False)
145
+ return keluar
146
+ except ImportError:
147
+ print("openpyxl tidak ada, beralih ke CSV. (pip install openpyxl untuk Excel)")
148
+ keluar = keluar[:-5]
149
+ os.makedirs(keluar, exist_ok=True)
150
+ for nama, tabel in hasil.items():
151
+ tabel.to_csv(os.path.join(keluar, f"{nama}.csv"), index=False)
152
+ return keluar
153
+
154
+
155
+ def main(argv=None):
156
+ p = argparse.ArgumentParser(prog="python -m dqcheck.admin",
157
+ description="Rekap & export jawaban peserta dqcheck.")
158
+ p.add_argument("--sumber", help="folder data hasil sinkron GitHub, atau satu file CSV")
159
+ p.add_argument("--endpoint", help="URL Worker (ambil langsung dari server)")
160
+ p.add_argument("--admin-key", default=os.environ.get("DQCHECK_ADMIN_KEY"),
161
+ help="ADMIN_KEY Worker (atau env DQCHECK_ADMIN_KEY)")
162
+ p.add_argument("--sesi", type=int, help="hanya sesi tertentu")
163
+ p.add_argument("--keluar", default="rekap_dqcheck.xlsx", help="file .xlsx atau nama folder CSV")
164
+ a = p.parse_args(argv)
165
+
166
+ df = muat(a.sumber, a.endpoint, a.admin_key, a.sesi)
167
+ hasil = rekap(df)
168
+ lokasi = ekspor(hasil, a.keluar)
169
+ r = hasil["ringkasan"]
170
+ print(f"{len(df)} percobaan dari {df['id_peserta'].nunique()} peserta, sesi {sorted(int(s) for s in df['sesi'].unique())}")
171
+ print(r.to_string(index=False, max_rows=30))
172
+ print(f"\nTersimpan: {lokasi}")
173
+
174
+
175
+ if __name__ == "__main__":
176
+ main()