"""Quiz attempt (anti-cheat / focus-loss) — endpoint atomic finalisasi SATU attempt. POST /quiz-attempts/submit Kontrak: - Frontend membuat attempt_id (UUID canonical) saat kuis dimulai; SEMUA exit path (finish, keluar, SPA navigation, focus_lost, unload) memakai ID yang sama. Endpoint menerima fetch maupun sendBeacon. - Backend menyimpan audit attempt di quiz_attempts dan meng-update student_progress DALAM SATU transaksi. One-attempt policy dijaga oleh unique (user_id, lesson_id). - Retry dengan attempt_id sama → respons idempotent 200 tanpa menggandakan row dan tanpa mengubah record pertama. - Konflik payload dengan attempt_id sama (user/lesson berbeda) → 409. - Raw token tidak pernah di-log; response hanya memuat status/message/attempt_id. """ 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 quiz_attempts_bp = Blueprint("quiz_attempts", __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")) @quiz_attempts_bp.route("/quiz-attempts/submit", methods=["POST"]) def submit_quiz_attempt(): 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() 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] # ── validasi field dasar ───────────────────────────────────────── if not token or not lesson_name: return jsonify({"success": False, "message": "Token dan lesson wajib diisi"}), 400 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 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 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, ) 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_progress( db, user_id=user.id, lesson_id=lesson.id, state=state, score_earned=earned, score_total=total, ) 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("quiz-attempts/submit gagal") return jsonify({"success": False, "message": "Gagal menyimpan attempt"}), 500 finally: db.close() logger.info( "Quiz attempt 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, } )