pdf-quiz-generator/backend/app/services/attempt_expiry.py
Daniel 789cd1cc81 feat: right after a tip is its own slice
Opening a tip before answering is a nudge. The answer that follows is
still right — it is counted as right, and the percentage is not docked —
but it is not the same as right, so it keeps its own arc on the donut and
its own line in the legend: "3 correct after a tip".

attempt_answers.used_hint records it. The player reports which questions
had a tip opened before the answer went in; a tip read afterwards is
revision and does not count, which is the difference two of the tests
turn on. Both endings agree about it — an explicit submit carries the
list, and an exam that runs out takes it from the saved progress, so a
tab closing cannot launder a score.

Found while wiring this: RichText declared its component overrides inline
in the render, so every one was a fresh component type and React
remounted the whole rendered tree on each render. An open tip closed
itself every time the exam clock ticked. The map is memoised now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 01:51:03 +02:00

118 lines
5 KiB
Python

"""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