trymeapp/app/routers/quizzes.py

321 lines
11 KiB
Python
Raw Normal View History

2026-11-11 22:06:26 +00:00
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload
from app.auth import get_active_user, get_consented_user, get_optional_user
from app.database import get_db
from app.models import Question, Quiz, QuizAttempt, QuizVote, User
from app.schemas import (
QuestionAuthorOut,
QuestionPlayOut,
QuestionResultOut,
QuizCreateIn,
QuizDetailOut,
QuizSubmitIn,
QuizSubmitOut,
QuizSummaryOut,
QuizUpdateIn,
VoteIn,
VoteOut,
)
from app.services.quiz_service import (
compute_quiz_mode,
enrich_quizzes,
generate_share_slug,
grade_question,
serialize_play_options,
)
router = APIRouter(prefix="/api/v1/quizzes", tags=["Quizzes"])
def _load_quiz_by_slug_or_id(db: Session, slug_or_id: str) -> Quiz | None:
stmt = select(Quiz).options(selectinload(Quiz.author), selectinload(Quiz.questions))
if slug_or_id.isdigit():
quiz = db.scalar(stmt.where(Quiz.id == int(slug_or_id)))
if quiz is not None:
return quiz
return db.scalar(stmt.where(Quiz.share_slug == slug_or_id))
@router.get("", response_model=list[QuizSummaryOut])
def list_quizzes(
q: str | None = Query(default=None, max_length=120),
mode: str | None = Query(default=None),
mine_only: bool = Query(default=False),
db: Session = Depends(get_db),
current_user: User | None = Depends(get_optional_user),
) -> list[QuizSummaryOut]:
stmt = (
select(Quiz)
.options(selectinload(Quiz.author), selectinload(Quiz.questions))
.where(Quiz.is_removed.is_(False))
)
if mine_only:
if current_user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Sign in to view your own quizzes.",
)
stmt = stmt.where(Quiz.author_id == current_user.id)
elif current_user is not None:
stmt = stmt.where(or_(Quiz.is_public.is_(True), Quiz.author_id == current_user.id))
else:
stmt = stmt.where(Quiz.is_public.is_(True))
if q and q.strip():
needle = f"%{q.strip()}%"
stmt = stmt.where(or_(Quiz.title.ilike(needle), Quiz.description.ilike(needle)))
stmt = stmt.order_by(Quiz.created_at.desc(), Quiz.id.desc())
quizzes = list(db.scalars(stmt).all())
if mode and mode not in ("all", ""):
quizzes = [quiz for quiz in quizzes if compute_quiz_mode(quiz.questions) == mode]
return enrich_quizzes(db, quizzes, current_user)
@router.post("", response_model=QuizDetailOut, status_code=status.HTTP_201_CREATED)
def create_quiz(
payload: QuizCreateIn,
db: Session = Depends(get_db),
user: User = Depends(get_consented_user),
) -> QuizDetailOut:
if len(payload.questions) > 40:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Public free tier quizzes may contain at most 40 questions.",
)
slug = generate_share_slug(db, payload.title)
quiz = Quiz(
share_slug=slug,
author_id=user.id,
title=payload.title,
description=payload.description.strip(),
is_public=payload.is_public,
)
for idx, q_in in enumerate(payload.questions):
quiz.questions.append(
Question(
position=idx,
question_type=q_in.question_type,
prompt=q_in.prompt,
options=[opt.model_dump() for opt in q_in.options],
correct_answer=q_in.correct_answer,
accepted_answers=q_in.accepted_answers,
)
)
db.add(quiz)
db.commit()
loaded = _load_quiz_by_slug_or_id(db, str(quiz.id))
assert loaded is not None
return _build_quiz_detail(db, loaded, user)
def _build_quiz_detail(db: Session, quiz: Quiz, current_user: User | None) -> QuizDetailOut:
summary = enrich_quizzes(db, [quiz], current_user)[0]
play_questions = [
QuestionPlayOut(
id=q.id,
position=q.position,
question_type=q.question_type, # type: ignore[arg-type]
prompt=q.prompt,
options=serialize_play_options(q.options),
)
for q in quiz.questions
]
editable: list[QuestionAuthorOut] | None = None
if current_user and (current_user.id == quiz.author_id or current_user.is_admin):
editable = [
QuestionAuthorOut(
id=q.id,
position=q.position,
question_type=q.question_type, # type: ignore[arg-type]
prompt=q.prompt,
options=serialize_play_options(q.options),
correct_answer=q.correct_answer,
accepted_answers=list(q.accepted_answers or []),
)
for q in quiz.questions
]
return QuizDetailOut(
**summary.model_dump(),
questions=play_questions,
editable_questions=editable,
)
@router.get("/{slug_or_id}", response_model=QuizDetailOut)
def get_quiz(
slug_or_id: str,
db: Session = Depends(get_db),
current_user: User | None = Depends(get_optional_user),
) -> QuizDetailOut:
quiz = _load_quiz_by_slug_or_id(db, slug_or_id)
if quiz is None or quiz.is_removed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quiz not found.")
if not quiz.is_public:
if current_user is None or (
current_user.id != quiz.author_id and not current_user.is_admin
):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quiz not found.")
return _build_quiz_detail(db, quiz, current_user)
@router.put("/{quiz_id}", response_model=QuizDetailOut)
def update_quiz(
quiz_id: int,
payload: QuizUpdateIn,
db: Session = Depends(get_db),
user: User = Depends(get_consented_user),
) -> QuizDetailOut:
quiz = _load_quiz_by_slug_or_id(db, str(quiz_id))
if quiz is None or quiz.is_removed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quiz not found.")
if quiz.author_id != user.id and not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the quiz author or a moderator can edit this quiz.",
)
quiz.title = payload.title
quiz.description = payload.description.strip()
quiz.is_public = payload.is_public
quiz.questions.clear()
db.flush()
for idx, q_in in enumerate(payload.questions):
quiz.questions.append(
Question(
position=idx,
question_type=q_in.question_type,
prompt=q_in.prompt,
options=[opt.model_dump() for opt in q_in.options],
correct_answer=q_in.correct_answer,
accepted_answers=q_in.accepted_answers,
)
)
db.commit()
loaded = _load_quiz_by_slug_or_id(db, str(quiz.id))
assert loaded is not None
return _build_quiz_detail(db, loaded, user)
@router.delete("/{quiz_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_quiz(
quiz_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_active_user),
) -> None:
quiz = db.get(Quiz, quiz_id)
if quiz is None or quiz.is_removed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quiz not found.")
if quiz.author_id != user.id and not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the quiz author or a moderator can delete this quiz.",
)
quiz.is_removed = True
db.commit()
@router.post("/{quiz_id}/submit", response_model=QuizSubmitOut)
def submit_quiz(
quiz_id: int,
payload: QuizSubmitIn,
db: Session = Depends(get_db),
user: User = Depends(get_active_user),
) -> QuizSubmitOut:
quiz = _load_quiz_by_slug_or_id(db, str(quiz_id))
if quiz is None or quiz.is_removed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quiz not found.")
if not quiz.is_public and quiz.author_id != user.id and not user.is_admin:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quiz not found.")
total = len(quiz.questions)
correct_count = 0
results: list[QuestionResultOut] = []
for q in quiz.questions:
submitted = payload.answers.get(str(q.id), "")
is_correct, correct_display = grade_question(q, submitted)
if is_correct:
correct_count += 1
results.append(
QuestionResultOut(
question_id=q.id,
prompt=q.prompt,
question_type=q.question_type, # type: ignore[arg-type]
submitted_answer=submitted,
is_correct=is_correct,
correct_display=correct_display,
)
)
score_percent = round((correct_count / total) * 100.0, 1) if total > 0 else 100.0
attempt = QuizAttempt(
quiz_id=quiz.id,
user_id=user.id,
score_count=correct_count,
total_questions=total,
score_percent=score_percent,
answers_payload=payload.answers,
)
db.add(attempt)
db.commit()
db.refresh(attempt)
summary = enrich_quizzes(db, [quiz], user)[0]
assert summary.my_latest_score is not None
assert summary.my_best_score is not None
assert summary.global_best_score is not None
return QuizSubmitOut(
attempt_id=attempt.id,
score_count=correct_count,
total_questions=total,
score_percent=score_percent,
results=results,
my_latest_score=summary.my_latest_score,
my_best_score=summary.my_best_score,
global_best_score=summary.global_best_score,
)
@router.post("/{quiz_id}/vote", response_model=VoteOut)
def vote_quiz(
quiz_id: int,
payload: VoteIn,
db: Session = Depends(get_db),
user: User = Depends(get_consented_user),
) -> VoteOut:
quiz = db.get(Quiz, quiz_id)
if quiz is None or quiz.is_removed or not quiz.is_public:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Public quiz not found.")
existing = db.scalar(
select(QuizVote).where(QuizVote.quiz_id == quiz_id, QuizVote.user_id == user.id)
)
if payload.value == 0:
if existing is not None:
db.delete(existing)
else:
if existing is None:
db.add(QuizVote(quiz_id=quiz_id, user_id=user.id, value=payload.value))
else:
existing.value = payload.value
db.commit()
votes = db.scalars(select(QuizVote).where(QuizVote.quiz_id == quiz_id)).all()
upvotes = sum(1 for v in votes if v.value > 0)
downvotes = sum(1 for v in votes if v.value < 0)
return VoteOut(
quiz_id=quiz_id,
upvotes=upvotes,
downvotes=downvotes,
user_vote=payload.value,
)