From 9aef612f0127d20a2d59b0a26d72990a0fe5632f Mon Sep 17 00:00:00 2001 From: a2nr Date: Mon, 17 Aug 2026 07:02:35 +0700 Subject: [PATCH] feat(quiz/progress): pindah navigator ke quiz view, tampilkan guru di /progress, tambah 1 siswa, fix breakdown eval/diag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Task 1: pindahkan .quiz-navigator & .quiz-controls dari QuizTab ke QuizQuestionView; refactor props jadi mgr: LessonManager; CSS ke lesson.css. Navigator+kontrol kuis selalu terlihat di mobile walau workspace hidden. - Task 2: get_all_students_progress tidak filter role guru; field role per row; badge 'Guru', bulk-action & empty-state & ringkasan hanya hitung siswa. - Task 3: endpoint POST /students/add (reuse StudentRoundTripRow + find_student_import_conflicts + run_student_import); validate_single_student_input; AddStudentDialog + addStudent(); import/export/bulk-delete CSV lama tetap ada. - Fix (Opsi A): breakdown eval/diag hanya hitung MCQ (flashcard netral) — selaraskan dengan skor resmi FE; perbaiki tampilan eval:4/6 yg membingungkan. Tambah field type ke QuizAnswerPayload. Test: backend 239 passed (termasuk test baru teacher-in-report, add-student, breakdown-excludes-flashcards); svelte-check 0 error; build sukses. version.ts 27->28. --- .../lib/components/AddStudentDialog.svelte | 254 ++++++++++++++++++ frontend/src/lib/services/api.ts | 2 + .../src/lib/services/studentManagement.ts | 22 ++ frontend/src/lib/version.ts | 2 +- .../src/routes/lesson/[slug]/+page.svelte | 9 +- .../lesson/[slug]/QuizQuestionView.svelte | 89 +++++- .../src/routes/lesson/[slug]/QuizTab.svelte | 62 +---- frontend/src/routes/lesson/[slug]/lesson.css | 55 ++++ .../src/routes/lesson/[slug]/lesson.svelte.ts | 3 +- frontend/src/routes/progress/+page.svelte | 72 ++++- routes/student_management.py | 85 ++++++ services/storage/postgres_backend.py | 198 +++++++------- services/student_roundtrip.py | 17 ++ services/tests/test_progress_routes.py | 121 +++++++-- .../tests/test_student_management_routes.py | 65 +++++ 15 files changed, 850 insertions(+), 206 deletions(-) create mode 100644 frontend/src/lib/components/AddStudentDialog.svelte diff --git a/frontend/src/lib/components/AddStudentDialog.svelte b/frontend/src/lib/components/AddStudentDialog.svelte new file mode 100644 index 0000000..b3ab78f --- /dev/null +++ b/frontend/src/lib/components/AddStudentDialog.svelte @@ -0,0 +1,254 @@ + + +{#if open} + +{/if} + + diff --git a/frontend/src/lib/services/api.ts b/frontend/src/lib/services/api.ts index 56684b8..df73a8f 100644 --- a/frontend/src/lib/services/api.ts +++ b/frontend/src/lib/services/api.ts @@ -164,6 +164,8 @@ export interface QuizAnswerPayload { selected_option_id: string | null; is_correct: boolean; category: 'evaluasi' | 'diagnostik'; + /** 'mcq' | 'flashcard' — breakdown eval/diag hanya dihitung untuk MCQ. */ + type: 'mcq' | 'flashcard'; } export interface QuizAttemptSubmitResponse { diff --git a/frontend/src/lib/services/studentManagement.ts b/frontend/src/lib/services/studentManagement.ts index 248389d..3d23dfa 100644 --- a/frontend/src/lib/services/studentManagement.ts +++ b/frontend/src/lib/services/studentManagement.ts @@ -48,6 +48,14 @@ export interface BulkDeleteResponse { message?: string; } +export interface AddStudentResponse { + success: boolean; + student_id?: string; + nama_siswa?: string; + message?: string; + errors?: string[]; +} + export interface ExportResult { blob: Blob; filename: string; @@ -132,6 +140,20 @@ export async function bulkDeleteStudents( return (await parseResponse(res)) as BulkDeleteResponse; } +/** Tambah satu siswa langsung (nama + token). */ +export async function addStudent( + namaSiswa: string, + token: string, + customFetch: typeof fetch = fetch +): Promise { + const res = await customFetch('/api/students/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nama_siswa: namaSiswa, token }) + }); + return (await parseResponse(res)) as AddStudentResponse; +} + /** * Parsing respons API: error terstruktur backend (success:false + message/errors) * diteruskan apa adanya, sedangkan respons non-JSON (403/404/502 proxy, halaman diff --git a/frontend/src/lib/version.ts b/frontend/src/lib/version.ts index 75093da..5976710 100644 --- a/frontend/src/lib/version.ts +++ b/frontend/src/lib/version.ts @@ -3,4 +3,4 @@ * Both Footer.svelte and service-worker.ts import from here. * Bump this number after every feature/bugfix release. */ -export const APP_VERSION = 27; +export const APP_VERSION = 28; diff --git a/frontend/src/routes/lesson/[slug]/+page.svelte b/frontend/src/routes/lesson/[slug]/+page.svelte index bd7a518..279b568 100644 --- a/frontend/src/routes/lesson/[slug]/+page.svelte +++ b/frontend/src/routes/lesson/[slug]/+page.svelte @@ -240,14 +240,7 @@ import QuizQuestionView from './QuizQuestionView.svelte'; {#if mgr.isQuizMode}
- { - mgr.submitQuiz(); - }} - /> +
{:else}
{@html mgr.data?.lesson_content ?? ''}
diff --git a/frontend/src/routes/lesson/[slug]/QuizQuestionView.svelte b/frontend/src/routes/lesson/[slug]/QuizQuestionView.svelte index f11d281..c57e49b 100644 --- a/frontend/src/routes/lesson/[slug]/QuizQuestionView.svelte +++ b/frontend/src/routes/lesson/[slug]/QuizQuestionView.svelte @@ -1,32 +1,97 @@
- Soal {currentIndex + 1} dari {totalCount} - + {#if questions.length > 0} +
+ {#each questions as q, i} + {@const answered = isAnswered(q.id)} + + {/each} +
+ {/if} +
- {#if question} + {#if mgr.currentQuizQuestion}
- {#if question.image} - Ilustrasi soal + {#if mgr.currentQuizQuestion.image} + Ilustrasi soal {/if} - {@html question.question ?? question.front ?? ''} + {@html mgr.currentQuizQuestion.question ?? mgr.currentQuizQuestion.front ?? ''}
{:else}

Tidak ada soal.

{/if} + + {#if questions.length > 0} +
+ + {#if mgr.quizCurrentIndex < questions.length - 1} + + {:else} + + {/if} +
+ {/if}
diff --git a/frontend/src/routes/lesson/[slug]/QuizTab.svelte b/frontend/src/routes/lesson/[slug]/QuizTab.svelte index 8126ee3..c8ddb7f 100644 --- a/frontend/src/routes/lesson/[slug]/QuizTab.svelte +++ b/frontend/src/routes/lesson/[slug]/QuizTab.svelte @@ -89,14 +89,6 @@ mgr.submitQuiz(); } } - - function handleFinish() { - if (!mgr.quizAllAnswered) return; - - if (window.confirm('Apakah anda yakin untuk menyelesaikan kuis? Materi akan muncul kembali setelah ini.')) { - mgr.finishQuiz(); - } - }
@@ -328,43 +320,6 @@ {/if}
-
- {#each questions as q, i} - {@const answered = mgr.quizSession!.answers[q.id].selectedOptionId !== null || mgr.quizSession!.answers[q.id].acknowledged} - - {/each} -
- -
- - {#if mgr.quizCurrentIndex < questions.length - 1} - - {:else} - - {/if} -
-
⚠️ Keluar = selesai. Soal belum dijawab dianggap salah. @@ -473,29 +428,14 @@ color: white; } - .quiz-navigator { display: flex; flex-wrap: wrap; gap: 0.4rem; } - .nav-dot { width: 34px; height: 34px; border-radius: 8px; border: 1px solid var(--color-border); background: var(--color-bg); color: var(--color-text); font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.15s; } - .nav-dot:hover { border-color: var(--color-primary); } - .nav-dot.answered { background: rgba(51, 154, 240, 0.15); border-color: var(--color-primary); color: var(--color-primary); } - .nav-dot.active { background: var(--color-primary); border-color: var(--color-primary); color: white; } - - .quiz-controls { - display: flex; - justify-content: space-between; - gap: 1rem; - position: relative; - z-index: 10; - background: var(--color-bg); - padding-top: 1rem; - } .btn { padding: 0.6rem 1.2rem; border-radius: 8px; font-weight: 600; cursor: pointer; border: none; transition: opacity 0.2s; } .btn:disabled { opacity: 0.5; cursor: not-allowed; } .btn-primary { background: #339af0; color: white; } .btn-success { background: #40c057; color: white; } - .btn-outline { background: white; border: 1px solid var(--color-border); color: var(--color-text); } .btn-lg { padding: 1rem 2rem; font-size: 1.1rem; } .btn-exit-quiz { background: none; border: none; color: var(--color-danger, #dc3545); text-decoration: underline; font-size: 0.85rem; cursor: pointer; } + .btn-exit-quiz:hover { color: #c82333; } .cancel-container { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; } .cancel-lock-hint { font-size: 0.75rem; color: var(--color-danger, #dc3545); font-weight: 500; } diff --git a/frontend/src/routes/lesson/[slug]/lesson.css b/frontend/src/routes/lesson/[slug]/lesson.css index 3ae0907..5b665b6 100644 --- a/frontend/src/routes/lesson/[slug]/lesson.css +++ b/frontend/src/routes/lesson/[slug]/lesson.css @@ -500,3 +500,58 @@ margin-bottom: 1rem; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); } + +/* ── Quiz navigator & controls (dipindah dari QuizTab.svelte) ── */ +.quiz-navigator { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.nav-dot { + width: 34px; + height: 34px; + border-radius: 8px; + border: 1px solid var(--color-border); + background: var(--color-bg); + color: var(--color-text); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; +} +.nav-dot:hover { + border-color: var(--color-primary); +} +.nav-dot.answered { + background: rgba(51, 154, 240, 0.15); + border-color: var(--color-primary); + color: var(--color-primary); +} +.nav-dot.active { + background: var(--color-primary); + border-color: var(--color-primary); + color: white; +} + +.quiz-controls { + display: flex; + justify-content: space-between; + gap: 1rem; + position: relative; + z-index: 10; + background: var(--color-bg); + padding-top: 1rem; +} + +.btn-outline { + background: white; + border: 1px solid var(--color-border); + color: var(--color-text); +} + +@media (max-width: 600px) { + .quiz-question-view { + padding: 1rem 0; + } +} diff --git a/frontend/src/routes/lesson/[slug]/lesson.svelte.ts b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts index 063d734..59bb850 100644 --- a/frontend/src/routes/lesson/[slug]/lesson.svelte.ts +++ b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts @@ -435,7 +435,8 @@ export class LessonManager { question_id: q.id, selected_option_id: a.selectedOptionId, is_correct: a.isCorrect, - category: (q.category === 'diagnostik' ? 'diagnostik' : 'evaluasi') as 'evaluasi' | 'diagnostik' + category: (q.category === 'diagnostik' ? 'diagnostik' : 'evaluasi') as 'evaluasi' | 'diagnostik', + type: (q.type === 'flashcard' ? 'flashcard' : 'mcq') as 'mcq' | 'flashcard' }; }) }; diff --git a/frontend/src/routes/progress/+page.svelte b/frontend/src/routes/progress/+page.svelte index f6ae566..9a22a58 100644 --- a/frontend/src/routes/progress/+page.svelte +++ b/frontend/src/routes/progress/+page.svelte @@ -5,6 +5,7 @@ import { exportStudentsCsv, triggerBlobDownload } from '$services/studentManagement'; import StudentImportDialog from '$lib/components/StudentImportDialog.svelte'; import BulkDeleteStudentsDialog from '$lib/components/BulkDeleteStudentsDialog.svelte'; + import AddStudentDialog from '$lib/components/AddStudentDialog.svelte'; interface LessonHeader { filename: string; @@ -14,6 +15,7 @@ interface StudentProgress { id: string; nama_siswa: string; + role: 'teacher' | 'student'; completed_count: number; [key: string]: string | number | boolean; } @@ -36,6 +38,7 @@ let exportError = $state(''); let importOpen = $state(false); let deleteOpen = $state(false); + let addOpen = $state(false); let headerCheckbox = $state(); // Reactively load data when auth is ready @@ -57,7 +60,10 @@ const data = await res.json(); students = data.students ?? []; lessons = data.lessons ?? []; - studentSelection.setAvailable(students.map((s) => s.id)); + // Hanya siswa yang bisa di-select untuk bulk export/delete (guru hanya ditampilkan) + studentSelection.setAvailable( + students.filter((s) => s.role === 'student').map((s) => s.id) + ); } catch { // API not available } finally { @@ -117,6 +123,12 @@ loadData(); } + function handleAdded() { + addOpen = false; + studentSelection.clear(); + loadData(); + } + function handleHeaderCheckbox() { if (get(studentSelection.allSelected)) { studentSelection.clear(); @@ -150,6 +162,8 @@ const selectedStudents = $derived(students.filter((s) => selectedIds.includes(s.id))); const totalLessons = $derived(lessons.length); + // Hanya siswa yang dihitung untuk ringkasan & empty-state (guru hanya ditampilkan) + const studentRows = $derived(students.filter((s) => s.role === 'student')); @@ -164,7 +178,7 @@

Anda tidak memiliki akses ke halaman ini.

{:else}
- {students.length} siswa + {studentRows.length} siswa {totalLessons} pelajaran {#if selectionCount > 0} @@ -193,6 +207,14 @@ {/if} + +
- {#if students.length === 0} + {#if studentRows.length === 0}

Belum ada data siswa. Gunakan Import CSV untuk menambah siswa pertama.

{:else}
@@ -242,16 +264,26 @@ {#each students as student} {@const isChecked = selectedIds.includes(student.id as string)} + {@const isTeacher = student.role === 'teacher'} - studentSelection.toggle(student.id as string)} - aria-label={`Pilih ${student.nama_siswa}`} - /> + {#if isTeacher} + — + {:else} + studentSelection.toggle(student.id as string)} + aria-label={`Pilih ${student.nama_siswa}`} + /> + {/if} + + + {student.nama_siswa} + {#if isTeacher} + Guru + {/if} - {student.nama_siswa} {#each lessons as lesson} {@const key = lesson.filename.replace('.md', '')} {@const status = student[key]} @@ -322,6 +354,11 @@ onClose={() => (importOpen = false)} onImported={handleImported} /> + (addOpen = false)} + onAdded={handleAdded} + /> ({ id: s.id as string, nama_siswa: s.nama_siswa }))} @@ -447,6 +484,21 @@ font-weight: 500; min-width: 150px; } + .badge.teacher-badge { + margin-left: 0.4rem; + color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 10%, var(--color-bg)); + border: 1px solid color-mix(in srgb, var(--color-primary) 40%, var(--color-bg)); + border-radius: 999px; + padding: 0.1rem 0.45rem; + font-size: 0.65rem; + font-weight: 700; + white-space: nowrap; + } + .teacher-no-select { + color: var(--color-text-muted); + font-size: 0.9rem; + } .status-cell { min-width: 40px; } diff --git a/routes/student_management.py b/routes/student_management.py index 68949b9..adbc866 100644 --- a/routes/student_management.py +++ b/routes/student_management.py @@ -27,8 +27,10 @@ from services.database import SessionLocal from services.student_roundtrip import ( MAX_FILE_BYTES, RoundTripImportError, + StudentRoundTripRow, mask_student_id, parse_roundtrip_csv, + validate_single_student_input, ) from services.token_service import validate_token @@ -263,3 +265,86 @@ def bulk_delete(): "deleted_ids": deleted_ids, } ) + + +@student_management_bp.route("/students/add", methods=["POST"]) +def add_student(): + """Tambah satu siswa langsung dari form (nama + token). + + Memakai kembali jalur validasi & create-user CSV round-trip + (StudentRoundTripRow + find_student_import_conflicts + run_student_import) + agar aturan validasi & proteksi race condition identik dengan import CSV. + """ + if not _check_origin(): + return jsonify({"success": False, "message": "Origin tidak diizinkan"}), 403 + teacher = _teacher_from_cookie() + if teacher is None: + return jsonify({"success": False, "message": "Unauthorized (Teacher only)"}), 401 + if SessionLocal is None: + return jsonify({"success": False, "message": "PostgreSQL tidak aktif"}), 503 + + try: + data = request.get_json(silent=True, force=True) or {} + nama_siswa = data.get("nama_siswa", "") + token = data.get("token", "") + except Exception: # noqa: BLE001 + return jsonify({"success": False, "message": "Body JSON tidak valid"}), 400 + + # Validasi input (kontrak sama dengan baris CSV) — TIDAK bocorkan token. + errors = validate_single_student_input(nama_siswa, token) + if errors: + return jsonify({"success": False, "message": "Validasi gagal", "errors": errors}), 400 + + row = StudentRoundTripRow( + line=1, + student_id=None, # None = siswa baru + raw_token=token, + display_name=nama_siswa.strip(), + progress={}, + ) + + db = SessionLocal() + try: + conflicts = repositories.find_student_import_conflicts(db, [row]) + if conflicts: + # conflicts[0] adalah pesan tanpa token (sudah aman). + return ( + jsonify( + { + "success": False, + "message": "Token sudah terdaftar atau benturan dengan data existing", + "errors": conflicts, + } + ), + 409, + ) + result = repositories.run_student_import(db, [row]) + except RoundTripImportError as exc: + db.rollback() + return ( + jsonify( + { + "success": False, + "message": "Penambahan ditolak — data bertentangan dengan database", + "errors": exc.errors, + } + ), + 409, + ) + except Exception: # noqa: BLE001 — jangan bocorkan exception internal + db.rollback() + logger.exception("Add student gagal") + return jsonify({"success": False, "message": "Gagal menambahkan siswa"}), 500 + finally: + db.close() + + created = result.get("user_ids") or [] + student_id = created[0] if created else None + logger.info("Add student: student_id=%s nama=%s", mask_student_id(student_id), nama_siswa.strip()) + return jsonify( + { + "success": True, + "student_id": student_id, + "nama_siswa": nama_siswa.strip(), + } + ) diff --git a/services/storage/postgres_backend.py b/services/storage/postgres_backend.py index 2c37aa6..994654b 100644 --- a/services/storage/postgres_backend.py +++ b/services/storage/postgres_backend.py @@ -160,100 +160,110 @@ def reset_progress(student_id, lesson_name): def get_all_students_progress(all_lessons_func): - """Semua siswa (guru ikut, urutan created_at) + ordered_lessons dari registry. + """Semua user (siswa + guru) + ordered_lessons dari registry. - Student dict TIDAK mengandung token mentah (kontrak keamanan). - """ - if SessionLocal is None: - return [], [] - db = SessionLocal() - try: - lessons = list_lessons(db) - all_lessons_dict = {} - for lesson in all_lessons_func(): - lesson_key = lesson['filename'].replace('.md', '') - all_lessons_dict[lesson_key] = lesson + Guru ikut ditampilkan sebagai row di report /progress agar bisa di-review + materinya (dan di-reset progresnya sendiri). Field `role` menandai + apakah row tersebut guru atau siswa. Student dict TIDAK mengandung token + mentah (kontrak keamanan). + """ + if SessionLocal is None: + return [], [] + db = SessionLocal() + try: + lessons = list_lessons(db) + all_lessons_dict = {} + for lesson in all_lessons_func(): + lesson_key = lesson['filename'].replace('.md', '') + all_lessons_dict[lesson_key] = lesson - ordered_lessons = [] - for lesson in lessons: - slug = lesson.slug - if slug in all_lessons_dict: - ordered_lessons.append(all_lessons_dict[slug]) - else: - ordered_lessons.append({ - 'filename': f"{slug}.md", - 'title': lesson.title, - 'description': 'Lesson information not available', - }) + ordered_lessons = [] + for lesson in lessons: + slug = lesson.slug + if slug in all_lessons_dict: + ordered_lessons.append(all_lessons_dict[slug]) + else: + ordered_lessons.append({ + 'filename': f"{slug}.md", + 'title': lesson.title, + 'description': 'Lesson information not available', + }) + + # Semua user (tanpa filter role) — guru ikut sebagai row, urutan + # deterministik (created_at, id) agar stable untuk UI/export. + users = list( + db.scalars( + select(User) + .order_by(User.created_at, User.id) + ) + ) + students = [] + for user in users: + rows = {p.lesson_id: p for p in list_progress_for_user(db, user_id=user.id)} + attempts = { + a.lesson_id: a for a in list_quiz_attempts_for_user(db, user_id=user.id) + } + student = {'nama_siswa': user.display_name, 'id': user.id, 'role': user.role} + for lesson in lessons: + student[lesson.slug] = _status_to_string(rows.get(lesson.id)) + # Metadata anti-cheat — field TERPISAH, tidak mengubah kontrak + # status lama. `has_violation` hanya untuk reason focus_lost. + attempt = attempts.get(lesson.id) + student[f"{lesson.slug}_attempt_status"] = attempt.status if attempt else "" + student[f"{lesson.slug}_termination_reason"] = ( + attempt.termination_reason if attempt else "" + ) + student[f"{lesson.slug}_has_violation"] = bool( + attempt and attempt.termination_reason == "focus_lost" + ) + student[f"{lesson.slug}_attempt_finished_at"] = ( + attempt.finished_at.isoformat() if attempt and attempt.finished_at else "" + ) + # Breakdown kategori (evaluasi / diagnostik) dari answers_json attempt — + # untuk report guru. Sama seperti FE calculateQuizResult: breakdown + # HANYA dihitung untuk soal MCQ; flashcard netral (tidak masuk eval + # maupun diag) supaya penyebut eval = jumlah MCQ evaluasi, konsisten + # dengan skor resmi (statusString) yang juga cuma MCQ. + if attempt and attempt.answers_json: + try: + answers = json.loads(attempt.answers_json) + except (json.JSONDecodeError, TypeError): + answers = [] + eval_correct = 0 + eval_total = 0 + diag_correct = 0 + diag_total = 0 + for ans in answers: + # Flashcard netral: abaikan dari breakdown eval/diag. + if ans.get('type') == 'flashcard': + continue + cat = ans.get('category', 'evaluasi') + if cat == 'diagnostik': + diag_total += 1 + if ans.get('is_correct'): + diag_correct += 1 + else: + eval_total += 1 + if ans.get('is_correct'): + eval_correct += 1 + student[f"{lesson.slug}_eval"] = f"{eval_correct}/{eval_total}" + student[f"{lesson.slug}_diag"] = f"{diag_correct}/{diag_total}" + student[f"{lesson.slug}_diag_unmastered"] = json.dumps( + [ + a.get('question_id', '') + for a in answers + if a.get('type') != 'flashcard' + and a.get('category') == 'diagnostik' + and not a.get('is_correct') + ] + ) + else: + student[f"{lesson.slug}_eval"] = "" + student[f"{lesson.slug}_diag"] = "" + student[f"{lesson.slug}_diag_unmastered"] = "[]" + student['completed_count'] = count_completed_lessons(db, user_id=user.id) + students.append(student) + return students, ordered_lessons + finally: + db.close() - # Normalisasi: report hanya siswa (teacher tidak pernah muncul sebagai row), - # urutan deterministik (created_at, id) agar stable untuk UI/export. - users = list( - db.scalars( - select(User) - .where(User.role == "student") - .order_by(User.created_at, User.id) - ) - ) - students = [] - for user in users: - rows = {p.lesson_id: p for p in list_progress_for_user(db, user_id=user.id)} - attempts = { - a.lesson_id: a for a in list_quiz_attempts_for_user(db, user_id=user.id) - } - student = {'nama_siswa': user.display_name, 'id': user.id} - for lesson in lessons: - student[lesson.slug] = _status_to_string(rows.get(lesson.id)) - # Metadata anti-cheat — field TERPISAH, tidak mengubah kontrak - # status lama. `has_violation` hanya untuk reason focus_lost. - attempt = attempts.get(lesson.id) - student[f"{lesson.slug}_attempt_status"] = attempt.status if attempt else "" - student[f"{lesson.slug}_termination_reason"] = ( - attempt.termination_reason if attempt else "" - ) - student[f"{lesson.slug}_has_violation"] = bool( - attempt and attempt.termination_reason == "focus_lost" - ) - student[f"{lesson.slug}_attempt_finished_at"] = ( - attempt.finished_at.isoformat() if attempt and attempt.finished_at else "" - ) - # Breakdown kategori (evaluasi / diagnostik) dari answers_json attempt — - # untuk report guru. `eval_correct/eval_total` = skor resmi; - # `diag_correct/diag_total` = info diagnostik. - if attempt and attempt.answers_json: - try: - answers = json.loads(attempt.answers_json) - except (json.JSONDecodeError, TypeError): - answers = [] - eval_correct = 0 - eval_total = 0 - diag_correct = 0 - diag_total = 0 - for ans in answers: - cat = ans.get('category', 'evaluasi') - if cat == 'diagnostik': - diag_total += 1 - if ans.get('is_correct'): - diag_correct += 1 - else: - eval_total += 1 - if ans.get('is_correct'): - eval_correct += 1 - student[f"{lesson.slug}_eval"] = f"{eval_correct}/{eval_total}" - student[f"{lesson.slug}_diag"] = f"{diag_correct}/{diag_total}" - student[f"{lesson.slug}_diag_unmastered"] = json.dumps( - [ - a.get('question_id', '') - for a in answers - if a.get('category') == 'diagnostik' and not a.get('is_correct') - ] - ) - else: - student[f"{lesson.slug}_eval"] = "" - student[f"{lesson.slug}_diag"] = "" - student[f"{lesson.slug}_diag_unmastered"] = "[]" - student['completed_count'] = count_completed_lessons(db, user_id=user.id) - students.append(student) - return students, ordered_lessons - finally: - db.close() diff --git a/services/student_roundtrip.py b/services/student_roundtrip.py index 8fe6b96..2daf9ff 100644 --- a/services/student_roundtrip.py +++ b/services/student_roundtrip.py @@ -88,6 +88,23 @@ def _token_is_valid(token: str) -> bool: return True +def validate_single_student_input(display_name: str, raw_token: str) -> list[str]: + """Validasi input tambah-1-siswa (dipakai endpoint /students/add). + + Aturan sama dengan validasi baris CSV round-trip (nama & token), supaya + kedua jalur (CSV & single-add) punya kontrak identik. + """ + errors: list[str] = [] + name = (display_name or "").strip() + if not name: + errors.append("Nama siswa wajib diisi") + elif len(name) > MAX_NAME_LENGTH: + errors.append(f"Nama siswa maksimal {MAX_NAME_LENGTH} karakter") + if not _token_is_valid(raw_token): + errors.append(f"Token harus {TOKEN_MIN_LENGTH}-{TOKEN_MAX_LENGTH} karakter tanpa karakter kontrol") + return errors + + def _detect_import_delimiter(text: str) -> str: """Pilih delimiter import (`;` atau `,`) dari header, berbasis schema. diff --git a/services/tests/test_progress_routes.py b/services/tests/test_progress_routes.py index 58cd7dd..bfdb3e9 100644 --- a/services/tests/test_progress_routes.py +++ b/services/tests/test_progress_routes.py @@ -56,10 +56,22 @@ def test_teacher_can_access_report(client): resp = client.get(f"/progress-report.json?token={TEACHER_TOKEN}") assert resp.status_code == 200 data = resp.get_json() - assert data["students"] + # Guru + 2 siswa harusmuncul sebagai row + assert len(data["students"]) == 3 assert data["lessons"] +def test_teacher_appears_in_progress_report(client): + resp = client.get(f"/progress-report.json?token={TEACHER_TOKEN}") + assert resp.status_code == 200 + data = resp.get_json() + assert len(data["students"]) == 3 # 1 guru + 2 siswa + roles = {row["nama_siswa"]: row["role"] for row in data["students"]} + assert roles["Pak Guru"] == "teacher" + assert roles["Budi Santoso"] == "student" + assert roles["Siti Aminah"] == "student" + + def test_report_contains_no_raw_access_token(client): """RED (sekarang): payload report masih menyertakan kolom token.""" resp = client.get(f"/progress-report.json?token={TEACHER_TOKEN}") @@ -119,22 +131,93 @@ def test_raw_token_not_in_logs(client, caplog): assert STUDENT_TOKEN not in caplog.text -def test_pg_report_student_only(client): - """Report PostgreSQL hanya memuat role student — teacher tidak pernah row. +def test_pg_report_includes_teacher_and_students(client): + """Report PostgreSQL memuat semua user: guru + siswa sebagai row terpisah. - Data (Pak Guru/Budi/Siti + lesson hello_world) berasal dari fixture - `seed_demo_users` — tidak di-seed ulang di sini karena token_hash - deterministik (HMAC+pepper) menabrak unique constraint. - """ - client.set_cookie("student_token", TEACHER_TOKEN) - resp = client.get("/progress-report.json") - assert resp.status_code == 200 - data = resp.get_json() - names = [st["nama_siswa"] for st in data["students"]] - assert "Pak Guru" not in names - assert set(names) == {"Budi Santoso", "Siti Aminah"} - # field yang dipertahankan untuk frontend - for st in data["students"]: - assert st["id"] - assert "completed_count" in st - assert "hello_world" in st + Data (Pak Guru/Budi/Siti + lesson hello_world) berasal dari fixture + `seed_demo_users` — tidak di-seed ulang di sini karena token_hash + deterministik (HMAC+pepper) menabrak unique constraint. + """ + client.set_cookie("student_token", TEACHER_TOKEN) + resp = client.get("/progress-report.json") + assert resp.status_code == 200 + data = resp.get_json() + names = [st["nama_siswa"] for st in data["students"]] + assert "Pak Guru" in names + assert "Budi Santoso" in names + assert "Siti Aminah" in names + # guru punya role teacher, siswa punya role student + roles = {st["nama_siswa"]: st["role"] for st in data["students"]} + assert roles["Pak Guru"] == "teacher" + assert roles["Budi Santoso"] == "student" + # field yang dipertahankan untuk frontend + for st in data["students"]: + assert st["id"] + assert "completed_count" in st + assert "hello_world" in st + + +def test_quiz_breakdown_excludes_flashcards(client): + """Breakdown eval/diag HANYA menghitung MCQ — flashcard netral. + + Regresi guard untuk bug `eval:4/6`: flashcard ber-kategori 'evaluasi' + tidak boleh menambah penyebut eval (yang seharusnya = jumlah MCQ evaluasi). + """ + from services import token_service as ts + + # Seed attempt untuk Budi: 2 MCQ evaluasi benar + 1 flashcard 'evaluasi'. + # Skor resmi (statusString) hanya MCQ → "2/2". Breakdown eval harus 2/2, + # bukan 2/3 (yang akan terjadi kalau flashcard ikut dihitung). + answers = [ + { + "question_id": "mcq-1", + "selected_option_id": "o-a", + "is_correct": True, + "category": "evaluasi", + "type": "mcq", + }, + { + "question_id": "mcq-2", + "selected_option_id": "o-b", + "is_correct": True, + "category": "evaluasi", + "type": "mcq", + }, + { + "question_id": "fc-1", + "selected_option_id": None, + "is_correct": False, + "category": "evaluasi", + "type": "flashcard", + }, + ] + import uuid + + ts.update_student_progress(STUDENT_TOKEN, "hello_world", "2/2") + resp = client.post( + "/quiz-attempts/submit", + json={ + "attempt_id": str(uuid.uuid4()), + "token": STUDENT_TOKEN, + "lesson_name": "hello_world", + "status": "submitted", + "termination_reason": None, + "score": "2/2", + "occurred_at": "2026-01-01T00:00:00Z", + "started_at": "2026-01-01T00:00:00Z", + "visibility_event_count": 0, + "answers": answers, + }, + ) + assert resp.status_code == 200 + + client.set_cookie("student_token", TEACHER_TOKEN) + report = client.get("/progress-report.json") + assert report.status_code == 200 + students = {st["nama_siswa"]: st for st in report.get_json()["students"]} + budi = students["Budi Santoso"] + assert budi["hello_world"] == "2/2" + assert budi["hello_world_eval"] == "2/2", ( + f"eval breakdown harus 2/2 (MCQ saja), flashcard netral — dapat {budi['hello_world_eval']!r}" + ) + assert budi["hello_world_diag"] == "0/0", "diag 0/0 (tidak ada MCQ diagnostik)" diff --git a/services/tests/test_student_management_routes.py b/services/tests/test_student_management_routes.py index ece17b7..1bd450e 100644 --- a/services/tests/test_student_management_routes.py +++ b/services/tests/test_student_management_routes.py @@ -615,3 +615,68 @@ def test_import_comma_delimiter_csv_apply_success(client): raw = resp.get_data(as_text=True) assert "LocustBot48" not in raw assert hash_token("LocustBot48") not in raw + + +# ── add single student (Task 3) ────────────────────────────────────── + + +def test_add_student_success(client): + _seed_teacher_and_students() + _login_teacher(client) + resp = client.post( + "/students/add", + json={"nama_siswa": "Andi Wijaya", "token": "TOKEN_ANDI_BARU_001"}, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["success"] is True + assert data["nama_siswa"] == "Andi Wijaya" + assert "student_id" in data + + +def test_added_student_can_authenticate(client): + _seed_teacher_and_students() + _login_teacher(client) + client.post( + "/students/add", + json={"nama_siswa": "Andi Wijaya", "token": "TOKEN_ANDI_BARU_001"}, + ) + resp = client.post("/validate-token", json={"token": "TOKEN_ANDI_BARU_001"}) + assert resp.status_code == 200 + assert resp.get_json().get("success") is True + + +def test_add_student_duplicate_token_rejected(client): + _seed_teacher_and_students() + _login_teacher(client) + resp = client.post( + "/students/add", + json={"nama_siswa": "Siswa Lain", "token": STUDENT_TOKEN}, # token milik Budi + ) + data = resp.get_json() + assert data["success"] is False + assert resp.status_code == 409 + + +def test_add_student_invalid_token_length_rejected(client): + _seed_teacher_and_students() + _login_teacher(client) + resp = client.post("/students/add", json={"nama_siswa": "X", "token": "pendek"}) + data = resp.get_json() + assert data["success"] is False + assert resp.status_code == 400 + + +def test_add_student_requires_teacher_auth(client): + resp = client.post("/students/add", json={"nama_siswa": "X", "token": "TOKEN_VALID_12345"}) + assert resp.status_code == 401 + + +def test_add_student_empty_name_rejected(client): + _seed_teacher_and_students() + _login_teacher(client) + resp = client.post("/students/add", json={"nama_siswa": " ", "token": "TOKEN_VALID_12345"}) + data = resp.get_json() + assert data["success"] is False + assert resp.status_code == 400 +