"""Settling a timed attempt whose clock has run out. An exam that was closed without being suspended keeps running. When its time is up it is submitted with whatever was answered, and the score counts — the learner sat an exam and ran out of time, which is a result, not an accident to be hidden. Previously such attempts were graded and then flagged `expired=1`, which every statistic excluded, so the exam vanished as if never sat. Two places notice that a clock has run out — resuming the attempt, and listing sessions — and both call this so the outcome is the same whichever comes first. """ import json import logging from datetime import datetime, timezone from sqlalchemy.orm import Session from app.models.attempt import AttemptAnswer, QuizAttempt from app.services.study_plan_context import mark_block_complete from app.utils.quiz_questions import get_quiz_questions, grade_quiz_answers logger = logging.getLogger(__name__) def progress_key(user_id: int, attempt_id: int) -> str: return f"quiz_progress:{user_id}:{attempt_id}" def active_key(user_id: int, attempt_id: int) -> str: return f"quiz_active:{user_id}:{attempt_id}" def seconds_remaining(saved: dict) -> float | None: """Time left on an unsuspended timed attempt, or None when there is no clock. An exam's clock runs only while the exam is on screen, so what is left is what the player last saved — not what a wall clock would have spent. This used to be computed from `started_at` and `total_time`, which charged a learner for an hour away from the tab as though they had been sitting the paper, and made every per-question timing a fiction. A suspended attempt holds its `time_left` and has no running clock, so it never expires while suspended. """ if not saved or saved.get("suspended"): return None left = saved.get("time_left") if left is not None: try: return max(0.0, float(left)) except (TypeError, ValueError): pass # Older saved progress carries no `time_left`; fall back to the wall clock # rather than treating a timed attempt as untimed. total = saved.get("total_time") started = saved.get("started_at") if total is None or not started: return None try: started_at = datetime.fromisoformat(str(started).replace("Z", "+00:00")) except ValueError: return None if started_at.tzinfo is None: started_at = started_at.replace(tzinfo=timezone.utc) return float(total) - (datetime.now(timezone.utc) - started_at).total_seconds() def settle_if_expired(db: Session, redis_client, user_id: int, attempt: QuizAttempt, saved: dict) -> bool: """Submit `attempt` if its clock has run out. Returns True if it did. Grades exactly as an explicit submission would, so the two paths cannot disagree about a score. Serialised against a concurrent manual submit by re-reading the attempt under lock. """ remaining = seconds_remaining(saved) if remaining is None or remaining > 0: return False return _submit(db, redis_client, user_id, attempt, saved) def _submit(db: Session, redis_client, user_id: int, attempt: QuizAttempt, saved: dict) -> bool: """Grade and close one attempt from its saved progress. Shared by every path that ends an attempt without the learner pressing submit, so a clock running out, a tab closing and a manual submission cannot disagree about a score. Serialised against a concurrent manual submit by re-reading the attempt under lock. """ try: db.refresh(attempt, with_for_update=True) if attempt.completed_at: redis_client.delete(progress_key(user_id, attempt.id)) return True answers = [(int(qid), answer) for qid, answer in (saved.get("answers") or {}).items()] grades = grade_quiz_answers(get_quiz_questions(db, attempt.quiz_id), answers, attempt.selected_question_ids) hinted = {int(qid) for qid in (saved.get("hints") or [])} for question, answer, correct in grades: db.add(AttemptAnswer(attempt_id=attempt.id, question_id=question.id, user_answer=answer, is_correct=correct, used_hint=question.id in hinted)) attempt.score = sum(correct for _, _, correct in grades) attempt.total_questions = len(grades) attempt.completed_at = datetime.utcnow() mark_block_complete(db, user_id, attempt.quiz_id) db.commit() redis_client.delete(progress_key(user_id, attempt.id)) redis_client.delete(active_key(user_id, attempt.id)) return True except Exception: db.rollback() logger.warning("Could not settle expired attempt %s", attempt.id, exc_info=True) return False def load_saved(redis_client, user_id: int, attempt_id: int) -> dict | None: raw = redis_client.get(progress_key(user_id, attempt_id)) return json.loads(raw) if raw else None