fix(quiz): enforce 75% passing threshold, sync server progress, and persist attempt review
- Only show celebration overlay and top bar completed checkmark if quiz score meets 75% threshold (>= 0.75) and exercises are done - Return is_done, state, and composite_percent in POST /lesson-progress for exercise and quiz submissions - Synchronize frontend LessonManager state with authoritative server is_done response - Add has_quiz_attempt check in routes/lessons.py and token_service to recognize existing attempts even when composite state is in_progress - Update QuizTab to display persistent attempt review, score status, and explanation instead of retake prompt on page load/refresh - Make QuizQuestionView finish handler async, await finishQuiz(), and disable submission buttons to prevent navigation race conditions - Update local ordered_lessons completion state dynamically upon passing - Add unit and integration tests covering response payload attributes and has_quiz_attempt API field - Bump APP_VERSION to 30
This commit is contained in:
parent
9bfecfbcab
commit
1f4217be76
|
|
@ -173,6 +173,9 @@ export interface LessonProgressResponse {
|
||||||
idempotent?: boolean;
|
idempotent?: boolean;
|
||||||
attempt_id?: string;
|
attempt_id?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
is_done?: boolean;
|
||||||
|
state?: string;
|
||||||
|
composite_percent?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function submitLessonProgress(
|
export async function submitLessonProgress(
|
||||||
|
|
|
||||||
|
|
@ -47,5 +47,6 @@ export interface LessonContent {
|
||||||
evaluation_config: Record<string, any>;
|
evaluation_config: Record<string, any>;
|
||||||
quiz_data?: QuizQuestion[];
|
quiz_data?: QuizQuestion[];
|
||||||
lesson_progress_status?: string;
|
lesson_progress_status?: string;
|
||||||
|
has_quiz_attempt?: boolean;
|
||||||
hex_content?: string;
|
hex_content?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@
|
||||||
* Both Footer.svelte and service-worker.ts import from here.
|
* Both Footer.svelte and service-worker.ts import from here.
|
||||||
* Bump this number after every feature/bugfix release.
|
* Bump this number after every feature/bugfix release.
|
||||||
*/
|
*/
|
||||||
export const APP_VERSION = 29;
|
export const APP_VERSION = 30;
|
||||||
|
|
|
||||||
|
|
@ -9,21 +9,28 @@
|
||||||
|
|
||||||
const questions = $derived(mgr.quizSession?.questions ?? []);
|
const questions = $derived(mgr.quizSession?.questions ?? []);
|
||||||
|
|
||||||
|
let submitting = $state(false);
|
||||||
|
|
||||||
function isAnswered(qId: string): boolean {
|
function isAnswered(qId: string): boolean {
|
||||||
const a = mgr.quizSession?.answers[qId];
|
const a = mgr.quizSession?.answers[qId];
|
||||||
if (!a) return false;
|
if (!a) return false;
|
||||||
return a.selectedOptionId !== null || a.acknowledged;
|
return a.selectedOptionId !== null || a.acknowledged;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFinish() {
|
async function handleFinish() {
|
||||||
if (!mgr.quizAllAnswered) return;
|
if (!mgr.quizAllAnswered || submitting) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
window.confirm(
|
window.confirm(
|
||||||
'Apakah anda yakin untuk menyelesaikan kuis? Materi akan muncul kembali setelah ini.'
|
'Apakah anda yakin untuk menyelesaikan kuis? Materi akan muncul kembali setelah ini.'
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
mgr.finishQuiz();
|
submitting = true;
|
||||||
|
try {
|
||||||
|
await mgr.finishQuiz();
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -48,7 +55,7 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<button type="button" class="btn-exit-quiz" onclick={() => mgr.submitQuiz()}>Keluar Kuis</button>
|
<button type="button" class="btn-exit-quiz" onclick={() => mgr.submitQuiz()} disabled={submitting}>Keluar Kuis</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if mgr.quizCurrentQuestion}
|
{#if mgr.quizCurrentQuestion}
|
||||||
|
|
@ -87,9 +94,9 @@
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-success"
|
class="btn btn-success"
|
||||||
onclick={handleFinish}
|
onclick={handleFinish}
|
||||||
disabled={!mgr.quizAllAnswered}
|
disabled={!mgr.quizAllAnswered || submitting}
|
||||||
>
|
>
|
||||||
Selesai Kuis
|
{submitting ? 'Menyimpan...' : 'Selesai Kuis'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,19 @@
|
||||||
let { mgr }: Props = $props();
|
let { mgr }: Props = $props();
|
||||||
|
|
||||||
const isLoggedIn = $derived($authLoggedIn);
|
const isLoggedIn = $derived($authLoggedIn);
|
||||||
|
|
||||||
|
// --- Review-after-refresh: fetch stored attempt (one-attempt → session lost) ---
|
||||||
|
let storedAttempt: LessonProgressFetchResponse | null = $state(null);
|
||||||
|
let reviewQuestions: QuizQuestion[] = $state([]);
|
||||||
|
let reviewAnswers: Record<string, QuizAnswer> = $state({});
|
||||||
|
|
||||||
// Gate 2: Quiz already completed (one-attempt enforcement)
|
// Gate 2: Quiz already completed (one-attempt enforcement)
|
||||||
const quizAlreadyCompleted = $derived(
|
const quizAlreadyCompleted = $derived(
|
||||||
!!mgr.data?.lesson_progress_status &&
|
(!!mgr.data?.lesson_progress_status &&
|
||||||
mgr.data.lesson_progress_status !== 'not_started' &&
|
mgr.data.lesson_progress_status !== 'not_started' &&
|
||||||
mgr.data.lesson_progress_status !== ''
|
mgr.data.lesson_progress_status !== '') ||
|
||||||
|
!!mgr.data?.has_quiz_attempt ||
|
||||||
|
!!storedAttempt
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentQuestion = $derived(mgr.currentQuizQuestion);
|
const currentQuestion = $derived(mgr.currentQuizQuestion);
|
||||||
|
|
@ -31,11 +39,6 @@
|
||||||
// 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: LessonProgressFetchResponse | null = $state(null);
|
|
||||||
let reviewQuestions: QuizQuestion[] = $state([]);
|
|
||||||
let reviewAnswers: Record<string, QuizAnswer> = $state({});
|
|
||||||
|
|
||||||
async function loadStoredAttempt() {
|
async function loadStoredAttempt() {
|
||||||
const token = get(authToken) || localStorage.getItem('student_token') || '';
|
const token = get(authToken) || localStorage.getItem('student_token') || '';
|
||||||
if (!token || !mgr.data) return;
|
if (!token || !mgr.data) return;
|
||||||
|
|
@ -43,7 +46,7 @@
|
||||||
try {
|
try {
|
||||||
const resp = await fetchLessonProgress(lessonName, token);
|
const resp = await fetchLessonProgress(lessonName, token);
|
||||||
const storedAnswers = resp.answers ?? [];
|
const storedAnswers = resp.answers ?? [];
|
||||||
if (resp.success && storedAnswers.length) {
|
if (resp.success && (storedAnswers.length > 0 || resp.attempt_id)) {
|
||||||
storedAttempt = resp;
|
storedAttempt = resp;
|
||||||
// Rekonstruksi question+answer maps dari stored attempt agar
|
// Rekonstruksi question+answer maps dari stored attempt agar
|
||||||
// summary review per-soal bisa dirender setelah refresh.
|
// summary review per-soal bisa dirender setelah refresh.
|
||||||
|
|
@ -71,7 +74,7 @@
|
||||||
// Fetch attempt bila sudah selesai tapi session hilang (refresh/page-open)
|
// Fetch attempt bila sudah selesai tapi session hilang (refresh/page-open)
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const r = result;
|
const r = result;
|
||||||
if (!r && quizAlreadyCompleted && !mgr.quizSession) {
|
if (!r && (quizAlreadyCompleted || mgr.data?.has_quiz_attempt) && !mgr.quizSession) {
|
||||||
loadStoredAttempt();
|
loadStoredAttempt();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -113,13 +116,24 @@
|
||||||
{#if quizAlreadyCompleted && !result}
|
{#if quizAlreadyCompleted && !result}
|
||||||
{#if storedAttempt}
|
{#if storedAttempt}
|
||||||
{@const s = storedAttempt}
|
{@const s = storedAttempt}
|
||||||
<p class="score-display">Nilai Anda: <strong>{s.attempt_score ?? s.quiz_score_total != null ? `${s.quiz_score_earned ?? '-'}/${s.quiz_score_total}` : mgr.data?.lesson_progress_status}</strong></p>
|
{@const earned = s.quiz_score_earned ?? (s.attempt_score?.includes('/') ? parseInt(s.attempt_score.split('/')[0], 10) : null)}
|
||||||
|
{@const total = s.quiz_score_total ?? (s.attempt_score?.includes('/') ? parseInt(s.attempt_score.split('/')[1], 10) : null)}
|
||||||
|
<p class="score-display">Nilai Anda: <strong>{s.attempt_score ?? (total != null ? `${earned ?? '-'}/${total}` : mgr.data?.lesson_progress_status)}</strong></p>
|
||||||
{#if s.attempt_started_at || s.attempt_status}
|
{#if s.attempt_started_at || s.attempt_status}
|
||||||
<div class="score-breakdown">
|
<div class="score-breakdown">
|
||||||
{#if s.attempt_status}<span class="breakdown-item">Status: <strong>{s.attempt_status}</strong></span>{/if}
|
{#if s.attempt_status}<span class="breakdown-item">Status: <strong>{s.attempt_status}</strong></span>{/if}
|
||||||
{#if s.attempt_score}<span class="breakdown-item">Skor: <strong>{s.attempt_score}</strong></span>{/if}
|
{#if s.attempt_score}<span class="breakdown-item">Skor: <strong>{s.attempt_score}</strong></span>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if total != null && total > 0 && (earned ?? 0) / total < 0.75}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
Nilai Anda di bawah ambang batas 75%. Pelajari kembali topik di bawah ini.
|
||||||
|
</div>
|
||||||
|
{:else if total != null && total > 0}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
Selamat! Anda telah memahami materi ini dengan baik.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="score-display">Nilai Anda: <strong>{mgr.data?.lesson_progress_status}</strong></p>
|
<p class="score-display">Nilai Anda: <strong>{mgr.data?.lesson_progress_status}</strong></p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -249,13 +249,32 @@ export class LessonManager {
|
||||||
|
|
||||||
async completeLesson(status = 'completed') {
|
async completeLesson(status = 'completed') {
|
||||||
if (this.lessonCompleted && status === 'completed') return;
|
if (this.lessonCompleted && status === 'completed') return;
|
||||||
this.showCelebration = true;
|
|
||||||
|
|
||||||
if (get(authLoggedIn)) {
|
if (get(authLoggedIn)) {
|
||||||
const lessonName = this.slug.replace('.md', '');
|
const lessonName = this.slug.replace('.md', '');
|
||||||
await submitLessonProgress({ token: auth.token, lesson_name: lessonName, type: 'exercise' });
|
const res = await submitLessonProgress({ token: auth.token, lesson_name: lessonName, type: 'exercise' });
|
||||||
|
const isDone = res && typeof res.is_done === 'boolean' ? res.is_done : true;
|
||||||
|
this.lessonCompleted = isDone;
|
||||||
|
if (isDone) {
|
||||||
|
this.showCelebration = true;
|
||||||
|
}
|
||||||
|
lessonContext.update(ctx => ctx ? { ...ctx, completed: isDone } : ctx);
|
||||||
|
if (this.data?.ordered_lessons) {
|
||||||
|
const current = this.data.ordered_lessons.find(
|
||||||
|
(l) => l.filename === this.slug || l.filename.replace('.md', '') === this.slug.replace('.md', '')
|
||||||
|
);
|
||||||
|
if (current) current.completed = isDone;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.showCelebration = true;
|
||||||
this.lessonCompleted = true;
|
this.lessonCompleted = true;
|
||||||
lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx);
|
lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx);
|
||||||
|
if (this.data?.ordered_lessons) {
|
||||||
|
const current = this.data.ordered_lessons.find(
|
||||||
|
(l) => l.filename === this.slug || l.filename.replace('.md', '') === this.slug.replace('.md', '')
|
||||||
|
);
|
||||||
|
if (current) current.completed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -384,10 +403,30 @@ export class LessonManager {
|
||||||
const payload = this.buildQuizAttemptPayload(reason);
|
const payload = this.buildQuizAttemptPayload(reason);
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
|
|
||||||
// Update status UI lokal (tanpa menulis progress ulang — endpoint
|
// Hitung perkiraan awal (optimistic) lulus/tidaknya kuis (ambang batas 75%)
|
||||||
// attempt atomic yang menulis progress). Celebration hanya untuk
|
const isQuizScorePassed =
|
||||||
// penyelesaian normal, bukan exit penalti/termination.
|
reason === 'completed' && this.quizResult
|
||||||
this.markQuizCompletedLocally(reason === 'completed');
|
? this.quizResult.totalMcq > 0
|
||||||
|
? this.quizResult.correctMcq / this.quizResult.totalMcq >= 0.75
|
||||||
|
: true
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const hasExerciseTab =
|
||||||
|
this.data?.active_tabs?.some((t) =>
|
||||||
|
['c', 'python', 'circuit', 'flowchart', 'velxio'].includes(t)
|
||||||
|
) ?? false;
|
||||||
|
|
||||||
|
// Jika lesson juga memiliki latihan dan latihan belum selesai, kuis saja belum menyelesaikan lesson
|
||||||
|
const exerciseDone =
|
||||||
|
this.data?.lesson_progress_status?.startsWith('done:1:') ||
|
||||||
|
this.cPassed ||
|
||||||
|
this.pythonPassed ||
|
||||||
|
this.circuitPassed ||
|
||||||
|
this.flowchartPassed;
|
||||||
|
const optimisticLessonDone = isQuizScorePassed && (!hasExerciseTab || exerciseDone);
|
||||||
|
|
||||||
|
// Update status UI lokal secara optimis
|
||||||
|
this.markQuizCompletedLocally(optimisticLessonDone);
|
||||||
|
|
||||||
if (reason === 'focus_lost' || reason === 'page_unload') {
|
if (reason === 'focus_lost' || reason === 'page_unload') {
|
||||||
// Jangan mengandalkan await dari event lifecycle. Beacon pertama;
|
// Jangan mengandalkan await dari event lifecycle. Beacon pertama;
|
||||||
|
|
@ -395,21 +434,31 @@ export class LessonManager {
|
||||||
const ok = lessonProgressBeacon(payload);
|
const ok = lessonProgressBeacon(payload);
|
||||||
if (!ok && typeof document !== 'undefined' && document.visibilityState === 'visible') {
|
if (!ok && typeof document !== 'undefined' && document.visibilityState === 'visible') {
|
||||||
try {
|
try {
|
||||||
await submitLessonProgress(payload);
|
const res = await submitLessonProgress(payload);
|
||||||
|
if (this.data) this.data.has_quiz_attempt = true;
|
||||||
|
if (res && res.success && typeof res.is_done === 'boolean') {
|
||||||
|
this.markQuizCompletedLocally(res.is_done);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* best-effort saja — jangan memblokir UI */
|
/* best-effort saja — jangan memblokir UI */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await submitLessonProgress(payload);
|
const res = await submitLessonProgress(payload);
|
||||||
|
if (this.data) this.data.has_quiz_attempt = true;
|
||||||
|
if (res && res.success && typeof res.is_done === 'boolean') {
|
||||||
|
this.markQuizCompletedLocally(res.is_done);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback legacy: skor tetap tersimpan walau endpoint attempt
|
// Fallback legacy: skor tetap tersimpan walau endpoint attempt
|
||||||
// tidak bisa dijangkau. Tidak membuat attempt kedua — endpoint
|
// tidak bisa dijangkau. Tidak membuat attempt kedua — endpoint
|
||||||
// attempt atomic; jalur ini hanya penyelamat last-resort.
|
// attempt atomic; jalur ini hanya penyelamat last-resort.
|
||||||
await this.completeLesson(this.quizResult.statusString);
|
if (isQuizScorePassed) {
|
||||||
|
await this.completeLesson(this.quizResult.statusString);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -418,10 +467,16 @@ export class LessonManager {
|
||||||
* memanggil endpoint terpisah — attempt atomic /api/lesson-progress
|
* memanggil endpoint terpisah — attempt atomic /api/lesson-progress
|
||||||
* yang menulis progress.
|
* yang menulis progress.
|
||||||
*/
|
*/
|
||||||
private markQuizCompletedLocally(celebrate: boolean) {
|
private markQuizCompletedLocally(passed: boolean) {
|
||||||
this.lessonCompleted = true;
|
this.lessonCompleted = passed;
|
||||||
if (celebrate) this.showCelebration = true;
|
this.showCelebration = passed;
|
||||||
lessonContext.update((ctx) => (ctx ? { ...ctx, completed: true } : ctx));
|
lessonContext.update((ctx) => (ctx ? { ...ctx, completed: passed } : ctx));
|
||||||
|
if (this.data?.ordered_lessons) {
|
||||||
|
const current = this.data.ordered_lessons.find(
|
||||||
|
(l) => l.filename === this.slug || l.filename.replace('.md', '') === this.slug.replace('.md', '')
|
||||||
|
);
|
||||||
|
if (current) current.completed = passed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -84,9 +84,10 @@ def submit_lesson_progress():
|
||||||
|
|
||||||
has_exercise, has_quiz = get_lesson_components(lesson_name)
|
has_exercise, has_quiz = get_lesson_components(lesson_name)
|
||||||
|
|
||||||
|
progress_rec = None
|
||||||
if progress_type == "exercise":
|
if progress_type == "exercise":
|
||||||
repo.set_exercise_passed(db, user_id=user.id, lesson_id=lesson.id)
|
repo.set_exercise_passed(db, user_id=user.id, lesson_id=lesson.id)
|
||||||
repo.recompute_progress(
|
progress_rec = repo.recompute_progress(
|
||||||
db,
|
db,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
lesson_id=lesson.id,
|
lesson_id=lesson.id,
|
||||||
|
|
@ -97,7 +98,19 @@ def submit_lesson_progress():
|
||||||
done_min_percent=LESSON_DONE_MIN_PERCENT,
|
done_min_percent=LESSON_DONE_MIN_PERCENT,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return jsonify({"success": True, "idempotent": False})
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"idempotent": False,
|
||||||
|
"is_done": progress_rec.state == "done" if progress_rec else False,
|
||||||
|
"state": progress_rec.state if progress_rec else "not_started",
|
||||||
|
"composite_percent": (
|
||||||
|
round(progress_rec.composite_percent, 2)
|
||||||
|
if progress_rec and progress_rec.composite_percent is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# ── progress_type == 'quiz' ── mirror submit_quiz_attempt ─────
|
# ── progress_type == 'quiz' ── mirror submit_quiz_attempt ─────
|
||||||
attempt_id = str(data.get("attempt_id") or "").strip()
|
attempt_id = str(data.get("attempt_id") or "").strip()
|
||||||
|
|
@ -199,7 +212,7 @@ def submit_lesson_progress():
|
||||||
quiz_earned=earned,
|
quiz_earned=earned,
|
||||||
quiz_total=total,
|
quiz_total=total,
|
||||||
)
|
)
|
||||||
repo.recompute_progress(
|
progress_rec = repo.recompute_progress(
|
||||||
db,
|
db,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
lesson_id=lesson.id,
|
lesson_id=lesson.id,
|
||||||
|
|
@ -209,6 +222,8 @@ def submit_lesson_progress():
|
||||||
quiz_weight=LESSON_QUIZ_WEIGHT,
|
quiz_weight=LESSON_QUIZ_WEIGHT,
|
||||||
done_min_percent=LESSON_DONE_MIN_PERCENT,
|
done_min_percent=LESSON_DONE_MIN_PERCENT,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
progress_rec = repo.get_progress(db, user_id=user.id, lesson_id=lesson.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
@ -231,6 +246,13 @@ def submit_lesson_progress():
|
||||||
"success": True,
|
"success": True,
|
||||||
"idempotent": not created,
|
"idempotent": not created,
|
||||||
"attempt_id": attempt.id,
|
"attempt_id": attempt.id,
|
||||||
|
"is_done": progress_rec.state == "done" if progress_rec else False,
|
||||||
|
"state": progress_rec.state if progress_rec else "not_started",
|
||||||
|
"composite_percent": (
|
||||||
|
round(progress_rec.composite_percent, 2)
|
||||||
|
if progress_rec and progress_rec.composite_percent is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from services.lesson_service import (
|
||||||
render_home_content,
|
render_home_content,
|
||||||
find_lesson_file,
|
find_lesson_file,
|
||||||
)
|
)
|
||||||
from services.token_service import get_student_progress
|
from services.token_service import get_student_progress, has_quiz_attempt
|
||||||
|
|
||||||
lessons_bp = Blueprint('lessons', __name__)
|
lessons_bp = Blueprint('lessons', __name__)
|
||||||
|
|
||||||
|
|
@ -142,12 +142,15 @@ def api_lesson(filename):
|
||||||
progress = None
|
progress = None
|
||||||
lesson_completed = False
|
lesson_completed = False
|
||||||
lesson_progress_status = ''
|
lesson_progress_status = ''
|
||||||
|
lesson_slug = full_filename.replace('.md', '')
|
||||||
|
user_has_quiz_attempt = False
|
||||||
if token:
|
if token:
|
||||||
progress = get_student_progress(token)
|
progress = get_student_progress(token)
|
||||||
if progress:
|
if progress:
|
||||||
status = progress.get(full_filename.replace('.md', ''), '')
|
status = progress.get(lesson_slug, '')
|
||||||
lesson_progress_status = status
|
lesson_progress_status = status
|
||||||
lesson_completed = status not in (None, '', 'not_started')
|
lesson_completed = status not in (None, '', 'not_started')
|
||||||
|
user_has_quiz_attempt = has_quiz_attempt(token, lesson_slug)
|
||||||
|
|
||||||
# Detect sub-home for this lesson's folder (untuk sidebar & navigasi)
|
# Detect sub-home for this lesson's folder (untuk sidebar & navigasi)
|
||||||
sub_home_path, sub_home_folder = find_sub_home_for_lesson(file_path)
|
sub_home_path, sub_home_folder = find_sub_home_for_lesson(file_path)
|
||||||
|
|
@ -262,6 +265,7 @@ def api_lesson(filename):
|
||||||
'lesson_progress_status': lesson_progress_status,
|
'lesson_progress_status': lesson_progress_status,
|
||||||
'lesson_title': current_lesson_meta['title'] if current_lesson_meta else full_filename.replace('.md', '').replace('_', ' ').title(),
|
'lesson_title': current_lesson_meta['title'] if current_lesson_meta else full_filename.replace('.md', '').replace('_', ' ').title(),
|
||||||
'lesson_completed': lesson_completed,
|
'lesson_completed': lesson_completed,
|
||||||
|
'has_quiz_attempt': user_has_quiz_attempt,
|
||||||
'locked': is_locked,
|
'locked': is_locked,
|
||||||
'missing_prerequisites': missing_prereqs,
|
'missing_prerequisites': missing_prereqs,
|
||||||
'prev_lesson': prev_lesson,
|
'prev_lesson': prev_lesson,
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,26 @@ def get_student_progress(token):
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def has_quiz_attempt(token, lesson_name):
|
||||||
|
"""Cek apakah siswa sudah memiliki rekaman attempt kuis untuk lesson ini."""
|
||||||
|
if not token or SessionLocal is None:
|
||||||
|
return False
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
user = find_user_by_raw_token(db, token)
|
||||||
|
if user is None:
|
||||||
|
return False
|
||||||
|
lesson = get_lesson_by_slug(db, lesson_name)
|
||||||
|
if lesson is None:
|
||||||
|
return False
|
||||||
|
attempt = repo.get_quiz_attempt_for_user_lesson(
|
||||||
|
db, user_id=user.id, lesson_id=lesson.id
|
||||||
|
)
|
||||||
|
return attempt is not None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def reset_progress(student_id, lesson_name):
|
def reset_progress(student_id, lesson_name):
|
||||||
"""Reset via student_id = user.id (PG) — teacher tidak perlu token siswa.
|
"""Reset via student_id = user.id (PG) — teacher tidak perlu token siswa.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,25 @@ def test_quiz_submit_creates_attempt(client):
|
||||||
assert body["success"] is True
|
assert body["success"] is True
|
||||||
assert body["idempotent"] is False
|
assert body["idempotent"] is False
|
||||||
assert "attempt_id" in body
|
assert "attempt_id" in body
|
||||||
|
assert body["is_done"] is True
|
||||||
|
assert body["state"] == "done"
|
||||||
|
assert body["composite_percent"] == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_quiz_submit_below_threshold_marks_in_progress(client, monkeypatch):
|
||||||
|
import routes.lesson_progress as lp
|
||||||
|
|
||||||
|
monkeypatch.setattr(lp, "get_lesson_components", lambda name: (False, True))
|
||||||
|
resp = client.post(
|
||||||
|
"/lesson-progress",
|
||||||
|
json=_payload({"status": "submitted", "termination_reason": None, "score": "1/4"}),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["success"] is True
|
||||||
|
assert body["is_done"] is False
|
||||||
|
assert body["state"] == "in_progress"
|
||||||
|
assert body["composite_percent"] == 25.0
|
||||||
|
|
||||||
|
|
||||||
def test_quiz_retry_same_attempt_id_idempotent(client):
|
def test_quiz_retry_same_attempt_id_idempotent(client):
|
||||||
|
|
|
||||||
|
|
@ -303,3 +303,13 @@ def test_lesson_detail_missing_prerequisites_with_score(client, tmp_path, monkey
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
assert data["locked"] is True
|
assert data["locked"] is True
|
||||||
assert data["missing_prerequisites"] == ["dasar"]
|
assert data["missing_prerequisites"] == ["dasar"]
|
||||||
|
assert "has_quiz_attempt" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_lesson_detail_has_quiz_attempt_field(client, tmp_path, monkeypatch):
|
||||||
|
_make_bab_with_prereq(tmp_path, monkeypatch, "- [Dasar](lesson/dasar.md)")
|
||||||
|
from routes import lessons as lessons_routes
|
||||||
|
monkeypatch.setattr(lessons_routes, "has_quiz_attempt", lambda token, slug: True)
|
||||||
|
resp = client.get("/lesson/lanjutan.json?token=siswa1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["has_quiz_attempt"] is True
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@ def get_student_progress(token):
|
||||||
return _backend().get_student_progress(token)
|
return _backend().get_student_progress(token)
|
||||||
|
|
||||||
|
|
||||||
|
def has_quiz_attempt(token, lesson_name):
|
||||||
|
"""Cek apakah siswa sudah memiliki rekaman attempt kuis untuk lesson."""
|
||||||
|
return _backend().has_quiz_attempt(token, lesson_name)
|
||||||
|
|
||||||
|
|
||||||
def reset_student_progress(student_id, lesson_name):
|
def reset_student_progress(student_id, lesson_name):
|
||||||
"""Reset progress siswa by student_id (id anonim dari report — bukan token)."""
|
"""Reset progress siswa by student_id (id anonim dari report — bukan token)."""
|
||||||
return _backend().reset_progress(student_id, lesson_name)
|
return _backend().reset_progress(student_id, lesson_name)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue