295 lines
11 KiB
Python
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}"},
|
|
)
|
|
|