pdf-quiz-generator/backend/app/routers/attempts.py
ifedan-ed b876f13fac Initial commit: PDF Quiz Generator app
- FastAPI backend with JWT auth, roles (admin/moderator/user)
- PDF upload (up to 500MB) with streaming, PyMuPDF text extraction
- ChromaDB vectorization per page with metadata
- LiteLLM AI question extraction from PDF (not generation)
- Image extraction from PDF pages, graceful fallback
- Quiz modes: timed (countdown timer) + learning (answers shown inline)
- Page-by-page question navigation with dot navigator
- TTS endpoint using LiteLLM (Google Vertex / OpenAI voices)
- Admin dashboard: AI model management per task, user role management
- Moderator role: upload PDFs, create sections, generate quizzes
- Spaced repetition reminders via SMTP email (SM-2 intervals)
- APScheduler daily reminder jobs
- Celery + Redis for background PDF processing
- React frontend with all pages
- Docker Compose deployment (nginx + backend + celery + redis)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 20:04:53 +00:00

236 lines
7.5 KiB
Python

from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
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
router = APIRouter()
@router.post("/start", response_model=AttemptResponse)
def start_attempt(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.user_id == current_user.id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
attempt = QuizAttempt(
quiz_id=quiz_id,
user_id=current_user.id,
total_questions=quiz.questions_count,
)
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
questions = {
q.id: q for q in db.query(Question).filter(Question.quiz_id == attempt.quiz_id).all()
}
score = 0
answer_details = []
for ans in submission.answers:
question = questions.get(ans.question_id)
if not question:
continue
is_correct = ans.user_answer.strip().lower() == question.correct_answer.strip().lower()
if is_correct:
score += 1
attempt_answer = AttemptAnswer(
attempt_id=attempt_id,
question_id=ans.question_id,
user_answer=ans.user_answer,
is_correct=is_correct,
)
db.add(attempt_answer)
answer_details.append(AnswerDetail(
question_id=question.id,
question_text=question.question_text,
question_type=question.question_type,
user_answer=ans.user_answer,
correct_answer=question.correct_answer,
is_correct=is_correct,
explanation=question.explanation,
))
attempt.score = score
attempt.completed_at = datetime.utcnow()
db.commit()
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,
)
@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
]
@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()
total_quizzes = db.query(Quiz).filter(Quiz.user_id == current_user.id).count()
completed_attempts = db.query(QuizAttempt).filter(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(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
quiz_stats = []
quizzes = db.query(Quiz).filter(Quiz.user_id == current_user.id).all()
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")
answer_details = []
for ans in attempt.answers:
question = ans.question
answer_details.append(AnswerDetail(
question_id=question.id,
question_text=question.question_text,
question_type=question.question_type,
user_answer=ans.user_answer,
correct_answer=question.correct_answer,
is_correct=ans.is_correct,
explanation=question.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,
)