""" 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= dan/atau ?materi=, # 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}"}, )