elemes/routes/progress.py

295 lines
11 KiB
Python
Raw Normal View History

2026-03-25 09:39:51 +07:00
"""
Student progress tracking and reporting JSON API routes.
"""
import csv
import io
import json
2026-03-25 09:39:51 +07:00
import logging
import re
2026-03-25 09:39:51 +07:00
from flask import Blueprint, request, jsonify, Response
from services.token_service import (
validate_token,
reset_student_progress,
feat(quiz): izinkan guru melanjutkan attempt terminated dari jawaban tersimpan Kebijakan termination tetap strict, tapi guru pegang override untuk kendala tak terduga (false-positive blur dari dialog OS, browser crash, refresh tak sengaja). Selain reset (siswa mengulang dari nol), guru kini bisa memberi izin resume — siswa melanjutkan dari jawaban yang sudah tersimpan. - Tambah kolom quiz_attempts.resumed_at (migrasi 0006) sebagai penanda izin guru melanjutkan attempt terminated - Tambah endpoint teacher-only POST /resume-quiz-attempt — kontrak keamanan sama dengan /reset-progress (guru cukup student_id anonim, tanpa token siswa) - Syarat: hanya attempt ber-status terminated yang bisa di-resume; submitted ditolak karena kuis sudah selesai normal - has_quiz_attempt meloloskan attempt ber-izin sehingga gate one-attempt terbuka selama sesi lanjutan - finalize_quiz_attempt menimpa row attempt sekali (status/skor/answers baru) lalu clear resumed_at; retry berikutnya kembali ke jalur idempotent normal - Sinyal ke frontend siswa lewat quiz_resumable pada data lesson; laporan guru menampilkan badge "Dilanjutkan" dan tombol "Lanjutkan" untuk attempt terminated - Frontend: restoreQuizSession() merekonstruksi sesi dari jawaban tersimpan per question_id, is_correct dihitung ulang dari source sebagai guard bila konten soal berubah; option_id tak dikenal dianggap belum dijawab - Frontend: firstUnansweredIndex() menentukan soal awal sesi lanjutan; resolveQuizTabView() memusatkan keputusan view agar transisi resume dapat diuji Vitest - UI siswa: panel "Lanjutkan Kuis" + snackbar mode lanjutan (sekali muncul). Attempt di-refetch segar sebelum sesi dimulai agar izin yang sudah dicabut guru tidak memulai sesi yang jawabannya akan terbuang - Test: 16 integrasi backend (services/tests/test_resume_quiz.py), 9 unit quiz-session, 10 unit quiz-view - Docs: alur resume + keterbatasan di 12-quiz-integrity.md, ringkasan di 07-quiz-authoring.md, kontrak endpoint di 02-backend-flask.md
2026-10-20 19:24:40 +07:00
resume_quiz_attempt,
2026-03-25 09:39:51 +07:00
get_all_students_progress,
)
from services.lesson_service import (
get_lessons_with_learning_objectives,
find_lesson_file,
find_sub_home_for_lesson,
get_sub_home_data,
find_all_sub_home_folders,
)
2026-03-25 09:39:51 +07:00
progress_bp = Blueprint('progress', __name__)
def _masked(token: str) -> str:
"""Awalan token untuk log — jangan pernah log token mentah."""
return f"{token[:6]}..." if token else "(kosong)"
def _weighted_points_compact(raw) -> str:
"""JSON `_weighted_points` → compact CSV `10:5|7:2|0:1`.
Kosong bila kuis tidak punya soal weighted (atau payload lama).
"""
if not isinstance(raw, str) or not raw:
return ""
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return ""
points = data.get("points") if isinstance(data, dict) else None
if not isinstance(points, list):
return ""
parts = []
for p in points:
if not isinstance(p, dict):
continue
try:
parts.append(f"{int(p['weight'])}:{int(p['count'])}")
except (KeyError, TypeError, ValueError):
continue
return "|".join(parts)
def _safe_filename_part(value: str) -> str:
"""Bersihkan nama scope utk filename CSV (folder bisa mengandung '/')."""
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip())
return cleaned.strip("-._") or "scope"
@progress_bp.route('/reset-progress', methods=['POST'])
def reset_progress():
"""Reset progress siswa via student_id (Teacher only).
Kontrak keamanan: teacher TIDAK perlu (dan tidak boleh) memegang token
siswa — cukup id anonim dari payload report.
"""
try:
data = request.get_json()
teacher_token = data.get('teacher_token', '').strip()
student_id = data.get('student_id', '').strip()
lesson_name = data.get('lesson_name', '').strip()
if not teacher_token or not student_id or not lesson_name:
return jsonify({'success': False, 'message': 'All fields are required'}), 400
# Validasi teacher token
teacher_info = validate_token(teacher_token)
if not teacher_info or not teacher_info.get('is_teacher'):
return jsonify({'success': False, 'message': 'Unauthorized (Teacher only)'}), 401
# Perform reset (set to not_started)
updated = reset_student_progress(student_id, lesson_name)
if updated:
logging.info(
"Progress reset oleh teacher=%s untuk student_id=%s, lesson=%s",
teacher_info.get('student_name'), _masked(student_id), lesson_name,
)
return jsonify({'success': True, 'message': 'Progress reset successfully'})
else:
return jsonify({'success': False, 'message': 'Failed to reset progress'})
except Exception as e:
logging.error(f"Error in reset-progress: {e}")
return jsonify({'success': False, 'message': f'Error resetting progress: {e}'})
feat(quiz): izinkan guru melanjutkan attempt terminated dari jawaban tersimpan Kebijakan termination tetap strict, tapi guru pegang override untuk kendala tak terduga (false-positive blur dari dialog OS, browser crash, refresh tak sengaja). Selain reset (siswa mengulang dari nol), guru kini bisa memberi izin resume — siswa melanjutkan dari jawaban yang sudah tersimpan. - Tambah kolom quiz_attempts.resumed_at (migrasi 0006) sebagai penanda izin guru melanjutkan attempt terminated - Tambah endpoint teacher-only POST /resume-quiz-attempt — kontrak keamanan sama dengan /reset-progress (guru cukup student_id anonim, tanpa token siswa) - Syarat: hanya attempt ber-status terminated yang bisa di-resume; submitted ditolak karena kuis sudah selesai normal - has_quiz_attempt meloloskan attempt ber-izin sehingga gate one-attempt terbuka selama sesi lanjutan - finalize_quiz_attempt menimpa row attempt sekali (status/skor/answers baru) lalu clear resumed_at; retry berikutnya kembali ke jalur idempotent normal - Sinyal ke frontend siswa lewat quiz_resumable pada data lesson; laporan guru menampilkan badge "Dilanjutkan" dan tombol "Lanjutkan" untuk attempt terminated - Frontend: restoreQuizSession() merekonstruksi sesi dari jawaban tersimpan per question_id, is_correct dihitung ulang dari source sebagai guard bila konten soal berubah; option_id tak dikenal dianggap belum dijawab - Frontend: firstUnansweredIndex() menentukan soal awal sesi lanjutan; resolveQuizTabView() memusatkan keputusan view agar transisi resume dapat diuji Vitest - UI siswa: panel "Lanjutkan Kuis" + snackbar mode lanjutan (sekali muncul). Attempt di-refetch segar sebelum sesi dimulai agar izin yang sudah dicabut guru tidak memulai sesi yang jawabannya akan terbuang - Test: 16 integrasi backend (services/tests/test_resume_quiz.py), 9 unit quiz-session, 10 unit quiz-view - Docs: alur resume + keterbatasan di 12-quiz-integrity.md, ringkasan di 07-quiz-authoring.md, kontrak endpoint di 02-backend-flask.md
2026-10-20 19:24:40 +07:00
@progress_bp.route('/resume-quiz-attempt', methods=['POST'])
def resume_quiz_attempt_route():
"""Beri izin siswa melanjutkan kuis yang terminated (Teacher only).
Dipakai saat kendala tak terduga (mis. false-positive focus loss,
browser crash) — siswa melanjutkan dari jawaban tersimpan alih-alih
mengulang dari nol (reset). Kontrak keamanan sama dengan reset-progress:
teacher TIDAK perlu (dan tidak boleh) memegang token siswa — cukup id
anonim dari payload report.
"""
try:
data = request.get_json()
teacher_token = data.get('teacher_token', '').strip()
student_id = data.get('student_id', '').strip()
lesson_name = data.get('lesson_name', '').strip()
if not teacher_token or not student_id or not lesson_name:
return jsonify({'success': False, 'message': 'All fields are required'}), 400
# Validasi teacher token
teacher_info = validate_token(teacher_token)
if not teacher_info or not teacher_info.get('is_teacher'):
return jsonify({'success': False, 'message': 'Unauthorized (Teacher only)'}), 401
# Beri izin resume (attempt terminated → resumed_at di-set)
resumed = resume_quiz_attempt(student_id, lesson_name)
if resumed:
logging.info(
"Quiz attempt di-resume oleh teacher=%s untuk student_id=%s, lesson=%s",
teacher_info.get('student_name'), _masked(student_id), lesson_name,
)
return jsonify({'success': True, 'message': 'Quiz attempt resumed successfully'})
else:
return jsonify({'success': False, 'message': 'No terminated quiz attempt found for this student and lesson'})
except Exception as e:
logging.error(f"Error in resume-quiz-attempt: {e}")
return jsonify({'success': False, 'message': f'Error resuming quiz attempt: {e}'})
2026-03-25 09:39:51 +07:00
@progress_bp.route('/progress-report.json')
def api_progress_report():
"""Return progress report data as JSON."""
token = request.args.get('token', '').strip()
if not token:
token = request.cookies.get('student_token', '').strip()
2026-05-12 14:49:18 +07:00
if not token:
logging.warning("Unauthorized access attempt to progress-report.json: No token provided")
return jsonify({'success': False, 'message': 'Unauthorized: Token is required'}), 401
student_info = validate_token(token)
if not student_info:
logging.warning(f"Unauthorized access attempt to progress-report.json: Invalid token '{token[:6]}...'")
return jsonify({'success': False, 'message': 'Unauthorized: Invalid token'}), 401
# Security: Only teacher can see the full progress report
if not student_info.get('is_teacher'):
logging.warning(f"Unauthorized access attempt to progress-report.json: Student '{student_info.get('student_name')}' is not a teacher")
return jsonify({'success': False, 'message': 'Forbidden: Teacher access only'}), 403
2026-03-25 09:39:51 +07:00
all_students_progress, ordered_lessons = get_all_students_progress(
get_lessons_with_learning_objectives,
)
# Petakan sub-bab dari sub-home.md yang terdaftar
sub_home_folders = find_all_sub_home_folders()
sub_bab_map = {}
for folder in sub_home_folders:
data = get_sub_home_data(folder)
if data and data.get('title'):
sub_bab_map[folder] = data['title']
def _lesson_to_dict(lesson):
d = {'filename': lesson['filename'], 'title': lesson['title']}
file_path = lesson.get('path') or find_lesson_file(lesson['filename'])
if file_path:
_sub_home_path, folder_name = find_sub_home_for_lesson(file_path)
if folder_name and folder_name in sub_bab_map:
d['sub_bab'] = folder_name
d['sub_bab_title'] = sub_bab_map[folder_name]
return d
resp = jsonify({
2026-03-25 09:39:51 +07:00
'students': all_students_progress,
'lessons': [_lesson_to_dict(l) for l in ordered_lessons],
'sub_bab_list': [
{'folder': f, 'title': sub_bab_map[f]}
for f in sub_home_folders if f in sub_bab_map
],
2026-03-25 09:39:51 +07:00
})
resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return resp
2026-03-25 09:39:51 +07:00
@progress_bp.route('/progress-report/export-csv')
def export_progress_csv():
"""Export the progress report as CSV with composite scores."""
token = request.args.get('token', '').strip()
if not token:
token = request.cookies.get('student_token', '').strip()
2026-05-12 14:49:18 +07:00
if not token:
return jsonify({'success': False, 'message': 'Unauthorized'}), 401
2026-05-12 14:49:18 +07:00
student_info = validate_token(token)
if not student_info or not student_info.get('is_teacher'):
return jsonify({'success': False, 'message': 'Forbidden'}), 403
all_students_progress, ordered_lessons = get_all_students_progress(
2026-03-25 09:39:51 +07:00
get_lessons_with_learning_objectives,
)
# Scope export (opsional): ?bab=<folder> dan/atau ?materi=<slug>,
# keduanya repeatable. Tanpa param = semua materi (perilaku lama).
bab_filters = {v for v in request.args.getlist('bab') if v}
materi_filters = {v for v in request.args.getlist('materi') if v}
if bab_filters or materi_filters:
# Pemetaan sub-bab SAMA dengan /progress-report.json agar scope konsisten.
sub_bab_map = {}
for folder in find_all_sub_home_folders():
data = get_sub_home_data(folder)
if data and data.get('title'):
sub_bab_map[folder] = data['title']
filtered_lessons = []
for lesson in ordered_lessons:
slug = lesson['filename'].replace('.md', '')
if materi_filters and slug not in materi_filters:
continue
if bab_filters:
file_path = lesson.get('path') or find_lesson_file(lesson['filename'])
folder_name = None
if file_path:
_sub_home_path, folder_name = find_sub_home_for_lesson(file_path)
lesson_bab = folder_name if folder_name in sub_bab_map else None
if lesson_bab not in bab_filters:
# Dukung kartu "Lainnya (Materi Umum)".
if not (lesson_bab is None and '_uncategorized' in bab_filters):
continue
filtered_lessons.append(lesson)
ordered_lessons = filtered_lessons
lesson_slugs = [l['filename'].replace('.md', '') for l in ordered_lessons]
feat(quiz): taksonomi key quiz per jenis + MCQ berbobot (partial credit) Fitur B — setiap jenis soal kini punya key sendiri sehingga model penilaiannya eksplisit, tidak ditebak dari isi konten: - ---QUIZ_MCQ--- pilihan ganda biner (tepat satu [x]; larang [N]) - ---QUIZ_WEIGHTED--- pilihan ganda berbobot ([N] >= 1, minimal satu [ ], bobot tertinggi unik = kunci; poin parsial) - ---FLASHCARD--- kartu hafalan tanpa penilaian (larang opsi) - legacy ---QUIZ_FLASHCARD--- / ---INITIAL_QUIZ--- tetap dibaca tanpa perubahan perilaku (regex opsi legacy sengaja tidak berubah — baris '- [10]' di konten lama BUKAN opsi); dihapus dari docs/template - ::diagnostic tetap kategori di dalam QUIZ_MCQ & tetap masuk skor resmi Model skor: rata-rata persentase per jenis yang ada, Math.round, lulus >= 75. statusString: "X/Y" bila hanya MCQ/legacy, "P/100" bila ada weighted, "completed" bila hanya flashcard (auto-lulus). Satu sesi/ attempt untuk semua jenis; soal flat urut dokumen. Parser (backend & frontend mirror, ekstraksi multi-blok per key urut dokumen via loop marker-start paling awal; id q-N deterministik): - lesson_service.py: regex [ xX]?[0-9]* utk key baru, _iter_option_lines kind-aware, _validate_quiz_options per kind, _parse_flashcards(kind, id_offset), _extract_quiz_sections + guard loop maju (marker end hilang tidak lagi infinite loop), get_lesson_quiz_total -> 100 bila ada weighted (round-trip CSV "P/100") - parseFlashcards.ts / index.ts / detectActiveTabs.ts / extractSections.ts: mirror perilaku; heading soal <h3> kini fence-aware (fix fixture stale) Payload attempt baru: quiz_type, earned_points, question_max_points — opsional di validasi backend supaya klien lama tetap diterima; poin di-recompute dari konten saat resume/review. UI: QuizTab skor via statusString + alert berbasis percent, badge weighted (✓ 10 / 🟡 7/10 / ✗ 0) di review, preview guru badge bobot + "Berbobot (total N)", laporan guru badge MCQ/Bobot + tooltip, CSV progress 2 kolom baru per lesson (_quiz_mcq, _quiz_weighted), template editor konten QUIZ_MCQ/Quiz Bobot/Flashcard. Tooling & docs: scripts/convert_quiz_keys.py (konversi legacy -> key baru per run jenis, idempoten, verifikasi parse identik; examples/ content dikonversi), scanner mendukung semua key, docs/07 ditulis ulang, docs/15 direvisi ke desain final, README/documentation/docs 02/04/09 diperbarui. Tes: backend 444 lulus (+33: validasi ketat per key, urutan dokumen, multi-blok, zero regression, field selalu di-emit), frontend 249 lulus (+13 parser key baru, +11 model skor), parity fixture diregenerasi, svelte-check 0 error, vite build OK.
2026-10-25 20:16:56 +07:00
# Dua kolom breakdown JENIS per lesson (rapor guru): rasio benar MCQ dan
# poin weighted. Kolom kosong bila jenis itu tidak ada di kuis lesson.
fieldnames = ['nama_siswa']
for slug in lesson_slugs:
fieldnames.extend([
slug,
f"{slug}_quiz_mcq",
f"{slug}_quiz_weighted",
f"{slug}_weighted_points",
])
feat(quiz): taksonomi key quiz per jenis + MCQ berbobot (partial credit) Fitur B — setiap jenis soal kini punya key sendiri sehingga model penilaiannya eksplisit, tidak ditebak dari isi konten: - ---QUIZ_MCQ--- pilihan ganda biner (tepat satu [x]; larang [N]) - ---QUIZ_WEIGHTED--- pilihan ganda berbobot ([N] >= 1, minimal satu [ ], bobot tertinggi unik = kunci; poin parsial) - ---FLASHCARD--- kartu hafalan tanpa penilaian (larang opsi) - legacy ---QUIZ_FLASHCARD--- / ---INITIAL_QUIZ--- tetap dibaca tanpa perubahan perilaku (regex opsi legacy sengaja tidak berubah — baris '- [10]' di konten lama BUKAN opsi); dihapus dari docs/template - ::diagnostic tetap kategori di dalam QUIZ_MCQ & tetap masuk skor resmi Model skor: rata-rata persentase per jenis yang ada, Math.round, lulus >= 75. statusString: "X/Y" bila hanya MCQ/legacy, "P/100" bila ada weighted, "completed" bila hanya flashcard (auto-lulus). Satu sesi/ attempt untuk semua jenis; soal flat urut dokumen. Parser (backend & frontend mirror, ekstraksi multi-blok per key urut dokumen via loop marker-start paling awal; id q-N deterministik): - lesson_service.py: regex [ xX]?[0-9]* utk key baru, _iter_option_lines kind-aware, _validate_quiz_options per kind, _parse_flashcards(kind, id_offset), _extract_quiz_sections + guard loop maju (marker end hilang tidak lagi infinite loop), get_lesson_quiz_total -> 100 bila ada weighted (round-trip CSV "P/100") - parseFlashcards.ts / index.ts / detectActiveTabs.ts / extractSections.ts: mirror perilaku; heading soal <h3> kini fence-aware (fix fixture stale) Payload attempt baru: quiz_type, earned_points, question_max_points — opsional di validasi backend supaya klien lama tetap diterima; poin di-recompute dari konten saat resume/review. UI: QuizTab skor via statusString + alert berbasis percent, badge weighted (✓ 10 / 🟡 7/10 / ✗ 0) di review, preview guru badge bobot + "Berbobot (total N)", laporan guru badge MCQ/Bobot + tooltip, CSV progress 2 kolom baru per lesson (_quiz_mcq, _quiz_weighted), template editor konten QUIZ_MCQ/Quiz Bobot/Flashcard. Tooling & docs: scripts/convert_quiz_keys.py (konversi legacy -> key baru per run jenis, idempoten, verifikasi parse identik; examples/ content dikonversi), scanner mendukung semua key, docs/07 ditulis ulang, docs/15 direvisi ke desain final, README/documentation/docs 02/04/09 diperbarui. Tes: backend 444 lulus (+33: validasi ketat per key, urutan dokumen, multi-blok, zero regression, field selalu di-emit), frontend 249 lulus (+13 parser key baru, +11 model skor), parity fixture diregenerasi, svelte-check 0 error, vite build OK.
2026-10-25 20:16:56 +07:00
fieldnames.append('completed_count')
2026-03-25 09:39:51 +07:00
output = io.StringIO()
output.write('\ufeff')
writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=';')
writer.writeheader()
for student in all_students_progress:
row = {'nama_siswa': student.get('nama_siswa', '')}
for slug in lesson_slugs:
composite_val = student.get(f"{slug}_composite")
if composite_val is not None:
row[slug] = composite_val
else:
legacy = student.get(slug, '')
if legacy == 'completed':
row[slug] = 100
else:
row[slug] = ""
feat(quiz): taksonomi key quiz per jenis + MCQ berbobot (partial credit) Fitur B — setiap jenis soal kini punya key sendiri sehingga model penilaiannya eksplisit, tidak ditebak dari isi konten: - ---QUIZ_MCQ--- pilihan ganda biner (tepat satu [x]; larang [N]) - ---QUIZ_WEIGHTED--- pilihan ganda berbobot ([N] >= 1, minimal satu [ ], bobot tertinggi unik = kunci; poin parsial) - ---FLASHCARD--- kartu hafalan tanpa penilaian (larang opsi) - legacy ---QUIZ_FLASHCARD--- / ---INITIAL_QUIZ--- tetap dibaca tanpa perubahan perilaku (regex opsi legacy sengaja tidak berubah — baris '- [10]' di konten lama BUKAN opsi); dihapus dari docs/template - ::diagnostic tetap kategori di dalam QUIZ_MCQ & tetap masuk skor resmi Model skor: rata-rata persentase per jenis yang ada, Math.round, lulus >= 75. statusString: "X/Y" bila hanya MCQ/legacy, "P/100" bila ada weighted, "completed" bila hanya flashcard (auto-lulus). Satu sesi/ attempt untuk semua jenis; soal flat urut dokumen. Parser (backend & frontend mirror, ekstraksi multi-blok per key urut dokumen via loop marker-start paling awal; id q-N deterministik): - lesson_service.py: regex [ xX]?[0-9]* utk key baru, _iter_option_lines kind-aware, _validate_quiz_options per kind, _parse_flashcards(kind, id_offset), _extract_quiz_sections + guard loop maju (marker end hilang tidak lagi infinite loop), get_lesson_quiz_total -> 100 bila ada weighted (round-trip CSV "P/100") - parseFlashcards.ts / index.ts / detectActiveTabs.ts / extractSections.ts: mirror perilaku; heading soal <h3> kini fence-aware (fix fixture stale) Payload attempt baru: quiz_type, earned_points, question_max_points — opsional di validasi backend supaya klien lama tetap diterima; poin di-recompute dari konten saat resume/review. UI: QuizTab skor via statusString + alert berbasis percent, badge weighted (✓ 10 / 🟡 7/10 / ✗ 0) di review, preview guru badge bobot + "Berbobot (total N)", laporan guru badge MCQ/Bobot + tooltip, CSV progress 2 kolom baru per lesson (_quiz_mcq, _quiz_weighted), template editor konten QUIZ_MCQ/Quiz Bobot/Flashcard. Tooling & docs: scripts/convert_quiz_keys.py (konversi legacy -> key baru per run jenis, idempoten, verifikasi parse identik; examples/ content dikonversi), scanner mendukung semua key, docs/07 ditulis ulang, docs/15 direvisi ke desain final, README/documentation/docs 02/04/09 diperbarui. Tes: backend 444 lulus (+33: validasi ketat per key, urutan dokumen, multi-blok, zero regression, field selalu di-emit), frontend 249 lulus (+13 parser key baru, +11 model skor), parity fixture diregenerasi, svelte-check 0 error, vite build OK.
2026-10-25 20:16:56 +07:00
row[f"{slug}_quiz_mcq"] = student.get(f"{slug}_quiz_mcq", "")
row[f"{slug}_quiz_weighted"] = student.get(f"{slug}_quiz_weighted", "")
row[f"{slug}_weighted_points"] = _weighted_points_compact(
student.get(f"{slug}_weighted_points", "")
)
row['completed_count'] = student.get('completed_count', 0)
writer.writerow(row)
2026-03-25 09:39:51 +07:00
filename = 'progress_report.csv'
if bab_filters or materi_filters:
scope_parts = sorted(bab_filters) + sorted(materi_filters)
filename = f"progress_report_{'_'.join(_safe_filename_part(p) for p in scope_parts)}.csv"
2026-03-25 09:39:51 +07:00
return Response(
output.getvalue(),
mimetype='text/csv; charset=utf-8',
headers={"Content-Disposition": f"attachment; filename={filename}"},
2026-03-25 09:39:51 +07:00
)