feat(quiz/progress): pindah navigator ke quiz view, tampilkan guru di /progress, tambah 1 siswa, fix breakdown eval/diag

- 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.
This commit is contained in:
a2nr 2026-08-17 07:02:35 +07:00
parent 2d5f100808
commit 9aef612f01
15 changed files with 850 additions and 206 deletions

View File

@ -0,0 +1,254 @@
<script lang="ts">
import { addStudent } from '$services/studentManagement';
interface Props {
open: boolean;
onClose: () => void;
onAdded: () => void;
}
let { open, onClose, onAdded }: Props = $props();
let namaSiswa = $state('');
let token = $state('');
let busy = $state(false);
let error = $state('');
let added = $state(false);
let addedName = $state('');
const canSubmit = $derived(namaSiswa.trim().length > 0 && token.trim().length >= 8 && !busy && !added);
async function handleSubmit() {
if (busy || added) return;
busy = true;
error = '';
try {
const res = await addStudent(namaSiswa.trim(), token);
if (res.success) {
added = true;
addedName = res.nama_siswa ?? namaSiswa.trim();
onAdded();
} else {
error = res.message ?? 'Gagal menambahkan siswa.';
if (res.errors?.length) {
error += '\n' + res.errors.slice(0, 5).join('\n');
}
}
} catch (e) {
error =
e instanceof Error && e.message
? e.message
: 'Terjadi kesalahan saat menambahkan siswa.';
} finally {
busy = false;
}
}
function close() {
if (busy) return;
onClose();
}
function reset() {
namaSiswa = '';
token = '';
error = '';
added = false;
addedName = '';
}
// Reset state setiap kali dialog dibuka ulang
$effect(() => {
if (open) reset();
});
</script>
{#if open}
<div class="overlay" role="dialog" aria-modal="true" aria-label="Tambah Siswa" onclick={close}>
<div class="dialog" onclick={(e) => e.stopPropagation()}>
<header class="dialog-header">
<h2>Tambah Siswa</h2>
<button class="btn-icon" onclick={close} title="Tutup" aria-label="Tutup">&times;</button>
</header>
<div class="dialog-body">
{#if added}
<p class="status success">Siswa <strong>{addedName}</strong> berhasil ditambahkan!</p>
{:else}
<p class="hint">
Isi nama siswa dan token unik. Token digunakan siswa untuk login — panjang
<strong>8–128 karakter</strong>, tanpa spasi/karakter kontrol.
</p>
<label class="field">
<span>Nama Siswa</span>
<input
type="text"
bind:value={namaSiswa}
placeholder="Mis. Andi Wijaya"
disabled={busy}
autocomplete="off"
/>
</label>
<label class="field">
<span>Token</span>
<input
type="text"
bind:value={token}
placeholder="Mis. TOKEN_ANDI_001"
disabled={busy}
autocomplete="off"
/>
<small class="field-hint">8–128 karakter, tanpa spasi/karakter kontrol.</small>
</label>
{#if error}
<p class="error">{error}</p>
{/if}
{/if}
</div>
<footer class="dialog-footer">
<button class="btn" onclick={close} disabled={busy}>Batal</button>
{#if added}
<button class="btn btn-primary" onclick={close}>Tutup</button>
{:else}
<button class="btn btn-primary" onclick={handleSubmit} disabled={!canSubmit}>
{busy ? 'Menyimpan…' : 'Tambah Siswa'}
</button>
{/if}
</footer>
</div>
</div>
{/if}
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.dialog {
background: var(--color-bg);
border-radius: var(--radius);
width: 100%;
max-width: 420px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
overflow: hidden;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.9rem 1.1rem;
border-bottom: 1px solid var(--color-border);
}
.dialog-header h2 {
margin: 0;
font-size: 1.1rem;
}
.btn-icon {
background: none;
border: none;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
color: var(--color-text-muted);
}
.btn-icon:hover {
color: var(--color-text);
}
.dialog-body {
padding: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.hint {
font-size: 0.85rem;
color: var(--color-text-muted);
line-height: 1.5;
margin: 0;
}
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.85rem;
font-weight: 600;
}
.field input {
padding: 0.55rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 0.95rem;
font-weight: 400;
background: var(--color-bg);
color: var(--color-text);
}
.field input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
.field-hint {
font-size: 0.75rem;
font-weight: 400;
color: var(--color-text-muted);
}
.status.success {
color: var(--color-success);
background: color-mix(in srgb, var(--color-success) 10%, var(--color-bg));
border: 1px solid color-mix(in srgb, var(--color-success) 40%, var(--color-bg));
border-radius: var(--radius);
padding: 0.7rem 0.9rem;
font-size: 0.9rem;
margin: 0;
}
.error {
color: var(--color-danger);
font-size: 0.85rem;
white-space: pre-line;
background: color-mix(in srgb, var(--color-danger) 8%, var(--color-bg));
padding: 0.6rem 0.8rem;
border-radius: var(--radius);
border: 1px solid var(--color-danger);
margin: 0;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
padding: 0.9rem 1.1rem;
border-top: 1px solid var(--color-border);
}
.btn {
padding: 0.5rem 1.1rem;
border-radius: var(--radius);
font-weight: 600;
cursor: pointer;
border: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-dark, var(--color-primary));
}
</style>

View File

@ -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 {

View File

@ -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<AddStudentResponse> {
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

View File

@ -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;

View File

@ -240,14 +240,7 @@ import QuizQuestionView from './QuizQuestionView.svelte';
{#if mgr.isQuizMode}
<div class="quiz-question-area" bind:this={quizQuestionEl}>
<QuizQuestionView
question={mgr.currentQuizQuestion}
currentIndex={mgr.quizCurrentIndex}
totalCount={mgr.quizTotalCount}
onExit={() => {
mgr.submitQuiz();
}}
/>
<QuizQuestionView mgr={mgr} />
</div>
{:else}
<div class="prose">{@html mgr.data?.lesson_content ?? ''}</div>

View File

@ -1,32 +1,97 @@
<script lang="ts">
import type { QuizQuestion } from '$types/quiz';
import type { LessonManager } from './lesson.svelte';
interface Props {
question: QuizQuestion | null;
currentIndex: number;
totalCount: number;
onExit: () => void;
mgr: LessonManager;
}
let { question, currentIndex, totalCount, onExit }: Props = $props();
let { mgr }: Props = $props();
const questions = $derived(mgr.quizSession?.questions ?? []);
function isAnswered(qId: string): boolean {
const a = mgr.quizSession?.answers[qId];
if (!a) return false;
return a.selectedOptionId !== null || a.acknowledged;
}
function handleFinish() {
if (!mgr.quizAllAnswered) return;
if (
window.confirm(
'Apakah anda yakin untuk menyelesaikan kuis? Materi akan muncul kembali setelah ini.'
)
) {
mgr.finishQuiz();
}
}
</script>
<div class="quiz-question-view">
<div class="quiz-question-header">
<span class="quiz-question-counter">Soal {currentIndex + 1} dari {totalCount}</span>
<button type="button" class="btn-exit-quiz" onclick={onExit}>Keluar Kuis</button>
{#if questions.length > 0}
<div class="quiz-navigator" aria-label="Navigasi soal">
{#each questions as q, i}
{@const answered = isAnswered(q.id)}
<button
type="button"
class="nav-dot"
class:active={i === mgr.quizCurrentIndex}
class:answered
aria-label={`Soal ${i + 1}${answered ? ' (dijawab)' : ' (belum dijawab)'}`}
aria-current={i === mgr.quizCurrentIndex ? 'step' : undefined}
onclick={() => mgr.goToQuizQuestion(i)}
>
{i + 1}
</button>
{/each}
</div>
{/if}
<button type="button" class="btn-exit-quiz" onclick={() => mgr.submitQuiz()}>Keluar Kuis</button>
</div>
{#if question}
{#if mgr.currentQuizQuestion}
<div class="quiz-question-prose">
{#if question.image}
<img src={question.image} alt="Ilustrasi soal" class="quiz-question-image" />
{#if mgr.currentQuizQuestion.image}
<img
src={mgr.currentQuizQuestion.image}
alt="Ilustrasi soal"
class="quiz-question-image"
/>
{/if}
<!-- Hanya prompt soal (question/front). Opsi, jawaban, dan penjelasan
TIDAK pernah dirender di sini — itu bagian lembar jawaban/summary. -->
{@html question.question ?? question.front ?? ''}
{@html mgr.currentQuizQuestion.question ?? mgr.currentQuizQuestion.front ?? ''}
</div>
{:else}
<p class="quiz-empty">Tidak ada soal.</p>
{/if}
{#if questions.length > 0}
<div class="quiz-controls">
<button
type="button"
class="btn btn-outline"
onclick={() => mgr.prevQuizQuestion()}
disabled={mgr.quizCurrentIndex === 0}
>
← Sebelumnya
</button>
{#if mgr.quizCurrentIndex < questions.length - 1}
<button type="button" class="btn btn-primary" onclick={() => mgr.nextQuizQuestion()}>
Selanjutnya →
</button>
{:else}
<button
type="button"
class="btn btn-success"
onclick={handleFinish}
disabled={!mgr.quizAllAnswered}
>
Selesai Kuis
</button>
{/if}
</div>
{/if}
</div>

View File

@ -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();
}
}
</script>
<div class="quiz-container">
@ -328,43 +320,6 @@
{/if}
</div>
<div class="quiz-navigator" aria-label="Navigasi soal">
{#each questions as q, i}
{@const answered = mgr.quizSession!.answers[q.id].selectedOptionId !== null || mgr.quizSession!.answers[q.id].acknowledged}
<button
type="button"
class="nav-dot"
class:active={i === mgr.quizCurrentIndex}
class:answered={answered}
aria-label={`Soal ${i + 1}${answered ? ' (dijawab)' : ' (belum dijawab)'}`}
aria-current={i === mgr.quizCurrentIndex ? 'step' : undefined}
onclick={() => mgr.goToQuizQuestion(i)}
>
{i + 1}
</button>
{/each}
</div>
<div class="quiz-controls">
<button
type="button"
class="btn btn-outline"
onclick={() => mgr.prevQuizQuestion()}
disabled={mgr.quizCurrentIndex === 0}
>
← Sebelumnya
</button>
{#if mgr.quizCurrentIndex < questions.length - 1}
<button type="button" class="btn btn-primary" onclick={() => mgr.nextQuizQuestion()}>
Selanjutnya →
</button>
{:else}
<button type="button" class="btn btn-success" onclick={handleFinish} disabled={!mgr.quizAllAnswered}>
Selesai Kuis
</button>
{/if}
</div>
<div class="cancel-container">
<button type="button" class="btn-exit-quiz" onclick={handleExit}>Keluar Kuis</button>
<span class="cancel-lock-hint">⚠️ Keluar = selesai. Soal belum dijawab dianggap salah.</span>
@ -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; }

View File

@ -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;
}
}

View File

@ -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'
};
})
};

View File

@ -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<HTMLInputElement | undefined>();
// 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'));
</script>
<svelte:head>
@ -164,7 +178,7 @@
<p class="empty">Anda tidak memiliki akses ke halaman ini.</p>
{:else}
<div class="summary-bar">
<span><strong>{students.length}</strong> siswa</span>
<span><strong>{studentRows.length}</strong> siswa</span>
<span><strong>{totalLessons}</strong> pelajaran</span>
{#if selectionCount > 0}
@ -193,6 +207,14 @@
{/if}
</button>
<button
class="btn btn-sm btn-secondary"
onclick={() => (addOpen = true)}
title="Tambah satu siswa dengan nama & token"
>
+ Tambah Siswa
</button>
<button
class="btn btn-sm btn-secondary"
onclick={() => (importOpen = true)}
@ -216,7 +238,7 @@
</div>
</div>
{#if students.length === 0}
{#if studentRows.length === 0}
<p class="empty">Belum ada data siswa. Gunakan <strong>Import CSV</strong> untuk menambah siswa pertama.</p>
{:else}
<div class="table-wrapper">
@ -242,16 +264,26 @@
<tbody>
{#each students as student}
{@const isChecked = selectedIds.includes(student.id as string)}
{@const isTeacher = student.role === 'teacher'}
<tr class:selected={isChecked}>
<td class="checkbox-col">
<input
type="checkbox"
checked={isChecked}
onchange={() => studentSelection.toggle(student.id as string)}
aria-label={`Pilih ${student.nama_siswa}`}
/>
{#if isTeacher}
<span class="teacher-no-select" aria-label="Guru tidak dapat dipilih" title="Guru tidak dapat dipilih untuk aksi bulk">—</span>
{:else}
<input
type="checkbox"
checked={isChecked}
onchange={() => studentSelection.toggle(student.id as string)}
aria-label={`Pilih ${student.nama_siswa}`}
/>
{/if}
</td>
<td class="sticky-col student-name">
{student.nama_siswa}
{#if isTeacher}
<span class="badge teacher-badge">Guru</span>
{/if}
</td>
<td class="sticky-col student-name">{student.nama_siswa}</td>
{#each lessons as lesson}
{@const key = lesson.filename.replace('.md', '')}
{@const status = student[key]}
@ -322,6 +354,11 @@
onClose={() => (importOpen = false)}
onImported={handleImported}
/>
<AddStudentDialog
open={addOpen}
onClose={() => (addOpen = false)}
onAdded={handleAdded}
/>
<BulkDeleteStudentsDialog
open={deleteOpen}
studentNames={selectedStudents.map((s) => ({ 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;
}

View File

@ -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(),
}
)

View File

@ -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()

View File

@ -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.

View File

@ -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)"

View File

@ -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