feat(quiz): tag kategori diagnostik + breakdown evaluasi/diagnostik di kuis & report guru
- Backend parser (_parse_flashcards): deteksi marker ::diagnostic di baris sendiri -> category='diagnostik' (default evaluasi), strip dari tampilan.
- Persistensi: kolom answers_json (migration 0003) simpan ringkasan per-soal {question_id, selected_option_id, is_correct, category}.
- quiz_attempts: validasi/serialize answers_json + endpoint GET /quiz-attempts/<lesson> untuk review-after-refresh.
- quiz-session: hitung evalCorrect/evalTotal/diagCorrect/diagTotal/diagUnmastered; skor resmi = evaluasi saja.
- Frontend: QuizTab badge per-soal + breakdown 'Belum dikuasai'; progress guru tampil badge Eval/Diag per lesson.
- Content: pindahkan ::diagnostic ke baris sendiri (fix kerusakan fenced code/blockquote dari edit sebelumnya).
- Tests: unit parser kategori + payload answers.
This commit is contained in:
parent
79edcd1596
commit
58cb35a01a
|
|
@ -10,6 +10,7 @@ image: https://upload.wikimedia.org/wikipedia/commons/1/18/C_Programming_Languag
|
||||||
> Penjelasan: Bahasa C sebenarnya dikategorikan sebagai bahasa tingkat menengah (*middle-level language*) karena memiliki fitur bahasa tingkat rendah (seperti manipulasi memori) sekaligus fitur bahasa tingkat tinggi (seperti struktur kontrol yang manusiawi).
|
> Penjelasan: Bahasa C sebenarnya dikategorikan sebagai bahasa tingkat menengah (*middle-level language*) karena memiliki fitur bahasa tingkat rendah (seperti manipulasi memori) sekaligus fitur bahasa tingkat tinggi (seperti struktur kontrol yang manusiawi).
|
||||||
|
|
||||||
### Apa output dari `printf("%d", 5 + 5);`?
|
### Apa output dari `printf("%d", 5 + 5);`?
|
||||||
|
::diagnostic
|
||||||
- [] 55
|
- [] 55
|
||||||
- [x] 10
|
- [x] 10
|
||||||
- [] 5 + 5
|
- [] 5 + 5
|
||||||
|
|
@ -29,7 +30,8 @@ Berapakah keluaran program di atas?
|
||||||
> Pembahasan: `x * 3` menghasilkan `6` karena nilai `x` adalah `2`.
|
> Pembahasan: `x * 3` menghasilkan `6` karena nilai `x` adalah `2`.
|
||||||
|
|
||||||
### Berdasarkan tabel berikut, tipe data mana yang tepat untuk menyimpan `3.14`?
|
### Berdasarkan tabel berikut, tipe data mana yang tepat untuk menyimpan `3.14`?
|
||||||
| Tipe | Ukuran | Rentang |
|
::diagnostic
|
||||||
|
|| Tipe | Ukuran | Rentang ||
|
||||||
|--------|--------|--------------------|
|
|--------|--------|--------------------|
|
||||||
| int | 4 byte | -2^31 s/d 2^31-1 |
|
| int | 4 byte | -2^31 s/d 2^31-1 |
|
||||||
| float | 4 byte | ~1e-38 s/d ~1e38 |
|
| float | 4 byte | ~1e-38 s/d ~1e38 |
|
||||||
|
|
@ -40,6 +42,7 @@ Berapakah keluaran program di atas?
|
||||||
> Pembahasan: `double` punya presisi lebih tinggi dan rentang lebih luas untuk bilangan pecahan.
|
> Pembahasan: `double` punya presisi lebih tinggi dan rentang lebih luas untuk bilangan pecahan.
|
||||||
|
|
||||||
### Berapakah nilai dari $\int_0^1 2x \, dx$?
|
### Berapakah nilai dari $\int_0^1 2x \, dx$?
|
||||||
|
::diagnostic
|
||||||
- [x] 1
|
- [x] 1
|
||||||
- [] 2
|
- [] 2
|
||||||
- [] 0.5
|
- [] 0.5
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ describe('submitQuizAttempt', () => {
|
||||||
occurred_at: '2026-08-09T14:04:44.000Z',
|
occurred_at: '2026-08-09T14:04:44.000Z',
|
||||||
started_at: '2026-08-09T14:03:00.000Z',
|
started_at: '2026-08-09T14:03:00.000Z',
|
||||||
visibility_event_count: 1,
|
visibility_event_count: 1,
|
||||||
|
answers: [],
|
||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +86,8 @@ describe('submitQuizAttempt', () => {
|
||||||
score: '2/4',
|
score: '2/4',
|
||||||
occurred_at: p.occurred_at,
|
occurred_at: p.occurred_at,
|
||||||
started_at: p.started_at,
|
started_at: p.started_at,
|
||||||
visibility_event_count: 1
|
visibility_event_count: 1,
|
||||||
|
answers: []
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -110,37 +112,39 @@ describe('quizAttemptBeacon', () => {
|
||||||
vi.stubGlobal('navigator', { sendBeacon });
|
vi.stubGlobal('navigator', { sendBeacon });
|
||||||
|
|
||||||
const p: QuizAttemptSubmission = {
|
const p: QuizAttemptSubmission = {
|
||||||
attempt_id: '3f2f8c24-8c1a-4b2a-9e5a-1a2b3c4d5e6f',
|
attempt_id: '3f2f8c24-8c1a-4b2a-9e5a-1a2b3c4d5e6f',
|
||||||
token: 'student-token',
|
token: 'student-token',
|
||||||
lesson_name: 'quiz_test',
|
lesson_name: 'quiz_test',
|
||||||
status: 'terminated',
|
status: 'terminated',
|
||||||
termination_reason: 'focus_lost',
|
termination_reason: 'focus_lost',
|
||||||
score: '2/4',
|
score: '2/4',
|
||||||
occurred_at: '2026-08-09T14:04:44.000Z',
|
occurred_at: '2026-08-09T14:04:44.000Z',
|
||||||
started_at: '2026-08-09T14:03:00.000Z',
|
started_at: '2026-08-09T14:03:00.000Z',
|
||||||
visibility_event_count: 1
|
visibility_event_count: 1,
|
||||||
};
|
answers: [],
|
||||||
|
};
|
||||||
|
|
||||||
const ok = quizAttemptBeacon(p);
|
const ok = quizAttemptBeacon(p);
|
||||||
expect(ok).toBe(true);
|
expect(ok).toBe(true);
|
||||||
expect(sendBeacon).toHaveBeenCalledTimes(1);
|
expect(sendBeacon).toHaveBeenCalledTimes(1);
|
||||||
expect(sendBeacon.mock.calls[0][0]).toBe('/api/quiz-attempts/submit');
|
expect(sendBeacon.mock.calls[0][0]).toBe('/api/quiz-attempts/submit');
|
||||||
const blob = sendBeacon.mock.calls[0][1];
|
const blob = sendBeacon.mock.calls[0][1];
|
||||||
expect(blob.type).toBe('application/json');
|
expect(blob.type).toBe('application/json');
|
||||||
const text = await blob.text();
|
const text = await blob.text();
|
||||||
const body = JSON.parse(text);
|
const body = JSON.parse(text);
|
||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
attempt_id: p.attempt_id,
|
attempt_id: p.attempt_id,
|
||||||
token: p.token,
|
token: p.token,
|
||||||
lesson_name: 'quiz_test',
|
lesson_name: 'quiz_test',
|
||||||
status: 'terminated',
|
status: 'terminated',
|
||||||
termination_reason: 'focus_lost',
|
termination_reason: 'focus_lost',
|
||||||
score: '2/4',
|
score: '2/4',
|
||||||
occurred_at: p.occurred_at,
|
occurred_at: p.occurred_at,
|
||||||
started_at: p.started_at,
|
started_at: p.started_at,
|
||||||
visibility_event_count: 1
|
visibility_event_count: 1,
|
||||||
|
answers: p.answers
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('tidak melempar dan mengembalikan false saat sendBeacon tidak tersedia', () => {
|
it('tidak melempar dan mengembalikan false saat sendBeacon tidak tersedia', () => {
|
||||||
vi.stubGlobal('navigator', {});
|
vi.stubGlobal('navigator', {});
|
||||||
|
|
@ -153,7 +157,8 @@ describe('quizAttemptBeacon', () => {
|
||||||
score: '2/4',
|
score: '2/4',
|
||||||
occurred_at: '2026-08-09T14:04:44.000Z',
|
occurred_at: '2026-08-09T14:04:44.000Z',
|
||||||
started_at: '2026-08-09T14:03:00.000Z',
|
started_at: '2026-08-09T14:03:00.000Z',
|
||||||
visibility_event_count: 1
|
visibility_event_count: 1,
|
||||||
|
answers: []
|
||||||
};
|
};
|
||||||
expect(quizAttemptBeacon(p)).toBe(false);
|
expect(quizAttemptBeacon(p)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,15 @@ export interface QuizAttemptSubmission {
|
||||||
occurred_at: string;
|
occurred_at: string;
|
||||||
started_at: string;
|
started_at: string;
|
||||||
visibility_event_count: number;
|
visibility_event_count: number;
|
||||||
|
/** Ringkasan per-soal untuk review-after-refresh + breakdown kategori. */
|
||||||
|
answers: QuizAnswerPayload[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizAnswerPayload {
|
||||||
|
question_id: string;
|
||||||
|
selected_option_id: string | null;
|
||||||
|
is_correct: boolean;
|
||||||
|
category: 'evaluasi' | 'diagnostik';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuizAttemptSubmitResponse {
|
export interface QuizAttemptSubmitResponse {
|
||||||
|
|
@ -164,6 +173,20 @@ export interface QuizAttemptSubmitResponse {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Attempt yang dikembalikan endpoint GET (untuk review-after-refresh). */
|
||||||
|
export interface QuizAttemptFetchResponse {
|
||||||
|
success: boolean;
|
||||||
|
attempt_id: string;
|
||||||
|
status: 'submitted' | 'terminated';
|
||||||
|
termination_reason: QuizTerminationReason | null;
|
||||||
|
score: string;
|
||||||
|
score_earned?: number | null;
|
||||||
|
score_total?: number | null;
|
||||||
|
started_at?: string | null;
|
||||||
|
finished_at?: string | null;
|
||||||
|
answers: QuizAnswerPayload[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit finalisasi kuis (satu attempt) — endpoint atomic di backend.
|
* Submit finalisasi kuis (satu attempt) — endpoint atomic di backend.
|
||||||
* Dipakai untuk exit path normal: tombol Keluar, SPA navigation, finish.
|
* Dipakai untuk exit path normal: tombol Keluar, SPA navigation, finish.
|
||||||
|
|
@ -175,6 +198,20 @@ export function submitQuizAttempt(
|
||||||
return post<QuizAttemptSubmitResponse>('/quiz-attempts/submit', payload, customFetch);
|
return post<QuizAttemptSubmitResponse>('/quiz-attempts/submit', payload, customFetch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch attempt kuis siswa untuk sebuah lesson (GET /quiz-attempts/<lesson>).
|
||||||
|
* Dipakai saat halaman dibuka kembali supaya review per-soal tetap tampil
|
||||||
|
* setelah refresh (one-attempt policy → session hilang, tapi attempt persist).
|
||||||
|
*/
|
||||||
|
export function fetchQuizAttempt(
|
||||||
|
token: string,
|
||||||
|
lessonName: string,
|
||||||
|
customFetch = fetch
|
||||||
|
): Promise<QuizAttemptFetchResponse> {
|
||||||
|
const query = `token=${encodeURIComponent(token)}`;
|
||||||
|
return get<QuizAttemptFetchResponse>(`/quiz-attempts/${lessonName}?${query}`, customFetch);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kirim attempt via `navigator.sendBeacon` (synchronous, fire-and-forget).
|
* Kirim attempt via `navigator.sendBeacon` (synchronous, fire-and-forget).
|
||||||
* Dipakai untuk event lifecycle yang bisa membuat browser suspend
|
* Dipakai untuk event lifecycle yang bisa membuat browser suspend
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,16 @@ export interface QuizResult {
|
||||||
unansweredMcq: number;
|
unansweredMcq: number;
|
||||||
completedFlashcards: number;
|
completedFlashcards: number;
|
||||||
allHandled: boolean;
|
allHandled: boolean;
|
||||||
/** e.g. "2/4" when MCQ exist, "completed" when only flashcards. */
|
/** Skor resmi = evaluasi saja. "X/Y" bila ada MCQ evaluasi, "completed" bila tidak ada MCQ evaluasi (hanya diagnostik/flashcard). */
|
||||||
statusString: string;
|
statusString: string;
|
||||||
|
/** MCQ dengan category 'evaluasi' */
|
||||||
|
evalCorrect: number;
|
||||||
|
evalTotal: number;
|
||||||
|
/** MCQ dengan category 'diagnostik' */
|
||||||
|
diagCorrect: number;
|
||||||
|
diagTotal: number;
|
||||||
|
/** Soal diagnostik yang salah/atur tidak dijawab */
|
||||||
|
diagUnmastered: QuizQuestion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -74,12 +82,28 @@ export function calculateQuizResult(session: QuizSession): QuizResult {
|
||||||
let unansweredMcq = 0;
|
let unansweredMcq = 0;
|
||||||
let completedFlashcards = 0;
|
let completedFlashcards = 0;
|
||||||
|
|
||||||
|
let evalCorrect = 0;
|
||||||
|
let evalTotal = 0;
|
||||||
|
let diagCorrect = 0;
|
||||||
|
let diagTotal = 0;
|
||||||
|
const diagUnmastered: QuizQuestion[] = [];
|
||||||
|
|
||||||
for (const q of session.questions) {
|
for (const q of session.questions) {
|
||||||
const a = session.answers[q.id];
|
const a = session.answers[q.id];
|
||||||
|
const isEval = q.category !== 'diagnostik'; // default 'evaluasi'
|
||||||
if (q.type === 'mcq') {
|
if (q.type === 'mcq') {
|
||||||
totalMcq += 1;
|
totalMcq += 1;
|
||||||
if (a.isCorrect) correctMcq += 1;
|
if (a.isCorrect) correctMcq += 1;
|
||||||
if (a.selectedOptionId === null) unansweredMcq += 1;
|
if (a.selectedOptionId === null) unansweredMcq += 1;
|
||||||
|
|
||||||
|
if (isEval) {
|
||||||
|
evalTotal += 1;
|
||||||
|
if (a.isCorrect) evalCorrect += 1;
|
||||||
|
} else {
|
||||||
|
diagTotal += 1;
|
||||||
|
if (a.isCorrect) diagCorrect += 1;
|
||||||
|
else diagUnmastered.push(q);
|
||||||
|
}
|
||||||
} else if (a.acknowledged) {
|
} else if (a.acknowledged) {
|
||||||
completedFlashcards += 1;
|
completedFlashcards += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +114,20 @@ export function calculateQuizResult(session: QuizSession): QuizResult {
|
||||||
? session.answers[q.id].selectedOptionId !== null
|
? session.answers[q.id].selectedOptionId !== null
|
||||||
: session.answers[q.id].acknowledged
|
: session.answers[q.id].acknowledged
|
||||||
);
|
);
|
||||||
const statusString = totalMcq > 0 ? `${correctMcq}/${totalMcq}` : 'completed';
|
// Skor resmi = evaluasi saja; bila tidak ada MCQ evaluasi → 'completed'.
|
||||||
|
const statusString = evalTotal > 0 ? `${evalCorrect}/${evalTotal}` : 'completed';
|
||||||
|
|
||||||
return { totalMcq, correctMcq, unansweredMcq, completedFlashcards, allHandled, statusString };
|
return {
|
||||||
|
totalMcq,
|
||||||
|
correctMcq,
|
||||||
|
unansweredMcq,
|
||||||
|
completedFlashcards,
|
||||||
|
allHandled,
|
||||||
|
statusString,
|
||||||
|
evalCorrect,
|
||||||
|
evalTotal,
|
||||||
|
diagCorrect,
|
||||||
|
diagTotal,
|
||||||
|
diagUnmastered,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export interface QuizOption {
|
||||||
export interface QuizQuestion {
|
export interface QuizQuestion {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'flashcard' | 'mcq';
|
type: 'flashcard' | 'mcq';
|
||||||
|
category?: 'evaluasi' | 'diagnostik';
|
||||||
question?: string;
|
question?: string;
|
||||||
front?: string;
|
front?: string;
|
||||||
back?: string;
|
back?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { authLoggedIn } from '$stores/auth';
|
import { authLoggedIn, authToken } from '$stores/auth';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { renderMath } from '$lib/actions/renderMath';
|
import { renderMath } from '$lib/actions/renderMath';
|
||||||
import { shouldShowQuizReview } from '$services/quiz-integrity';
|
import { shouldShowQuizReview } from '$services/quiz-integrity';
|
||||||
import type { LessonManager } from './lesson.svelte';
|
import type { LessonManager } from './lesson.svelte';
|
||||||
|
import type { QuizQuestion, QuizAnswer } from '$types/quiz';
|
||||||
|
import { fetchQuizAttempt } from '$services/api';
|
||||||
|
import type { QuizAttemptFetchResponse } from '$services/api';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mgr: LessonManager;
|
mgr: LessonManager;
|
||||||
|
|
@ -26,6 +31,50 @@
|
||||||
// pembahasan (siswa yang kehilangan fokus tidak mendapat bocoran jawaban).
|
// pembahasan (siswa yang kehilangan fokus tidak mendapat bocoran jawaban).
|
||||||
const showReview = $derived(shouldShowQuizReview(mgr.quizTerminationReason));
|
const showReview = $derived(shouldShowQuizReview(mgr.quizTerminationReason));
|
||||||
|
|
||||||
|
// --- Review-after-refresh: fetch stored attempt (one-attempt → session lost) ---
|
||||||
|
let storedAttempt: QuizAttemptFetchResponse | null = $state(null);
|
||||||
|
let reviewQuestions: QuizQuestion[] = $state([]);
|
||||||
|
let reviewAnswers: Record<string, QuizAnswer> = $state({});
|
||||||
|
|
||||||
|
async function loadStoredAttempt() {
|
||||||
|
const token = get(authToken) || localStorage.getItem('student_token') || '';
|
||||||
|
if (!token || !mgr.data) return;
|
||||||
|
const lessonName = mgr.slug.replace('.md', '');
|
||||||
|
try {
|
||||||
|
const resp = await fetchQuizAttempt(token, lessonName);
|
||||||
|
if (resp.success && resp.answers?.length) {
|
||||||
|
storedAttempt = resp;
|
||||||
|
// Rekonstruksi question+answer maps dari stored attempt agar
|
||||||
|
// summary review per-soal bisa dirender setelah refresh.
|
||||||
|
const src = mgr.data?.quiz_data ?? [];
|
||||||
|
// Cocokkan by question_id; gunakan urutan asli (parser order).
|
||||||
|
reviewQuestions = src.filter((q) => resp.answers.some((a) => a.question_id === q.id));
|
||||||
|
reviewAnswers = {};
|
||||||
|
for (const ans of resp.answers) {
|
||||||
|
const q = src.find((qq) => qq.id === ans.question_id);
|
||||||
|
if (q) {
|
||||||
|
reviewAnswers[q.id] = {
|
||||||
|
questionId: q.id,
|
||||||
|
selectedOptionId: ans.selected_option_id,
|
||||||
|
acknowledged: false,
|
||||||
|
isCorrect: ans.is_correct,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort; review akan tetap tersedia di session bila masih hidup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch attempt bila sudah selesai tapi session hilang (refresh/page-open)
|
||||||
|
onMount(() => {
|
||||||
|
const r = result;
|
||||||
|
if (!r && quizAlreadyCompleted && !mgr.quizSession) {
|
||||||
|
loadStoredAttempt();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function handleExit() {
|
function handleExit() {
|
||||||
if (!mgr.isQuizMode) return;
|
if (!mgr.isQuizMode) return;
|
||||||
|
|
||||||
|
|
@ -57,7 +106,15 @@
|
||||||
<div class="summary-icon">🏁</div>
|
<div class="summary-icon">🏁</div>
|
||||||
<h2>Kuis Selesai!</h2>
|
<h2>Kuis Selesai!</h2>
|
||||||
{#if quizAlreadyCompleted && !result}
|
{#if quizAlreadyCompleted && !result}
|
||||||
<p class="score-display">Nilai Anda: <strong>{mgr.data?.lesson_progress_status}</strong></p>
|
{#if storedAttempt}
|
||||||
|
{@const s = storedAttempt}
|
||||||
|
<p class="score-display">Nilai Anda: <strong>{s.score}</strong></p>
|
||||||
|
<div class="score-breakdown">
|
||||||
|
<span class="breakdown-item">Evaluasi: <strong>{s.score_earned ?? '-'}/{s.score_total ?? '-'}</strong></span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="score-display">Nilai Anda: <strong>{mgr.data?.lesson_progress_status}</strong></p>
|
||||||
|
{/if}
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
Kuis ini sudah diselesaikan. Silakan hubungi guru jika ingin mengulang.
|
Kuis ini sudah diselesaikan. Silakan hubungi guru jika ingin mengulang.
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -65,7 +122,7 @@
|
||||||
{@const totalMcq = result.totalMcq}
|
{@const totalMcq = result.totalMcq}
|
||||||
{#if mgr.quizTerminationReason === 'focus_lost'}
|
{#if mgr.quizTerminationReason === 'focus_lost'}
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger">
|
||||||
Kuis dihentikan karena halaman kehilangan fokus. Skor disimpan dan percobaan tidak dapat diulang.
|
Kuis dihentikan karena halaman kehilangan fokus. Skor disimpan dan percobaann tidak dapat diulang.
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{@const correct = result.correctMcq}
|
{@const correct = result.correctMcq}
|
||||||
|
|
@ -81,14 +138,33 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if result.evalTotal > 0 || result.diagTotal > 0}
|
||||||
|
<div class="score-breakdown">
|
||||||
|
{#if result.evalTotal > 0}
|
||||||
|
<span class="breakdown-item">Evaluasi: <strong>{result.evalCorrect}/{result.evalTotal}</strong></span>
|
||||||
|
{/if}
|
||||||
|
{#if result.diagTotal > 0}
|
||||||
|
<span class="breakdown-item">Diagnostik: <strong>{result.diagCorrect}/{result.diagTotal}</strong></span>
|
||||||
|
{#if result.diagUnmastered.length > 0}
|
||||||
|
<span class="breakdown-item unmastered">
|
||||||
|
Belum dikuasai:
|
||||||
|
{#each result.diagUnmastered as q}
|
||||||
|
<span class="unmastered-topic">{(q.front ?? q.question ?? 'Soal').replace(/<[^>]*>/g, '').slice(0, 40)}</span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showReview && questions.length > 0}
|
{#if showReview && questions.length > 0}
|
||||||
<div class="summary-review">
|
<div class="summary-review">
|
||||||
<h3 class="review-title">Pembahasan</h3>
|
<h3 class="review-title">Pembahasan</h3>
|
||||||
{#each questions as q, i}
|
{#each questions as q, i}
|
||||||
{@const a = mgr.quizSession!.answers[q.id]}
|
{@const a = mgr.quizSession!.answers[q.id]}
|
||||||
<div class="review-item">
|
<div class="review-item" data-category={q.category ?? 'evaluasi'}>
|
||||||
<div class="review-header">
|
<div class="review-header">
|
||||||
<span class="review-number">Soal {i + 1}</span>
|
<span class="review-number">Soal {i + 1} <span class="review-category-badge">{q.category === 'diagnostik' ? 'Diagnostik' : 'Evaluasi'}</span></span>
|
||||||
{#if q.type === 'mcq'}
|
{#if q.type === 'mcq'}
|
||||||
<span class="review-badge" class:ok={a.isCorrect} class:bad={!a.isCorrect}>
|
<span class="review-badge" class:ok={a.isCorrect} class:bad={!a.isCorrect}>
|
||||||
{a.isCorrect ? '✓ Benar' : '✗ Salah'}
|
{a.isCorrect ? '✓ Benar' : '✗ Salah'}
|
||||||
|
|
@ -134,6 +210,52 @@
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if storedAttempt && !result && showReview && reviewQuestions.length > 0}
|
||||||
|
<div class="summary-review">
|
||||||
|
<h3 class="review-title">Pembahasan (tersimpan)</h3>
|
||||||
|
{#each reviewQuestions as q, i}
|
||||||
|
{@const a = reviewAnswers[q.id]}
|
||||||
|
<div class="review-item" data-category={q.category ?? 'evaluasi'}>
|
||||||
|
<div class="review-header">
|
||||||
|
<span class="review-number">Soal {i + 1} <span class="review-category-badge">{q.category === 'diagnostik' ? 'Diagnostik' : 'Evaluasi'}</span></span>
|
||||||
|
{#if q.type === 'mcq'}
|
||||||
|
<span class="review-badge" class:ok={a.isCorrect} class:bad={!a.isCorrect}>
|
||||||
|
{a.isCorrect ? '✓ Benar' : '✗ Salah'}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="review-badge ok">Selesai</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="review-question">{@html q.question ?? q.front ?? ''}</div>
|
||||||
|
{#if q.type === 'mcq' && q.options}
|
||||||
|
<div class="review-options">
|
||||||
|
{#each q.options as opt}
|
||||||
|
<div
|
||||||
|
class="review-option"
|
||||||
|
class:chosen={opt.id === a.selectedOptionId}
|
||||||
|
class:correct-opt={opt.is_correct}
|
||||||
|
>
|
||||||
|
<span class="review-option-mark">
|
||||||
|
{opt.is_correct ? '✅' : opt.id === a.selectedOptionId ? '❌' : '○'}
|
||||||
|
</span>
|
||||||
|
<span class="review-option-text">{@html opt.text}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if !a.isCorrect && a.selectedOptionId === null}
|
||||||
|
<p class="review-unanswered">Tidak dijawab — dianggap salah.</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="review-back">{@html q.back ?? ''}</div>
|
||||||
|
{/if}
|
||||||
|
{#if q.explanation}
|
||||||
|
<div class="explanation-box">{@html q.explanation}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if !mgr.isQuizMode}
|
{:else if !mgr.isQuizMode}
|
||||||
|
|
@ -354,6 +476,12 @@
|
||||||
.explanation-box { font-size: 0.85rem; line-height: 1.5; padding: 1rem; background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: 8px; text-align: left; margin-top: 0.5rem; }
|
.explanation-box { font-size: 0.85rem; line-height: 1.5; padding: 1rem; background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: 8px; text-align: left; margin-top: 0.5rem; }
|
||||||
.explanation-box :global(p) { margin: 0; }
|
.explanation-box :global(p) { margin: 0; }
|
||||||
|
|
||||||
|
.score-breakdown { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: center; margin: 0.75rem 0 1.25rem; }
|
||||||
|
.breakdown-item { font-size: 0.95rem; padding: 0.35rem 0.7rem; border-radius: 999px; background: var(--color-bg-secondary); border: 1px solid var(--color-border); }
|
||||||
|
.breakdown-item strong { color: var(--color-primary); }
|
||||||
|
.breakdown-item.unmastered { flex-basis: 100%; justify-content: center; }
|
||||||
|
.unmastered-topic { font-size: 0.8rem; background: rgba(220, 53, 69, 0.1); color: var(--color-danger); padding: 0.15rem 0.5rem; border-radius: 6px; margin: 0.15rem; display: inline-block; }
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.option-btn { padding: 0.75rem; font-size: 0.95rem; }
|
.option-btn { padding: 0.75rem; font-size: 0.95rem; }
|
||||||
.quiz-container { padding: 1rem; }
|
.quiz-container { padding: 1rem; }
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { pickDefaultTab } from '$services/lesson-tabs';
|
||||||
import { tick, untrack } from 'svelte';
|
import { tick, untrack } from 'svelte';
|
||||||
import { auth, authLoggedIn, authToken } from '$stores/auth';
|
import { auth, authLoggedIn, authToken } from '$stores/auth';
|
||||||
import { lessonContext } from '$stores/lessonContext';
|
import { lessonContext } from '$stores/lessonContext';
|
||||||
import { compileCode, trackProgress, submitQuizAttempt, quizAttemptBeacon, type QuizAttemptSubmission } from '$services/api';
|
import { compileCode, trackProgress, submitQuizAttempt, quizAttemptBeacon, fetchQuizAttempt, type QuizAttemptSubmission, type QuizAttemptFetchResponse } from '$services/api';
|
||||||
import { createAttemptId, type QuizTerminationReason } from '$services/quiz-integrity';
|
import { createAttemptId, type QuizTerminationReason } from '$services/quiz-integrity';
|
||||||
import { evaluateVelxioSubmission } from '$services/velxio-evaluator';
|
import { evaluateVelxioSubmission } from '$services/velxio-evaluator';
|
||||||
import { evaluateFlowchartSubmission } from '$services/flowchart-evaluator';
|
import { evaluateFlowchartSubmission } from '$services/flowchart-evaluator';
|
||||||
|
|
@ -416,7 +416,16 @@ export class LessonManager {
|
||||||
score: result.statusString,
|
score: result.statusString,
|
||||||
occurred_at: now,
|
occurred_at: now,
|
||||||
started_at: this.quizStartedAt ?? now,
|
started_at: this.quizStartedAt ?? now,
|
||||||
visibility_event_count: this.visibilityEventCount
|
visibility_event_count: this.visibilityEventCount,
|
||||||
|
answers: this.quizSession.questions.map((q) => {
|
||||||
|
const a = this.quizSession!.answers[q.id];
|
||||||
|
return {
|
||||||
|
question_id: q.id,
|
||||||
|
selected_option_id: a.selectedOptionId,
|
||||||
|
is_correct: a.isCorrect,
|
||||||
|
category: (q.category === 'diagnostik' ? 'diagnostik' : 'evaluasi') as 'evaluasi' | 'diagnostik'
|
||||||
|
};
|
||||||
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,17 @@
|
||||||
[key: string]: string | number | boolean;
|
[key: string]: string | number | boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `_diag_unmastered` dikirim backend sebagai JSON string array of question_id.
|
||||||
|
function parseUnmastered(raw: string | number | boolean | undefined): string[] {
|
||||||
|
if (typeof raw !== 'string') return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let students = $state<StudentProgress[]>([]);
|
let students = $state<StudentProgress[]>([]);
|
||||||
let lessons = $state<LessonHeader[]>([]);
|
let lessons = $state<LessonHeader[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
@ -247,6 +258,9 @@
|
||||||
{@const violation = student[key + '_has_violation']}
|
{@const violation = student[key + '_has_violation']}
|
||||||
{@const violationReason = student[key + '_termination_reason']}
|
{@const violationReason = student[key + '_termination_reason']}
|
||||||
{@const violationTime = student[key + '_attempt_finished_at']}
|
{@const violationTime = student[key + '_attempt_finished_at']}
|
||||||
|
{@const evalScore = student[key + '_eval']}
|
||||||
|
{@const diagScore = student[key + '_diag']}
|
||||||
|
{@const diagUnmastered = parseUnmastered(student[key + '_diag_unmastered'])}
|
||||||
<td class="status-cell">
|
<td class="status-cell">
|
||||||
<div class="cell-content">
|
<div class="cell-content">
|
||||||
{#if status === 'completed'}
|
{#if status === 'completed'}
|
||||||
|
|
@ -266,6 +280,21 @@
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if evalScore}
|
||||||
|
<span class="badge eval" title="Skor evaluasi (skor resmi)">Eval: {evalScore}</span>
|
||||||
|
{/if}
|
||||||
|
{#if diagScore}
|
||||||
|
<span class="badge diag" title="Skor soal diagnostik">Diag: {diagScore}</span>
|
||||||
|
{#if diagUnmastered.length > 0}
|
||||||
|
<span
|
||||||
|
class="badge unmastered"
|
||||||
|
title="Belum dikuasai: {diagUnmastered.join(', ')}"
|
||||||
|
>
|
||||||
|
{diagUnmastered.length} belum dikuasai
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if status && status !== 'not_started'}
|
{#if status && status !== 'not_started'}
|
||||||
<button
|
<button
|
||||||
class="btn-reset-mini"
|
class="btn-reset-mini"
|
||||||
|
|
@ -443,10 +472,33 @@
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.badge.eval, .badge.diag, .badge.unmastered {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge.eval {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
.badge.diag {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.badge.unmastered {
|
||||||
|
color: var(--color-danger, #e8590c);
|
||||||
|
background: color-mix(in srgb, var(--color-danger, #e8590c) 10%, var(--color-bg));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-danger, #e8590c) 40%, var(--color-bg));
|
||||||
|
}
|
||||||
.cell-content {
|
.cell-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
.btn-reset-mini {
|
.btn-reset-mini {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""quiz_attempts: simpan ringkasan per-soal (review-after-refresh + breakdown kategori)
|
||||||
|
|
||||||
|
Revision ID: 0003
|
||||||
|
Revises: 0002
|
||||||
|
Create Date: 2026-08-14
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0003"
|
||||||
|
down_revision = "0002"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("quiz_attempts", sa.Column("answers_json", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("quiz_attempts", "answers_json")
|
||||||
|
|
@ -15,6 +15,7 @@ Kontrak:
|
||||||
- Raw token tidak pernah di-log; response hanya memuat status/message/attempt_id.
|
- Raw token tidak pernah di-log; response hanya memuat status/message/attempt_id.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
@ -119,6 +120,17 @@ def submit_quiz_attempt():
|
||||||
):
|
):
|
||||||
return jsonify({"success": False, "message": "visibility_event_count tidak valid"}), 400
|
return jsonify({"success": False, "message": "visibility_event_count tidak valid"}), 400
|
||||||
|
|
||||||
|
# answers: ringkasan per-soal [{question_id, selected_option_id, is_correct, category}]
|
||||||
|
answers_raw = data.get("answers")
|
||||||
|
answers_json = None
|
||||||
|
if answers_raw is not None:
|
||||||
|
if not isinstance(answers_raw, list):
|
||||||
|
return jsonify({"success": False, "message": "answers harus berupa list"}), 400
|
||||||
|
try:
|
||||||
|
answers_json = json.dumps(answers_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"success": False, "message": "answers tidak dapat diserialisasi"}), 400
|
||||||
|
|
||||||
if SessionLocal is None:
|
if SessionLocal is None:
|
||||||
return jsonify({"success": False, "message": "PostgreSQL tidak aktif"}), 503
|
return jsonify({"success": False, "message": "PostgreSQL tidak aktif"}), 503
|
||||||
|
|
||||||
|
|
@ -145,6 +157,7 @@ def submit_quiz_attempt():
|
||||||
finished_at=datetime.now(timezone.utc),
|
finished_at=datetime.now(timezone.utc),
|
||||||
visibility_event_count=visibility_count,
|
visibility_event_count=visibility_count,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
|
answers_json=answers_json,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
@ -185,3 +198,57 @@ def submit_quiz_attempt():
|
||||||
"attempt_id": attempt.id,
|
"attempt_id": attempt.id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@quiz_attempts_bp.route("/quiz-attempts/<lesson_name>", methods=["GET"])
|
||||||
|
def get_quiz_attempt(lesson_name: str):
|
||||||
|
"""Dapatkan attempt kuis siswa untuk lesson (untuk review-after-refresh).
|
||||||
|
|
||||||
|
Auth: student_token via query param `token` (siswa hanya lihat attempt miliknya).
|
||||||
|
Response: attempt_id, status, termination_reason, score, score_earned, score_total,
|
||||||
|
started_at, finished_at, answers (parsed dari answers_json).
|
||||||
|
"""
|
||||||
|
token = (request.args.get("token") or "").strip()
|
||||||
|
if not token:
|
||||||
|
return jsonify({"success": False, "message": "Token wajib"}), 400
|
||||||
|
if SessionLocal is None:
|
||||||
|
return jsonify({"success": False, "message": "PostgreSQL tidak aktif"}), 503
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
user = repo.get_user_by_token(db, token)
|
||||||
|
if user is None:
|
||||||
|
return jsonify({"success": False, "message": "Token tidak valid"}), 401
|
||||||
|
lesson = repo.get_lesson_by_slug(db, lesson_name)
|
||||||
|
if lesson is None:
|
||||||
|
return jsonify({"success": False, "message": "Lesson tidak dikenal"}), 404
|
||||||
|
attempt = repo.get_quiz_attempt_for_user_lesson(
|
||||||
|
db, user_id=user.id, lesson_id=lesson.id
|
||||||
|
)
|
||||||
|
if attempt is None:
|
||||||
|
return jsonify({"success": False, "message": "Belum ada attempt"}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
answers = json.loads(attempt.answers_json) if attempt.answers_json else []
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
answers = []
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"attempt_id": attempt.id,
|
||||||
|
"status": attempt.status,
|
||||||
|
"termination_reason": attempt.termination_reason,
|
||||||
|
"score": f"{attempt.score_earned}/{attempt.score_total}"
|
||||||
|
if attempt.score_earned is not None and attempt.score_total is not None
|
||||||
|
else "completed",
|
||||||
|
"score_earned": attempt.score_earned,
|
||||||
|
"score_total": attempt.score_total,
|
||||||
|
"started_at": attempt.started_at.isoformat() if attempt.started_at else None,
|
||||||
|
"finished_at": attempt.finished_at.isoformat() if attempt.finished_at else None,
|
||||||
|
"answers": answers,
|
||||||
|
})
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("get_quiz_attempt gagal")
|
||||||
|
return jsonify({"success": False, "message": "Gagal mengambil attempt"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -796,6 +796,12 @@ def _parse_flashcards(text):
|
||||||
image_url = f'/assets/{image_url}'
|
image_url = f'/assets/{image_url}'
|
||||||
body = re.sub(r'^\s*image:\s*.*$', '', body, flags=re.MULTILINE).strip()
|
body = re.sub(r'^\s*image:\s*.*$', '', body, flags=re.MULTILINE).strip()
|
||||||
|
|
||||||
|
# Check for ::diagnostic marker (stripped from display, sets category)
|
||||||
|
category = 'evaluasi'
|
||||||
|
if re.search(r'^\s*::diagnostic\s*$', body, re.MULTILINE):
|
||||||
|
category = 'diagnostik'
|
||||||
|
body = re.sub(r'^\s*::diagnostic\s*$', '', body, flags=re.MULTILINE).strip()
|
||||||
|
|
||||||
# Option lines outside fenced code, in original order
|
# Option lines outside fenced code, in original order
|
||||||
option_lines = list(_iter_option_lines(body))
|
option_lines = list(_iter_option_lines(body))
|
||||||
first_option_idx = option_lines[0][0] if option_lines else len(body)
|
first_option_idx = option_lines[0][0] if option_lines else len(body)
|
||||||
|
|
@ -849,7 +855,8 @@ def _parse_flashcards(text):
|
||||||
'question': md.markdown(prompt_text, extensions=MD_EXTENSIONS),
|
'question': md.markdown(prompt_text, extensions=MD_EXTENSIONS),
|
||||||
'options': parsed_options,
|
'options': parsed_options,
|
||||||
'explanation': md.markdown(explanation, extensions=MD_EXTENSIONS) if explanation else "",
|
'explanation': md.markdown(explanation, extensions=MD_EXTENSIONS) if explanation else "",
|
||||||
'image': image_url
|
'image': image_url,
|
||||||
|
'category': category
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# It's a simple Flashcard
|
# It's a simple Flashcard
|
||||||
|
|
@ -865,7 +872,8 @@ def _parse_flashcards(text):
|
||||||
'back': md.markdown(clean_back, extensions=MD_EXTENSIONS),
|
'back': md.markdown(clean_back, extensions=MD_EXTENSIONS),
|
||||||
'explanation': md.markdown(explanation, extensions=MD_EXTENSIONS) if explanation else "",
|
'explanation': md.markdown(explanation, extensions=MD_EXTENSIONS) if explanation else "",
|
||||||
'image': image_url,
|
'image': image_url,
|
||||||
'options': None
|
'options': None,
|
||||||
|
'category': category
|
||||||
})
|
})
|
||||||
|
|
||||||
return flashcards
|
return flashcards
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,9 @@ from sqlalchemy import (
|
||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
|
JSON,
|
||||||
String,
|
String,
|
||||||
|
Text,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
func,
|
func,
|
||||||
)
|
)
|
||||||
|
|
@ -142,6 +144,11 @@ class QuizAttempt(Base):
|
||||||
# Diagnosis device/browser saja — bukan dasar hukuman tambahan.
|
# Diagnosis device/browser saja — bukan dasar hukuman tambahan.
|
||||||
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
# Ringkasan per-soal (question_id -> selected_option_id, is_correct, category).
|
||||||
|
# Digunakan frontend untuk render review-after-refresh + breakdown kategori;
|
||||||
|
# tidak memengaruui skor resmi (evaluasi saja, lihat quiz-session.ts).
|
||||||
|
answers_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship(back_populates="quiz_attempts")
|
user: Mapped["User"] = relationship(back_populates="quiz_attempts")
|
||||||
lesson: Mapped["Lesson"] = relationship(back_populates="quiz_attempts")
|
lesson: Mapped["Lesson"] = relationship(back_populates="quiz_attempts")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,19 @@ def list_quiz_attempts_for_user(db: Session, *, user_id: str) -> list[QuizAttemp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_quiz_attempt_for_user_lesson(db: Session, *, user_id: str, lesson_id: str) -> QuizAttempt | None:
|
||||||
|
"""Satu attempt kuis per (user, lesson) — one-attempt policy (unique constraint)."""
|
||||||
|
return db.scalars(
|
||||||
|
select(QuizAttempt)
|
||||||
|
.where(
|
||||||
|
QuizAttempt.user_id == user_id,
|
||||||
|
QuizAttempt.lesson_id == lesson_id,
|
||||||
|
)
|
||||||
|
.order_by(QuizAttempt.finished_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
def delete_quiz_attempts(db: Session, *, user_id: str, lesson_id: str) -> int:
|
def delete_quiz_attempts(db: Session, *, user_id: str, lesson_id: str) -> int:
|
||||||
"""Hapus attempt (one-attempt) — dipakai saat guru me-reset progress."""
|
"""Hapus attempt (one-attempt) — dipakai saat guru me-reset progress."""
|
||||||
attempts = list(
|
attempts = list(
|
||||||
|
|
@ -282,6 +295,7 @@ def finalize_quiz_attempt(
|
||||||
finished_at: datetime,
|
finished_at: datetime,
|
||||||
visibility_event_count: int,
|
visibility_event_count: int,
|
||||||
user_agent: str | None = None,
|
user_agent: str | None = None,
|
||||||
|
answers_json: str | None = None,
|
||||||
) -> tuple[QuizAttempt, bool]:
|
) -> tuple[QuizAttempt, bool]:
|
||||||
"""Insert/confirm SATU attempt kuis — idempotent per `attempt_id`.
|
"""Insert/confirm SATU attempt kuis — idempotent per `attempt_id`.
|
||||||
|
|
||||||
|
|
@ -315,6 +329,7 @@ def finalize_quiz_attempt(
|
||||||
finished_at=finished_at,
|
finished_at=finished_at,
|
||||||
visibility_event_count=visibility_event_count,
|
visibility_event_count=visibility_event_count,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
|
answers_json=answers_json,
|
||||||
)
|
)
|
||||||
db.add(attempt)
|
db.add(attempt)
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ validasi via HMAC-SHA256 digest + role eksplisit di tabel users.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|
@ -216,6 +217,41 @@ def get_all_students_progress(all_lessons_func):
|
||||||
student[f"{lesson.slug}_attempt_finished_at"] = (
|
student[f"{lesson.slug}_attempt_finished_at"] = (
|
||||||
attempt.finished_at.isoformat() if attempt and attempt.finished_at else ""
|
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)
|
student['completed_count'] = count_completed_lessons(db, user_id=user.id)
|
||||||
students.append(student)
|
students.append(student)
|
||||||
return students, ordered_lessons
|
return students, ordered_lessons
|
||||||
|
|
|
||||||
|
|
@ -198,3 +198,77 @@ def test_old_flat_format_still_parses_with_ids():
|
||||||
assert quiz[0]['type'] == 'mcq'
|
assert quiz[0]['type'] == 'mcq'
|
||||||
assert len(quiz[0]['options']) == 2
|
assert len(quiz[0]['options']) == 2
|
||||||
assert quiz[0]['options'][1]['is_correct'] is True
|
assert quiz[0]['options'][1]['is_correct'] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_category_is_evaluasi():
|
||||||
|
md_text = """### Mana yang benar?
|
||||||
|
- [] Salah
|
||||||
|
- [x] Benar
|
||||||
|
"""
|
||||||
|
quiz = _parse_flashcards(md_text)
|
||||||
|
assert len(quiz) == 1
|
||||||
|
assert quiz[0]['category'] == 'evaluasi'
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnostic_marker_on_own_line_sets_category_mcq():
|
||||||
|
md_text = """### Apa output dari `printf("%d", 5 + 5);`?
|
||||||
|
::diagnostic
|
||||||
|
- [] 55
|
||||||
|
- [x] 10
|
||||||
|
- [] 5 + 5
|
||||||
|
|
||||||
|
> Penjelasan: 5 + 5 = 10.
|
||||||
|
"""
|
||||||
|
quiz = _parse_flashcards(md_text)
|
||||||
|
assert len(quiz) == 1
|
||||||
|
q = quiz[0]
|
||||||
|
assert q['category'] == 'diagnostik'
|
||||||
|
# Marker tidak boleh bocor ke teks yang dirender
|
||||||
|
assert '::diagnostic' not in q['question']
|
||||||
|
assert len(q['options']) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnostic_marker_on_own_line_sets_category_flashcard():
|
||||||
|
md_text = """### Apa fungsi `return 0;`?
|
||||||
|
::diagnostic
|
||||||
|
Program selesai dengan sukses.
|
||||||
|
"""
|
||||||
|
quiz = _parse_flashcards(md_text)
|
||||||
|
assert len(quiz) == 1
|
||||||
|
q = quiz[0]
|
||||||
|
assert q['category'] == 'diagnostik'
|
||||||
|
assert '::diagnostic' not in q['front']
|
||||||
|
assert 'sukses' in q['back']
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnostic_marker_inline_in_heading_does_not_set_category():
|
||||||
|
"""Kontrak: marker wajib di baris sendiri — format inline (lama) diabaikan."""
|
||||||
|
md_text = """### Apa output dari `printf("%d", 5 + 5);` <-- ::diagnostic
|
||||||
|
- [] 55
|
||||||
|
- [x] 10
|
||||||
|
- [] 5 + 5
|
||||||
|
"""
|
||||||
|
quiz = _parse_flashcards(md_text)
|
||||||
|
assert len(quiz) == 1
|
||||||
|
q = quiz[0]
|
||||||
|
assert q['category'] == 'evaluasi'
|
||||||
|
# Konten heading tetap dipertahankan apa adanya (termasuk komentar)
|
||||||
|
assert '::diagnostic' in q['question']
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnostic_mixed_questions_categorized_independently():
|
||||||
|
md_text = """### Soal evaluasi 1
|
||||||
|
- [] a
|
||||||
|
- [x] b
|
||||||
|
|
||||||
|
### Soal diagnostik 1
|
||||||
|
::diagnostic
|
||||||
|
- [] a
|
||||||
|
- [x] b
|
||||||
|
|
||||||
|
### Soal evaluasi 2
|
||||||
|
- [] a
|
||||||
|
- [x] b
|
||||||
|
"""
|
||||||
|
quiz = _parse_flashcards(md_text)
|
||||||
|
assert [q['category'] for q in quiz] == ['evaluasi', 'diagnostik', 'evaluasi']
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ def _payload(overrides: dict | None = None) -> dict:
|
||||||
"occurred_at": "2026-08-09T14:04:44.000Z",
|
"occurred_at": "2026-08-09T14:04:44.000Z",
|
||||||
"started_at": "2026-08-09T14:03:00.000Z",
|
"started_at": "2026-08-09T14:03:00.000Z",
|
||||||
"visibility_event_count": 1,
|
"visibility_event_count": 1,
|
||||||
|
"answers": [],
|
||||||
}
|
}
|
||||||
if overrides:
|
if overrides:
|
||||||
data.update(overrides)
|
data.update(overrides)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue