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
118 lines
5 KiB
Python
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
|