elemes/routes/progress.py

295 lines
11 KiB
Python

"""
Student progress tracking and reporting JSON API routes.
"""
import csv
import io
import json
import logging
import re
from flask import Blueprint, request, jsonify, Response
from services.token_service import (
validate_token,
reset_student_progress,
resume_quiz_attempt,
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,
)
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}'})
@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}'})
@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()
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
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({
'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
],
})
resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return resp
@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()
if not token:
return jsonify({'success': False, 'message': 'Unauthorized'}), 401
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(
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]
# 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",
])
fieldnames.append('completed_count')
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] = ""
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)
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"
return Response(
output.getvalue(),
mimetype='text/csv; charset=utf-8',
headers={"Content-Disposition": f"attachment; filename={filename}"},
)