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:
parent
2d5f100808
commit
9aef612f01
|
|
@ -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">×</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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
};
|
||||
})
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue