"""Unified lesson progress endpoint (exercise + quiz) — single-point token validation. POST /lesson-progress body: {token, lesson_name, type: 'exercise'|'quiz', ...type-specific fields} GET /lesson-progress/ query: ?token=... -> progress state + composite + quiz attempt audit Replaces the fragmented /track-progress (exercise) and /quiz-attempts/submit (quiz) endpoints. Token validation happens ONCE at the top of the POST/GET handlers. Route paths deliberately omit the /api prefix — the SvelteKit hooks.server.ts proxy strips /api and forwards to Flask. The OLD endpoints (routes/progress.py, routes/quiz_attempts.py) remain in place and are removed in a separate task. """ import json import logging from datetime import datetime, timezone from flask import Blueprint, jsonify, request from sqlalchemy.exc import IntegrityError from services import repositories as repo from services.database import SessionLocal from services.progress_status import parse_progress_status from services.student_roundtrip import is_canonical_uuid from config import LESSON_EXERCISE_WEIGHT, LESSON_QUIZ_WEIGHT, LESSON_DONE_MIN_PERCENT from services.lesson_service import render_markdown_content, find_lesson_file, get_lesson_components lesson_progress_bp = Blueprint("lesson_progress", __name__) logger = logging.getLogger(__name__) ALLOWED_REASONS = {"focus_lost", "spa_navigation", "page_unload", "user_exit", "completed"} MAX_PAYLOAD_BYTES = 64 * 1024 MAX_SCORE_LENGTH = 32 MAX_AGENT_LENGTH = 255 def _parse_ts(value, *, required: bool) -> datetime | None: """ISO-8601 timestamp -> datetime UTC-aware. Raise ValueError bila invalid.""" if value in (None, ""): if required: raise ValueError return None if not isinstance(value, str): raise ValueError return datetime.fromisoformat(value.replace("Z", "+00:00")) @lesson_progress_bp.route("/lesson-progress", methods=["POST"]) def submit_lesson_progress(): if request.content_length is not None and request.content_length > MAX_PAYLOAD_BYTES: return jsonify({"success": False, "message": "Payload terlalu besar"}), 413 data = request.get_json(silent=True) if not isinstance(data, dict): return jsonify({"success": False, "message": "Malformed JSON"}), 400 token = str(data.get("token") or "").strip() lesson_name = str(data.get("lesson_name") or "").strip() progress_type = str(data.get("type") or "").strip() # ── validasi field dasar ───────────────────────────────────────── if not token or not lesson_name: return jsonify({"success": False, "message": "Token dan lesson wajib diisi"}), 400 if progress_type not in ("exercise", "quiz"): return jsonify({"success": False, "message": "type harus 'exercise' atau 'quiz'"}), 400 if SessionLocal is None: return jsonify({"success": False, "message": "PostgreSQL tidak aktif"}), 503 db = SessionLocal() try: # ── SINGLE POINT OF TOKEN VALIDATION ────────────────────────── user = repo.get_user_by_token(db, token) if user is None: return jsonify({"success": False, "message": "Token tidak valid"}), 401 lesson = repo.get_lesson_by_slug(db, lesson_name) if lesson is None: return jsonify({"success": False, "message": "Lesson tidak dikenal"}), 404 has_exercise, has_quiz = get_lesson_components(lesson_name) progress_rec = None if progress_type == "exercise": repo.set_exercise_passed(db, user_id=user.id, lesson_id=lesson.id) progress_rec = repo.recompute_progress( db, user_id=user.id, lesson_id=lesson.id, has_exercise=has_exercise, has_quiz=has_quiz, exercise_weight=LESSON_EXERCISE_WEIGHT, quiz_weight=LESSON_QUIZ_WEIGHT, done_min_percent=LESSON_DONE_MIN_PERCENT, ) db.commit() 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 ───── attempt_id = str(data.get("attempt_id") or "").strip() status = str(data.get("status") or "").strip() reason_raw = data.get("termination_reason") termination_reason = str(reason_raw).strip() if reason_raw not in (None, "") else None score = str(data.get("score") or "").strip() visibility_count = data.get("visibility_event_count", 0) user_agent = (request.headers.get("User-Agent") or "")[:MAX_AGENT_LENGTH] if not is_canonical_uuid(attempt_id): return jsonify({"success": False, "message": "attempt_id harus UUID canonical"}), 400 if status not in ("submitted", "terminated"): return jsonify({"success": False, "message": "status harus submitted atau terminated"}), 400 if termination_reason is None: if status != "submitted": return jsonify( {"success": False, "message": "terminated wajib memiliki termination_reason"} ), 400 else: if status != "terminated": return jsonify( { "success": False, "message": "hanya status terminated yang boleh memiliki termination_reason", } ), 400 if termination_reason not in ALLOWED_REASONS: return jsonify( {"success": False, "message": "termination_reason tidak dikenal"} ), 400 if termination_reason == "focus_lost" and status != "terminated": return jsonify( {"success": False, "message": "focus_lost wajib berstatus terminated"} ), 400 if not score or len(score) > MAX_SCORE_LENGTH: return jsonify({"success": False, "message": "format skor tidak valid"}), 400 try: state, earned, total = parse_progress_status(score) except ValueError: return jsonify({"success": False, "message": "format skor tidak dikenal"}), 400 if state == "not_started": return jsonify({"success": False, "message": "skor tidak boleh not_started"}), 400 try: occurred_at = _parse_ts(data.get("occurred_at"), required=True) started_at = _parse_ts(data.get("started_at"), required=False) or occurred_at except ValueError: return jsonify({"success": False, "message": "timestamp tidak valid"}), 400 if ( not isinstance(visibility_count, int) or isinstance(visibility_count, bool) or visibility_count < 0 or visibility_count > 1000 ): return jsonify({"success": False, "message": "visibility_event_count tidak valid"}), 400 # answers: ringkasan per-soal [{question_id, selected_option_id, is_correct, category}] answers_raw = data.get("answers") answers_json = None if answers_raw is not None: if not isinstance(answers_raw, list): return jsonify({"success": False, "message": "answers harus berupa list"}), 400 try: answers_json = json.dumps(answers_raw) except (TypeError, ValueError): return jsonify({"success": False, "message": "answers tidak dapat diserialisasi"}), 400 try: attempt, created = repo.finalize_quiz_attempt( db, attempt_id=attempt_id, user_id=user.id, lesson_id=lesson.id, status=status, termination_reason=termination_reason, score_earned=earned, score_total=total, started_at=started_at, finished_at=datetime.now(timezone.utc), visibility_event_count=visibility_count, user_agent=user_agent, answers_json=answers_json, ) except ValueError as exc: db.rollback() return jsonify({"success": False, "message": str(exc)}), 409 # Update progress HANYA untuk attempt yang benar-benar baru — retry # idempotent tidak menulis ulang progress maupun row attempt. if created: repo.set_quiz_score( db, user_id=user.id, lesson_id=lesson.id, quiz_earned=earned, quiz_total=total, ) progress_rec = repo.recompute_progress( db, user_id=user.id, lesson_id=lesson.id, has_exercise=has_exercise, has_quiz=has_quiz, exercise_weight=LESSON_EXERCISE_WEIGHT, quiz_weight=LESSON_QUIZ_WEIGHT, done_min_percent=LESSON_DONE_MIN_PERCENT, ) else: progress_rec = repo.get_progress(db, user_id=user.id, lesson_id=lesson.id) db.commit() except IntegrityError: db.rollback() return jsonify( {"success": False, "message": "Attempt konflik — satu percobaan per kuis"} ), 409 except Exception: # noqa: BLE001 — jangan bocorkan exception internal db.rollback() logger.exception("lesson-progress submit gagal") return jsonify({"success": False, "message": "Gagal menyimpan progress"}), 500 finally: db.close() logger.info( "Lesson progress (quiz) disimpan: user_id=%s lesson=%s status=%s created=%s", user.id, lesson_name, status, created, ) return jsonify( { "success": True, "idempotent": not created, "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 ), } ) @lesson_progress_bp.route("/lesson-progress/", methods=["GET"]) def get_lesson_progress(lesson_name: str): """Ambil progress lesson + audit attempt kuis (review-after-refresh). Auth: student_token via query param `token` (siswa hanya lihat miliknya). Response: state, exercise_passed, quiz_score_*, composite_percent, serta field audit attempt (attempt_id, status, termination_reason, attempt_score, started_at, finished_at, answers). """ token = (request.args.get("token") or "").strip() if not token: return jsonify({"success": False, "message": "Token wajib"}), 400 if SessionLocal is None: return jsonify({"success": False, "message": "PostgreSQL tidak aktif"}), 503 db = SessionLocal() try: user = repo.get_user_by_token(db, token) if user is None: return jsonify({"success": False, "message": "Token tidak valid"}), 401 lesson = repo.get_lesson_by_slug(db, lesson_name) if lesson is None: return jsonify({"success": False, "message": "Lesson tidak dikenal"}), 404 progress = repo.get_progress(db, user_id=user.id, lesson_id=lesson.id) attempt = repo.get_quiz_attempt_for_user_lesson( db, user_id=user.id, lesson_id=lesson.id ) try: answers = json.loads(attempt.answers_json) if attempt and attempt.answers_json else [] except (json.JSONDecodeError, TypeError): answers = [] composite_percent = None if progress is not None and progress.composite_percent is not None: composite_percent = round(progress.composite_percent, 2) attempt_score = None if attempt is not None and attempt.score_earned is not None and attempt.score_total is not None: attempt_score = f"{attempt.score_earned}/{attempt.score_total}" return jsonify( { "success": True, "lesson_name": lesson_name, "state": progress.state if progress else "not_started", "exercise_passed": progress.exercise_passed if progress else None, "quiz_score_earned": progress.quiz_score_earned if progress else None, "quiz_score_total": progress.quiz_score_total if progress else None, "composite_percent": composite_percent, "attempt_id": attempt.id if attempt else None, "attempt_status": attempt.status if attempt else None, "termination_reason": attempt.termination_reason if attempt else None, "attempt_score": attempt_score, "attempt_started_at": attempt.started_at.isoformat() if attempt and attempt.started_at else None, "attempt_finished_at": attempt.finished_at.isoformat() if attempt and attempt.finished_at else None, "answers": answers, } ) except Exception: # noqa: BLE001 logger.exception("get_lesson_progress gagal") return jsonify({"success": False, "message": "Gagal mengambil progress"}), 500 finally: db.close()