elemes/routes/lesson_progress.py

363 lines
15 KiB
Python

"""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/<lesson_name>
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
ALLOWED_QUIZ_TYPES = {"mcq", "weighted", "flashcard"}
def _validate_answer_entry(ans):
"""Validasi satu entri jawaban kuis → None bila valid, selain itu pesan error.
Field poin (`earned_points` / `question_max_points`) dan `quiz_type`
bersifat OPSIONAL supaya payload klien lama tetap diterima. Bila ada:
- `quiz_type` harus salah satu dari ALLOWED_QUIZ_TYPES.
- poin wajib bilangan bulat non-negatif dan earned <= max.
"""
if not isinstance(ans, dict):
return "answers harus berisi objek"
quiz_type = ans.get("quiz_type")
if quiz_type is not None and quiz_type not in ALLOWED_QUIZ_TYPES:
return "quiz_type tidak dikenal"
earned = ans.get("earned_points")
max_points = ans.get("question_max_points")
if earned is None and max_points is None:
return None
for name, value in (("earned_points", earned), ("question_max_points", max_points)):
if value is None:
return f"{name} wajib diisi bersama pasangannya"
if isinstance(value, bool) or not isinstance(value, (int, float)):
return f"{name} harus numerik"
if value < 0:
return f"{name} tidak boleh negatif"
if float(value) != int(value):
return f"{name} harus bilangan bulat"
if float(earned) > float(max_points):
return "earned_points melebihi question_max_points"
return None
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
for ans in answers_raw:
err = _validate_answer_entry(ans)
if err:
return jsonify({"success": False, "message": err}), 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/<lesson_name>", 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,
"attempt_resumed_at": attempt.resumed_at.isoformat() if attempt and attempt.resumed_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()