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
962 lines
40 KiB
Python
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,
|
|
}
|