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}
+
{
+ 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 @@
- {#if question}
+ {#if mgr.currentQuizQuestion}
- {#if question.image}
-
+ {#if mgr.currentQuizQuestion.image}
+
{/if}
- {@html question.question ?? question.front ?? ''}
+ {@html mgr.currentQuizQuestion.question ?? mgr.currentQuizQuestion.front ?? ''}
{:else}
Tidak ada soal.
{/if}
+
+ {#if questions.length > 0}
+
+ mgr.prevQuizQuestion()}
+ disabled={mgr.quizCurrentIndex === 0}
+ >
+ ← Sebelumnya
+
+ {#if mgr.quizCurrentIndex < questions.length - 1}
+ mgr.nextQuizQuestion()}>
+ Selanjutnya →
+
+ {:else}
+
+ Selesai Kuis
+
+ {/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}
- mgr.goToQuizQuestion(i)}
- >
- {i + 1}
-
- {/each}
-
-
-
- mgr.prevQuizQuestion()}
- disabled={mgr.quizCurrentIndex === 0}
- >
- ← Sebelumnya
-
- {#if mgr.quizCurrentIndex < questions.length - 1}
- mgr.nextQuizQuestion()}>
- Selanjutnya →
-
- {:else}
-
- Selesai Kuis
-
- {/if}
-
-
Keluar Kuis
⚠️ 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}
+ (addOpen = true)}
+ title="Tambah satu siswa dengan nama & token"
+ >
+ + Tambah Siswa
+
+
(importOpen = true)}
@@ -216,7 +238,7 @@
- {#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
+