elemes/routes/quiz_attempts.py

188 lines
7.3 KiB
Python

"""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,
}
)