pdf-quiz-generator/backend/app/routers/attempts.py
Daniel 3279e14bb2 refactor: remove the LMS
There will be no courses. What was there: one draft called "jk" with two empty
lessons, and 4,000 lines of code around it — courses, modules, lessons,
enrolments, per-lesson progress, SCORM, BigBlueButton, completion certificates,
three React pages, a router, two models.

Its real cost was everywhere else. Every query that measured practice had to
remember `Quiz.course_id.is_(None)`, and forgetting it in one place would have
silently mixed course attempts into a learner's analytics; the bank predicate
carried a subquery to exclude a course's own questions from every search,
recommendation and share; quiz access had a second, parallel rule about
enrolment. All of that is gone, so the remaining rules say what they mean.

`quizzes.allow_review` goes with it. It was only ever enforced for a course
quiz, so it had become a promise nothing keeps — the public session page was
still offering "no answer review" about sessions that review fine.

The fixtures' question 5 lived in a course quiz and stood for "a question that
exists but is not in your bank". There is no such thing now — a question is in
the bank unless it is deleted — so the counts it kept out of the numbers are
back in, and the tests that turned on it now turn on deletion or on the
attempt that actually holds a question.

Files the LMS uploaded stay on disk and stay protected: LEGACY_LMS_PREFIXES in
app/utils/upload_access.py is what keeps them unreachable.

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

962 lines
40 KiB
Python

import logging
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request
logger = logging.getLogger(__name__)
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import case, func
from app.services.attempt_expiry import active_key, load_saved, progress_key
from app.services.knowledge_groups import Grouping, score_rows
from app.services.question_figures import figures_for_questions
from app.services.study_plan_context import mark_block_complete, plan_context_for_quizzes, unmark_block_complete
from app.database import get_db
from app.models.quiz import Quiz
from app.models.question import Question
from app.models.question_category import QuestionCategory
from app.services.quiz_builder import bank_query, category_breadcrumbs
from app.models.attempt import QuizAttempt, AttemptAnswer
from app.models.pdf_document import PDFDocument
from app.models.user import User
from app.schemas.attempt import (
AttemptSubmit,
AttemptResponse,
AttemptDetail,
AnswerDetail,
DashboardStats,
QuizStats,
)
from app.utils.quiz_access import can_access_quiz, general_quiz_visibility, require_quiz_access
from app.utils.auth import get_current_user
from app.utils.quiz_questions import get_quiz_questions, grade_quiz_answers
router = APIRouter()
@router.post("/start", response_model=AttemptResponse)
def start_attempt(
quiz_id: int,
fresh: bool = False,
mode: Literal["study", "exam"] | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
if not can_access_quiz(db, quiz, current_user):
raise HTTPException(status_code=403, detail="This quiz is private")
chosen_mode = mode or ("study" if quiz.mode == "learning" else "exam")
# Enforce max_attempts
if quiz.max_attempts:
completed_count = db.query(QuizAttempt).filter(
QuizAttempt.quiz_id == quiz_id,
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
).count()
if completed_count >= quiz.max_attempts:
raise HTTPException(status_code=403, detail="Maximum attempts reached for this quiz")
# Reuse the most recent incomplete attempt unless fresh=true
if not fresh:
existing = db.query(QuizAttempt).filter(
QuizAttempt.quiz_id == quiz_id,
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.is_(None),
).order_by(QuizAttempt.started_at.desc()).first()
if existing:
return AttemptResponse(
id=existing.id,
quiz_id=existing.quiz_id,
score=0,
total_questions=existing.total_questions,
percentage=0.0,
started_at=existing.started_at,
completed_at=None,
mode=existing.mode or "exam",
)
# Question pool: randomly select N questions if questions_per_attempt is set
import random
selected_ids = None
total_q = quiz.questions_count
if quiz.questions_per_attempt and quiz.questions_per_attempt < quiz.questions_count:
all_q_ids = [q.id for q in quiz.questions]
selected_ids = sorted(random.sample(all_q_ids, quiz.questions_per_attempt))
total_q = quiz.questions_per_attempt
attempt = QuizAttempt(
quiz_id=quiz_id,
user_id=current_user.id,
total_questions=total_q,
selected_question_ids=selected_ids,
mode=chosen_mode,
)
db.add(attempt)
db.commit()
db.refresh(attempt)
return AttemptResponse(
id=attempt.id,
quiz_id=attempt.quiz_id,
score=0,
total_questions=attempt.total_questions,
percentage=0.0,
started_at=attempt.started_at,
completed_at=None,
mode=attempt.mode,
)
@router.post("/{attempt_id}/submit", response_model=AttemptDetail)
def submit_attempt(
attempt_id: int,
submission: AttemptSubmit,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
attempt = db.query(QuizAttempt).filter(
QuizAttempt.id == attempt_id,
QuizAttempt.user_id == current_user.id,
).with_for_update().first()
if not attempt:
raise HTTPException(status_code=404, detail="Attempt not found")
if attempt.completed_at:
raise HTTPException(status_code=400, detail="Attempt already submitted")
quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first()
require_quiz_access(db, quiz, current_user)
categories = db.query(QuestionCategory).all()
grades = grade_quiz_answers(get_quiz_questions(db, attempt.quiz_id),
[(ans.question_id, ans.user_answer) for ans in submission.answers], attempt.selected_question_ids)
score = sum(correct for _, _, correct in grades)
timings = submission.timings or {}
hinted = set(submission.hints or [])
for question, user_answer, is_correct in grades:
db.add(AttemptAnswer(attempt_id=attempt_id, question_id=question.id,
user_answer=user_answer, is_correct=is_correct,
seconds_spent=timings.get(question.id),
used_hint=question.id in hinted))
attempt.total_questions = len(grades)
# Review and grading use the same selected set, including skipped outcomes.
answer_details = []
figures = figures_for_questions(db, [q.id for q, _, _ in grades])
for q, user_answer, is_correct in grades:
answer_details.append(AnswerDetail(
question_id=q.id,
question_text=q.question_text,
question_type=q.question_type,
options=q.options,
user_answer=user_answer,
correct_answer=q.correct_answer,
is_correct=is_correct,
explanation=q.explanation,
explanation_image_path=q.explanation_image_path,
image_path=q.image_path,
page_reference=q.page_reference,
category_breadcrumbs=category_breadcrumbs(categories, q.question_category_id),
figures=figures.get(q.id, []),
))
attempt.score = score
attempt.completed_at = datetime.utcnow()
# A block behind this quiz is now done. Nothing else ever set this;
# plans showed every block as unfinished however many times it was sat.
mark_block_complete(db, current_user.id, attempt.quiz_id)
db.commit()
# Clear saved progress from Redis when submitted
try:
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
r.delete(f"quiz_progress:{current_user.id}:{attempt.id}", f"quiz_active:{current_user.id}:{attempt.id}")
except Exception:
logger.warning("Failed to clear quiz progress from Redis", exc_info=True)
# Out of what was answered, not out of what was set. An unanswered
# question is not a wrong answer.
percentage = score_percent(score, answered_counts(db, [attempt.id]).get(attempt.id, 0))
return AttemptDetail(
id=attempt.id,
quiz_id=attempt.quiz_id,
score=score,
total_questions=attempt.total_questions,
percentage=round(percentage, 1),
started_at=attempt.started_at,
completed_at=attempt.completed_at,
answers=answer_details,
)
@router.get("/", response_model=list[AttemptResponse])
def list_attempts(
quiz_id: int | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = db.query(QuizAttempt).filter(
QuizAttempt.user_id == current_user.id,
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
)
if quiz_id:
query = query.filter(QuizAttempt.quiz_id == quiz_id)
attempts = query.order_by(QuizAttempt.started_at.desc()).all()
# One query for the lot rather than one per row.
answered = answered_counts(db, [a.id for a in attempts])
return [
AttemptResponse(
id=a.id,
quiz_id=a.quiz_id,
score=a.score,
total_questions=a.total_questions,
percentage=score_percent(a.score, answered.get(a.id, 0)),
started_at=a.started_at,
completed_at=a.completed_at,
)
for a in attempts
]
class ProgressSave(BaseModel):
quiz_id: int
attempt_id: int
answers: dict[int, str] # {question_id: answer}; reject malformed cached UI values
hints: list[int] | None = None # questions where a tip was opened before answering
current_idx: int
mode: str
voice: str | None = None
time_left: int | None = None # remaining seconds for timed mode (authoritative when suspended=True)
started_at: str | None = None # ISO timestamp when quiz started (for timer calculation)
total_time: int | None = None # original time limit in seconds
suspended: bool = False # when True, timer is paused; time_left is preserved authoritatively
@router.post("/progress")
def save_progress(
data: ProgressSave,
request: Request = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Save in-progress quiz answers to Redis (survives logout/browser change).
Each attempt gets its own saved progress (key includes attempt_id).
Also records the latest active browser session for diagnostics. Resuming
from another browser is allowed; the newest browser takes over the attempt.
"""
quiz = db.query(Quiz).filter(Quiz.id == data.quiz_id).first()
require_quiz_access(db, quiz, current_user)
attempt = db.query(QuizAttempt).filter(QuizAttempt.id == data.attempt_id,
QuizAttempt.quiz_id == data.quiz_id, QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.is_(None)).first()
if not attempt:
raise HTTPException(404, "Active attempt not found")
try:
import redis as redis_lib, json as _json
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
# Session lock: identify this device by a session_id header
session_id = request.headers.get("x-quiz-session", "") if request else ""
lock_key = f"quiz_active:{current_user.id}:{data.attempt_id}"
if session_id:
r.setex(lock_key, 30, session_id) # 30s TTL, refreshed every save
key = f"quiz_progress:{current_user.id}:{data.attempt_id}"
r.setex(key, 7 * 24 * 3600, _json.dumps({ # 7 days
"quiz_id": data.quiz_id,
"attempt_id": data.attempt_id,
"answers": data.answers,
"hints": data.hints or [],
"current_idx": data.current_idx,
"mode": attempt.mode or "exam",
"voice": data.voice,
"time_left": data.time_left,
"started_at": data.started_at,
"total_time": data.total_time,
"suspended": data.suspended,
}))
except Exception:
logger.warning("Redis unavailable for progress save", exc_info=True)
raise HTTPException(503, "Progress could not be saved. Keep this tab open and retry.")
return {"saved": True}
@router.get("/progress")
def get_progress(
quiz_id: int,
request: Request = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Retrieve in-progress quiz answers from Redis.
Finds the latest incomplete attempt for this quiz, then checks Redis.
Allows another browser/device to resume the attempt; the newest browser
takes over the soft activity marker instead of blocking with a 409.
Auto-submits timed quizzes if timer has expired."""
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
require_quiz_access(db, quiz, current_user)
try:
import redis as redis_lib, json as _json
from app.config import settings
from datetime import datetime, timezone
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
# Find latest incomplete attempt for this quiz
attempt = db.query(QuizAttempt).filter(
QuizAttempt.quiz_id == quiz_id,
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.is_(None),
).order_by(QuizAttempt.started_at.desc()).first()
if not attempt:
return None
session_id = request.headers.get("x-quiz-session", "") if request else ""
lock_key = f"quiz_active:{current_user.id}:{attempt.id}"
if session_id:
r.setex(lock_key, 30, session_id)
key = f"quiz_progress:{current_user.id}:{attempt.id}"
data = r.get(key)
if not data:
return None
saved = _json.loads(data)
saved["mode"] = attempt.mode or "exam"
# If the quiz was suspended, timer is paused — re-anchor on resume so
# the held time_left becomes the new total_time starting now.
if saved.get("suspended"):
time_left = saved.get("time_left")
if time_left is not None and time_left > 0:
saved["started_at"] = datetime.now(timezone.utc).isoformat()
saved["total_time"] = int(time_left)
saved["suspended"] = False
# Persist the re-anchored progress so timer continues correctly
r.setex(key, 7 * 24 * 3600, _json.dumps(saved))
return saved
# An exam whose clock ran out is handed back with no time on it, and
# nothing more. The player opens it, shows Time's Up, and submits when
# the learner closes that — which is the only moment anybody has said
# the block is over. Marking it here instead meant a paper could be
# taken in and scored by a request the learner never made, days after
# they last saw it, and the first they knew of it was a result.
return saved
except Exception:
logger.warning("Redis unavailable for progress retrieval", exc_info=True)
raise HTTPException(503, "Saved progress is temporarily unavailable. Retry before starting.")
@router.delete("/progress/{attempt_id}", status_code=204)
def clear_progress(
attempt_id: int,
current_user: User = Depends(get_current_user),
):
"""Clear saved progress when quiz is submitted."""
try:
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
r.delete(f"quiz_progress:{current_user.id}:{attempt_id}")
r.delete(f"quiz_active:{current_user.id}:{attempt_id}")
except Exception:
logger.warning("Redis unavailable for progress clear", exc_info=True)
class ResetConfirm(BaseModel):
# The word typed into the confirmation box. Required in the body, not a
# query flag, so nothing can reset a learner's history from a bare link.
confirm: str
@router.post("/reset-all", status_code=200)
def reset_all_practice_data(
data: ResetConfirm,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Remove everything this learner has practised, and nothing they have made.
Goes: attempts and their answers, saved in-progress sessions, study-plan progress and
reading marks, saved questions and the notes written on questions and on
article sections. Stays: the account,
anything authored (tests, questions, articles, plans), AI conversations.
Returns the counts so the page can say what went.
"""
if data.confirm.strip().upper() != "RESET":
raise HTTPException(400, "Type RESET to confirm")
from app.models.favorite import Favorite
from app.models.study_plan import StudyPlanArticleRead, StudyPlanBlockProgress
from app.models.user_note import ArticleSectionNote, QuestionNote
uid = current_user.id
attempts = (db.query(QuizAttempt).join(Quiz, Quiz.id == QuizAttempt.quiz_id)
.filter(QuizAttempt.user_id == uid).all())
attempt_ids = [a.id for a in attempts]
removed = {"attempts": len(attempt_ids)}
if attempt_ids:
removed["answers"] = db.query(AttemptAnswer).filter(
AttemptAnswer.attempt_id.in_(attempt_ids)).delete(synchronize_session=False)
db.query(QuizAttempt).filter(QuizAttempt.id.in_(attempt_ids)).delete(synchronize_session=False)
removed["plan_blocks"] = db.query(StudyPlanBlockProgress).filter(
StudyPlanBlockProgress.user_id == uid).delete(synchronize_session=False)
removed["reading_marks"] = db.query(StudyPlanArticleRead).filter(
StudyPlanArticleRead.user_id == uid).delete(synchronize_session=False)
removed["saved_questions"] = db.query(Favorite).filter(Favorite.user_id == uid).delete(synchronize_session=False)
removed["question_notes"] = db.query(QuestionNote).filter(QuestionNote.user_id == uid).delete(synchronize_session=False)
removed["section_notes"] = db.query(ArticleSectionNote).filter(
ArticleSectionNote.user_id == uid).delete(synchronize_session=False)
db.commit()
try:
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
keys = [k for pattern in (f"quiz_progress:{uid}:*", f"quiz_active:{uid}:*") for k in r.scan_iter(pattern)]
removed["in_progress"] = r.delete(*keys) if keys else 0
except Exception:
logger.warning("Redis unavailable during reset for user %s", uid, exc_info=True)
removed["in_progress"] = None
return {"removed": removed}
# Deleting a single attempt is gone. A session is a record of work done, and
# removing one edits the history every figure on the analysis is computed from
# — which turns a measurement into something somebody chose. Starting again is
# offered whole instead: POST /attempts/reset-all, under Settings, Your data.
@router.get("/quiz/{quiz_id}/in-progress")
def get_in_progress_attempt(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return the latest incomplete attempt for a quiz, or null."""
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
require_quiz_access(db, quiz, current_user)
attempt = db.query(QuizAttempt).filter(
QuizAttempt.quiz_id == quiz_id,
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.is_(None),
).order_by(QuizAttempt.started_at.desc()).first()
if not attempt:
return None
return AttemptResponse(
id=attempt.id,
quiz_id=attempt.quiz_id,
score=0,
total_questions=attempt.total_questions,
percentage=0.0,
started_at=attempt.started_at,
completed_at=None,
)
@router.get("/in-progress")
def get_in_progress_attempts(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return all incomplete (not yet submitted) attempts for this user."""
incomplete = (
db.query(QuizAttempt)
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
.filter(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.is_(None),
general_quiz_visibility(current_user), # exclude what is no longer available
)
.order_by(QuizAttempt.started_at.desc())
.all()
)
result = []
for a in incomplete:
quiz = db.query(Quiz).filter(Quiz.id == a.quiz_id).first()
result.append({
"attempt_id": a.id,
"quiz_id": a.quiz_id,
"quiz_code": str(a.quiz_id),
"quiz_title": quiz.title if quiz else f"Quiz {a.quiz_id}",
"total_questions": a.total_questions,
"started_at": a.started_at.isoformat() if a.started_at else None,
})
return result
@router.get("/history")
def get_quiz_history(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return per-quiz attempt history for the performance line graph (expired attempts excluded)."""
completed = (
db.query(QuizAttempt)
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
.filter(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
# A repetition is practice, not a new measurement: the answers have
# already been seen, so getting them right again says nothing about
# whether they were known. It has its own analysis; it is not in this.
(Quiz.is_repetition == 0) | (Quiz.is_repetition.is_(None)),
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
)
.order_by(QuizAttempt.completed_at)
.all()
)
# Group by quiz
from collections import defaultdict
answered = answered_counts(db, [a.id for a in completed])
by_quiz: dict = defaultdict(list)
quiz_titles: dict = {}
for a in completed:
pct = score_percent(a.score, answered.get(a.id, 0))
by_quiz[a.quiz_id].append({
"attempt_id": a.id,
"date": a.completed_at.isoformat(),
"percentage": pct,
"score": a.score,
"total": a.total_questions,
})
if a.quiz_id not in quiz_titles:
quiz = db.query(Quiz).filter(Quiz.id == a.quiz_id).first()
quiz_titles[a.quiz_id] = quiz.title if quiz else f"Quiz {a.quiz_id}"
return [
{"quiz_id": qid, "title": quiz_titles[qid], "attempts": attempts}
for qid, attempts in by_quiz.items()
]
@router.get("/stats/dashboard", response_model=DashboardStats)
def get_dashboard_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
total_docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).count()
# Count distinct quizzes the user has attempted (expired attempts excluded)
total_quizzes = (
db.query(QuizAttempt.quiz_id)
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
.filter(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
# A repetition is practice, not a new measurement: the answers have
# already been seen, so getting them right again says nothing about
# whether they were known. It has its own analysis; it is not in this.
(Quiz.is_repetition == 0) | (Quiz.is_repetition.is_(None)),
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
)
.distinct().count()
)
completed_attempts = (
db.query(QuizAttempt)
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
.filter(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
# A repetition is practice, not a new measurement: the answers have
# already been seen, so getting them right again says nothing about
# whether they were known. It has its own analysis; it is not in this.
(Quiz.is_repetition == 0) | (Quiz.is_repetition.is_(None)),
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
)
.all()
)
total_attempts = len(completed_attempts)
answered = answered_counts(db, [a.id for a in completed_attempts])
avg_score = 0.0
if completed_attempts:
scores = [score_percent(a.score, answered.get(a.id, 0)) for a in completed_attempts]
avg_score = round(sum(scores) / len(scores), 1)
# Per-quiz stats — based on quizzes the user has attempted, not created
quiz_stats = []
attempted_quiz_ids = {a.quiz_id for a in completed_attempts}
quizzes = db.query(Quiz).filter(Quiz.id.in_(attempted_quiz_ids)).all() if attempted_quiz_ids else []
for quiz in quizzes:
quiz_attempts = [a for a in completed_attempts if a.quiz_id == quiz.id]
if quiz_attempts:
pcts = [score_percent(a.score, answered.get(a.id, 0)) for a in quiz_attempts]
quiz_stats.append(QuizStats(
quiz_id=quiz.id,
quiz_title=quiz.title,
attempts_count=len(quiz_attempts),
best_score=round(max(pcts), 1),
latest_score=round(pcts[-1], 1),
average_score=round(sum(pcts) / len(pcts), 1),
))
# What a learner is actually working through is questions, not quizzes: how
# many of the bank they have seen, and how many they have got right at least
# once. A count of quizzes says how the material happens to be packaged.
seen, mastered = db.query(
func.count(func.distinct(AttemptAnswer.question_id)),
func.count(func.distinct(case(
(AttemptAnswer.is_correct.is_(True), AttemptAnswer.question_id)))),
).join(QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id).filter(
QuizAttempt.user_id == current_user.id,
).first() or (0, 0)
bank_total = bank_query(db, current_user).count()
return DashboardStats(
total_documents=total_docs,
total_quizzes=total_quizzes,
total_attempts=total_attempts,
average_score=avg_score,
quiz_stats=quiz_stats,
questions_seen=int(seen or 0),
questions_correct=int(mastered or 0),
bank_total=bank_total,
)
@router.get("/{attempt_id}", response_model=AttemptDetail)
def get_attempt(
attempt_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
attempt = db.query(QuizAttempt).filter(
QuizAttempt.id == attempt_id,
QuizAttempt.user_id == current_user.id,
).first()
if not attempt:
raise HTTPException(status_code=404, detail="Attempt not found")
quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first()
require_quiz_access(db, quiz, current_user)
categories = db.query(QuestionCategory).all()
# Never reveal review content for an unfinished attempt or an unselected pool question.
review_allowed = attempt.completed_at is not None
submitted_map = {ans.question_id: ans for ans in attempt.answers}
answer_details = []
if review_allowed:
shown = [q for q in get_quiz_questions(db, attempt.quiz_id)
if attempt.selected_question_ids is None or q.id in attempt.selected_question_ids]
figures = figures_for_questions(db, [q.id for q in shown])
for q in shown:
ans = submitted_map.get(q.id)
answer_details.append(AnswerDetail(
question_id=q.id,
question_text=q.question_text,
question_type=q.question_type,
options=q.options,
user_answer=ans.user_answer if ans else "",
correct_answer=q.correct_answer,
is_correct=ans.is_correct if ans else False,
explanation=q.explanation,
explanation_image_path=q.explanation_image_path,
image_path=q.image_path,
page_reference=q.page_reference,
category_breadcrumbs=category_breadcrumbs(categories, q.question_category_id),
figures=figures.get(q.id, []),
))
percentage = score_percent(attempt.score,
answered_counts(db, [attempt.id]).get(attempt.id, 0))
return AttemptDetail(
id=attempt.id,
quiz_id=attempt.quiz_id,
score=attempt.score,
total_questions=attempt.total_questions,
percentage=round(percentage, 1),
started_at=attempt.started_at,
completed_at=attempt.completed_at,
answers=answer_details,
)
@router.get("/quiz/{quiz_id}/analysis")
def quiz_analysis(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""The analysis for a session addressed by quiz rather than by attempt.
A session nobody has sat still has an analysis: it is all zeroes and every
question is skipped, and that is the honest picture — the same page the
learner will see filled in, showing what is missing. Answering it with
"nothing to see" made a session that had never been opened look like a
different kind of object from one that had.
Where an attempt exists, its analysis is returned instead, so the page is
the same whichever way it was reached.
"""
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
require_quiz_access(db, quiz, current_user)
attempt = db.query(QuizAttempt).filter(
QuizAttempt.quiz_id == quiz_id, QuizAttempt.user_id == current_user.id,
).order_by(QuizAttempt.completed_at.isnot(None).desc(),
QuizAttempt.started_at.desc()).first()
if attempt:
return attempt_analysis(attempt.id, db, current_user)
questions = get_quiz_questions(db, quiz_id)
categories = {c.id: c.name for c in db.query(QuestionCategory).all()}
detail = [{
"position": index,
"question_id": question.id,
"excerpt": (question.question_text or "")[:120],
"status": "skipped",
"difficulty": getattr(question, "difficulty", None),
"category": categories.get(getattr(question, "question_category_id", None)),
"seconds_spent": None,
"peer_percent": None,
"correct_answer": None,
} for index, question in enumerate(questions, start=1)]
return {
"attempt_id": None,
"quiz_id": quiz_id,
"title": quiz.title,
"mode": quiz.mode,
"completed_at": None,
"total": len(detail),
"answered": 0,
"score": 0,
"percent": 0,
"seconds_total": None,
"seconds_per_question": None,
"questions": detail,
"plan": plan_context_for_quizzes(db, current_user.id, [quiz_id]).get(quiz_id),
"not_started": True,
}
class _LiveAnswer:
"""An answer that has been given but not yet submitted.
Shaped like an AttemptAnswer so the analysis does not have to care which of
the two it is reading. Not persisted: submitting is what writes answers,
and reading a page must not.
"""
__slots__ = ("question_id", "user_answer", "is_correct", "seconds_spent")
def __init__(self, question_id, user_answer, is_correct):
self.question_id = question_id
self.user_answer = user_answer
self.is_correct = is_correct
# Per-question timing is submitted with the attempt, so a live session
# has none yet. None, not zero — those are different claims.
self.seconds_spent = None
def answered_counts(db: Session, attempt_ids: list[int]) -> dict[int, int]:
"""How many questions were actually answered in each attempt."""
if not attempt_ids:
return {}
rows = (db.query(AttemptAnswer.attempt_id, func.count(AttemptAnswer.id))
.filter(AttemptAnswer.attempt_id.in_(attempt_ids),
AttemptAnswer.user_answer.isnot(None),
AttemptAnswer.user_answer != "")
.group_by(AttemptAnswer.attempt_id).all())
return {attempt_id: count for attempt_id, count in rows}
def score_percent(score: int, answered: int) -> float:
"""What you got right, out of what you answered.
An unanswered question is not a wrong answer, it is not an answer — so it
is not in the denominator. Counting it as wrong made leaving an exam early
look like failing it, and made the figure say more about how far you got
than about how well you did. How far you got is the separate number
beside it.
"""
return round(score / answered * 100, 1) if answered else 0.0
def _rows_from_progress(db: Session, user_id: int, attempt: QuizAttempt) -> list:
"""Grade what is saved for a live attempt, so it can be analysed mid-session."""
try:
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
saved = load_saved(r, user_id, attempt.id)
except Exception:
logger.warning("Redis unavailable for live analysis of attempt %s", attempt.id, exc_info=True)
return []
answers = (saved or {}).get("answers") or {}
if not answers:
return []
graded = grade_quiz_answers(
get_quiz_questions(db, attempt.quiz_id),
[(int(qid), value) for qid, value in answers.items()],
attempt.selected_question_ids,
)
# Every question, not only the answered ones: the unanswered are what make
# the figure read "1 of 34" and the table show the rest as skipped, exactly
# as a finished session does.
return [_LiveAnswer(question.id, answer, correct) for question, answer, correct in graded]
@router.get("/{attempt_id}/recommendations")
def attempt_recommendations(
attempt_id: int,
group: Literal["articles", "disciplines", "systems"] = "disciplines",
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""This session's weakest topics, asked three ways.
Which reading to go back to, which discipline is weak, which organ system
is weak — the same rules the Analysis page uses, from the same place, so
the two cannot disagree about where a question belongs.
A session still running is not marked, so there is nothing to rank: an
exam would be answering the question it is asking.
"""
attempt = db.query(QuizAttempt).filter(
QuizAttempt.id == attempt_id, QuizAttempt.user_id == current_user.id).first()
if not attempt:
raise HTTPException(404, "Attempt not found")
graded = attempt.completed_at is not None or attempt.mode != "exam"
rows = db.query(AttemptAnswer).filter(AttemptAnswer.attempt_id == attempt_id).all() if graded else []
question_ids = [row.question_id for row in rows if row.user_answer]
primary = dict(db.query(Question.id, Question.question_category_id).filter(
Question.id.in_(question_ids)).all()) if question_ids else {}
grouping = Grouping(db, group)
marks = [(row.question_id, primary.get(row.question_id), bool(row.is_correct))
for row in rows if row.user_answer]
return {"group": group, "rows": score_rows(grouping, marks)}
@router.get("/{attempt_id}/analysis")
def attempt_analysis(
attempt_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Everything the session analysis shows, in one call.
The results page had a score and a list of explanations; what a learner
needs afterwards is where the time went and which topics to go back to.
Peer statistics come from every other completed answer to the same question,
which is the only comparison available and an honest one.
"""
attempt = db.query(QuizAttempt).filter(
QuizAttempt.id == attempt_id, QuizAttempt.user_id == current_user.id).first()
if not attempt:
raise HTTPException(404, "Attempt not found")
quiz = db.get(Quiz, attempt.quiz_id)
rows = db.query(AttemptAnswer).filter(AttemptAnswer.attempt_id == attempt_id).all()
# A session still in progress has no AttemptAnswer rows — they are written
# on submit — so the analysis of one read as entirely empty while the
# session list, which counts the saved progress, said a question had been
# answered. The two are now looking at the same thing.
if not rows and attempt.completed_at is None:
rows = _rows_from_progress(db, current_user.id, attempt)
# An exam that is still running is not marked. Grading it here would let a
# learner answer, open this page to see whether it was right, and go back
# and change it — which is the exam defeated, not analysed. Progress is
# still shown: how many are answered, and how long it is taking.
#
# Study mode is graded live, because study mode marks each answer as it is
# given; there is nothing here it has not already said.
# Only an exam withholds, and only while it is running. Anything else —
# study mode, or a mode that was never recorded — is graded as before.
graded = attempt.completed_at is not None or attempt.mode != "exam"
question_ids = [row.question_id for row in rows]
questions = {q.id: q for q in db.query(Question).filter(Question.id.in_(question_ids)).all()} \
if question_ids else {}
categories = {c.id: c.name for c in db.query(QuestionCategory).all()}
# How everyone else did on these same questions, excluding this attempt so a
# learner is not compared against themselves.
peer: dict[int, tuple[int, int]] = {}
if question_ids:
for qid, total, correct in db.query(
AttemptAnswer.question_id,
func.count(AttemptAnswer.id),
func.sum(case((AttemptAnswer.is_correct.is_(True), 1), else_=0)),
).filter(
AttemptAnswer.question_id.in_(question_ids),
AttemptAnswer.attempt_id != attempt_id,
).group_by(AttemptAnswer.question_id).all():
peer[qid] = (int(total or 0), int(correct or 0))
detail = []
timed = []
for index, row in enumerate(rows, start=1):
question = questions.get(row.question_id)
total, correct = peer.get(row.question_id, (0, 0))
if row.seconds_spent:
timed.append(row.seconds_spent)
detail.append({
"position": index,
"question_id": row.question_id,
"excerpt": (getattr(question, "question_text", "") or "")[:120],
# "Skipped" means you went past it. In a session still running you
# have not been past it yet, so it says so — and in an exam that is
# running, an answered one says only that.
"status": (
("correct" if row.is_correct else "skipped" if not row.user_answer else "incorrect")
if attempt.completed_at
else ("correct" if graded and row.is_correct
else "incorrect" if graded and row.user_answer
else "answered" if row.user_answer
else "unanswered")
),
"difficulty": getattr(question, "difficulty", None),
"category": categories.get(getattr(question, "question_category_id", None)),
"seconds_spent": row.seconds_spent,
# None rather than 0% when nobody else has answered: an unanswered
# question has no peer rate, and 0 would read as "everyone failed".
"peer_percent": round(100 * correct / total) if total else None,
"peer_sample": total,
})
answered = sum(1 for row in rows if row.user_answer)
score = sum(1 for row in rows if row.is_correct) if graded else None
elapsed = None
if attempt.completed_at and attempt.started_at:
elapsed = int((attempt.completed_at - attempt.started_at).total_seconds())
return {
"attempt_id": attempt.id,
"quiz_id": attempt.quiz_id,
"title": getattr(quiz, "title", None),
"mode": getattr(quiz, "mode", None),
"completed_at": attempt.completed_at,
"total": len(rows),
"answered": answered,
"score": score,
"percent": (round(score_percent(score, answered)) if rows else 0) if graded else None,
# False while an exam is still running: the interface shows progress
# and says why there is no score yet, rather than showing a nought.
"graded": graded,
"seconds_total": elapsed,
"seconds_per_question": round(sum(timed) / len(timed)) if timed else None,
"questions": detail,
# Where to go back to is its own call now — /recommendations, grouped
# by article, discipline or system — so changing the grouping does not
# re-read the question table and the peer statistics beside it.
# Present when a study-plan block produced this session: the way back
# to the plan and on to the next block.
"plan": plan_context_for_quizzes(db, current_user.id, [attempt.quiz_id]).get(attempt.quiz_id),
"not_started": False,
}