pdf-quiz-generator/backend/app/routers/attempts.py
Daniel 8137d5b20c Decouple course quizzes, replace passlib, add security hardening
- Fully decouple course quizzes from main quiz system (hidden from
  dashboard stats, history, search, results page)
- Course quiz results show "Back to Course" instead of retake/delete
- Add allow_review toggle for course creators to control answer review
- Show quiz title on course page, hide pool size from students
- Add course thumbnails to browse cards
- Replace passlib with bcrypt directly (compatible with existing hashes)
- Add HIBP breached password warnings on register/reset/change password
- Add CLI management tools (reset-password, set-role, stats, etc.)
- Fix quiz PATCH endpoint: ownership check instead of moderator-only
- Add max_length validation on course/module/lesson titles
- Fix score display bug on results page (0 of N when review disabled)
- Fix question count on course quiz start (show per-attempt, not pool)
- Improve suspend warning for timed course quizzes with max attempts
- Clean up validation error messages (show "Invalid email" not Pydantic dump)
- Add DDL migration for allow_review column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:31:05 +02:00

597 lines
21 KiB
Python

from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request
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:
pass
percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
# Update reminder schedule
try:
from app.services.reminder_service import update_reminder_schedule
update_reminder_schedule(db, current_user.id, attempt.quiz_id, percentage)
except Exception:
pass # Don't fail submission if reminder update fails
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)
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 (informational, not authoritative)
started_at: str | None = None # ISO timestamp when quiz started (for timer calculation)
total_time: int | None = None # original time limit in seconds
@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,
}))
except Exception:
pass # Redis unavailable — degrade gracefully
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)
# Check if timer expired for timed quiz (auto-submit)
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()
db.commit()
r.delete(key)
return None # no progress to resume — already submitted
except Exception:
pass
return saved
except Exception:
pass
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:
pass
@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)."""
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),
)
.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)
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),
)
.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),
)
.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,
)