188 lines
7.3 KiB
Python
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,
|
|
}
|
|
)
|