pdf-quiz-generator/backend/app/routers/attempts.py
Daniel 5398342e3d Suspend pauses timer; hide timer-expired attempts from history
Suspend now pauses the timer instead of letting it run out:
- 'Suspend & Leave' sends suspended=true with time_left to backend
- On resume, backend re-anchors started_at to now with held time_left
- Closing tab without suspending continues to run the timer (unchanged)

Timer-expired auto-submits are marked with expired=1 and excluded from:
- Attempt history (GET /attempts/history)
- Dashboard stats (quiz count, total attempts, average score)
- Attempt list (GET /attempts)
- DDL: ALTER TABLE quiz_attempts ADD COLUMN expired INTEGER DEFAULT 0

Course-quiz decoupling is preserved — these changes only touch
non-course quizzes (Quiz.course_id IS NULL).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 19:27:46 +02:00

622 lines
23 KiB
Python

import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request
logger = logging.getLogger(__name__)
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.models.quiz import Quiz
from app.models.question import Question
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.auth import get_current_user
from app.utils.quiz_questions import get_quiz_questions
router = APIRouter()
@router.post("/start", response_model=AttemptResponse)
def start_attempt(
quiz_id: int,
fresh: bool = False,
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")
# 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,
)
# 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,
)
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,
)
@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,
).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")
# Get all questions for this quiz via junction table
questions = {q.id: q for q in get_quiz_questions(db, attempt.quiz_id)}
score = 0
submitted = {ans.question_id: ans.user_answer for ans in submission.answers}
# Save submitted answers
for ans in submission.answers:
question = questions.get(ans.question_id)
if not question:
continue
is_correct = bool(question.correct_answer) and ans.user_answer.strip().lower() == question.correct_answer.strip().lower()
if is_correct:
score += 1
db.add(AttemptAnswer(
attempt_id=attempt_id,
question_id=ans.question_id,
user_answer=ans.user_answer,
is_correct=is_correct,
))
# Check if review is allowed (course quizzes may disallow)
quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first()
is_course_quiz = quiz and quiz.course_id is not None
review_allowed = not is_course_quiz or (quiz.allow_review == 1)
# Build full review — include ALL questions, unanswered marked as incorrect
answer_details = []
if review_allowed:
for q in get_quiz_questions(db, attempt.quiz_id):
user_answer = submitted.get(q.id, "")
is_correct = bool(user_answer) and bool(q.correct_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower()
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,
))
attempt.score = score
attempt.completed_at = datetime.utcnow()
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.quiz_id}")
except Exception:
logger.warning("Failed to clear quiz progress from Redis", exc_info=True)
percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
# Update reminder schedule (skip course quizzes)
if not is_course_quiz:
try:
from app.services.reminder_service import update_reminder_schedule
update_reminder_schedule(db, current_user.id, attempt.quiz_id, percentage)
except Exception:
logger.warning("Failed to update reminder schedule for quiz %d", attempt.quiz_id, exc_info=True)
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,
course_id=quiz.course_id if quiz else None,
allow_review=review_allowed,
)
@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()
return [
AttemptResponse(
id=a.id,
quiz_id=a.quiz_id,
score=a.score,
total_questions=a.total_questions,
percentage=round((a.score / a.total_questions * 100) if a.total_questions > 0 else 0, 1),
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 # {question_id: answer}
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,
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 refreshes the active session lock to prevent concurrent resume on another device."""
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,
"current_idx": data.current_idx,
"mode": data.mode,
"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)
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.
Rejects if another device is actively using this attempt.
Auto-submits timed quizzes if timer has expired."""
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
# Check if another device is actively using this attempt
session_id = request.headers.get("x-quiz-session", "") if request else ""
lock_key = f"quiz_active:{current_user.id}:{attempt.id}"
active_session = r.get(lock_key)
if active_session and session_id and active_session != session_id:
raise HTTPException(
status_code=409,
detail="This quiz is active on another device. It will become available when the other session ends or times out (~30 seconds)."
)
key = f"quiz_progress:{current_user.id}:{attempt.id}"
data = r.get(key)
if not data:
return None
saved = _json.loads(data)
# 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
# Check if timer expired for timed quiz (auto-submit) — only for unsuspended attempts
total_time = saved.get("total_time")
started_at_str = saved.get("started_at")
if total_time is not None and started_at_str:
try:
started_at = datetime.fromisoformat(started_at_str.replace('Z', '+00:00'))
elapsed = (datetime.now(timezone.utc) - started_at).total_seconds()
if elapsed >= total_time:
# Timer expired — auto-submit
questions = {q.id: q for q in get_quiz_questions(db, quiz_id)}
score = 0
submitted_answers = saved.get("answers", {})
for qid_str, user_ans in submitted_answers.items():
qid = int(qid_str)
question = questions.get(qid)
if question:
correct = (question.correct_answer or "").strip().lower()
if (user_ans or "").strip().lower() == correct:
score += 1
db.add(AttemptAnswer(
attempt_id=attempt.id,
question_id=qid,
user_answer=user_ans,
is_correct=(user_ans or "").strip().lower() == correct,
))
attempt.score = score
attempt.completed_at = datetime.utcnow()
attempt.expired = 1 # mark as timer-expired; exclude from history
db.commit()
r.delete(key)
return None # no progress to resume — already submitted
except Exception:
logger.warning("Failed to check quiz timer expiration", exc_info=True)
return saved
except Exception:
logger.warning("Redis unavailable for progress retrieval", exc_info=True)
return None
@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}")
except Exception:
logger.warning("Redis unavailable for progress clear", exc_info=True)
@router.delete("/{attempt_id}", status_code=204)
def delete_attempt(
attempt_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete own attempt + answers + reminders for that quiz. Cannot delete course quiz attempts."""
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")
# Block deletion of course quiz attempts
quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first()
if quiz and quiz.course_id:
raise HTTPException(status_code=403, detail="Cannot delete course quiz attempts")
quiz_id = attempt.quiz_id
# Delete all attempts for this quiz by this user (wipe history)
from app.models.reminder import ReminderSchedule
db.query(ReminderSchedule).filter(
ReminderSchedule.user_id == current_user.id,
ReminderSchedule.quiz_id == quiz_id,
).delete()
db.delete(attempt)
db.commit()
@router.get("/{attempt_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."""
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. Excludes course quizzes."""
incomplete = (
db.query(QuizAttempt)
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
.filter(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.is_(None),
Quiz.course_id.is_(None), # exclude course quizzes
)
.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_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 (excludes course quizzes and expired attempts)."""
completed = (
db.query(QuizAttempt)
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
.filter(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
Quiz.course_id.is_(None),
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
)
.order_by(QuizAttempt.completed_at)
.all()
)
# Group by quiz
from collections import defaultdict
by_quiz: dict = defaultdict(list)
quiz_titles: dict = {}
for a in completed:
pct = round((a.score / a.total_questions * 100) if a.total_questions > 0 else 0, 1)
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 (excludes course quizzes and expired attempts)
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),
Quiz.course_id.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),
Quiz.course_id.is_(None),
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
)
.all()
)
total_attempts = len(completed_attempts)
avg_score = 0.0
if completed_attempts:
scores = [(a.score / a.total_questions * 100) if a.total_questions > 0 else 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 = [(a.score / a.total_questions * 100) if a.total_questions > 0 else 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),
))
return DashboardStats(
total_documents=total_docs,
total_quizzes=total_quizzes,
total_attempts=total_attempts,
average_score=avg_score,
quiz_stats=quiz_stats,
)
@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()
is_course_quiz = quiz and quiz.course_id is not None
review_allowed = not is_course_quiz or (quiz.allow_review == 1)
# Show ALL questions in review, not just answered ones
submitted_map = {ans.question_id: ans for ans in attempt.answers}
answer_details = []
if review_allowed:
for q in get_quiz_questions(db, attempt.quiz_id):
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,
))
percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 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,
course_id=quiz.course_id if quiz else None,
allow_review=review_allowed,
)