pdf-quiz-generator/backend/app/routers/questions.py
Daniel 9af63e67b5 Major: categories, question bank, security fixes, mobile layout, UX improvements
Security:
- Nginx: X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, CSP, Referrer-Policy, Permissions-Policy headers
- Redis-backed login rate limiting (survives container restarts)
- Admin litellm/models endpoint: api_key moved from GET query param to POST body
- Nextcloud credentials moved from localStorage to sessionStorage (cleared on tab close)

UX / Layout:
- Login: unverified users see inline "Resend verification email" option
- QuizPage mobile: TTS Listen button on its own row below question text
- QuizPage mobile: Voice selector on its own row in header card (not squashed with timer)
- QuizEditPage: scroll position preserved after saving a question edit

Quiz Categories:
- New QuizCategory model + quiz_categories table
- category_id column added to quizzes table
- GET/POST/DELETE /api/categories endpoints
- Quizzes grouped by category in QuizzesPage; moderators can assign via 🏷 menu
- Uncategorized section shown when categories exist

Question Bank:
- GET /api/questions/bank — search all questions across quizzes
- POST /api/questions/from-bank — create new quiz from selected questions (copies, originals untouched)
- QuestionBankPage: search, checkbox select, study modal, create quiz form
- "Question Bank" link added to Navbar

Search:
- "View all N questions →" button expands to full question list
- Each question has a Study button opening in-place modal with study mode
- Summary view shows 2 questions per quiz with Study button

Extraction prompt:
- Stronger emphasis on correct_answer field with step-by-step letter → full text example
- Explicit instruction never to store just the letter

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 20:08:05 +02:00

128 lines
4.3 KiB
Python

"""Question bank — view, search, and create quizzes from individual questions."""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import cast, String, or_
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.question import Question
from app.models.quiz import Quiz
from app.models.user import User
from app.utils.auth import get_current_user, require_moderator
router = APIRouter()
@router.get("/bank")
def get_question_bank(
q: str | None = Query(None),
quiz_id: int | None = Query(None),
limit: int = Query(50, le=200),
offset: int = Query(0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all questions across all quizzes. Supports keyword filter and quiz filter."""
query = db.query(Question)
if quiz_id:
query = query.filter(Question.quiz_id == quiz_id)
if q and q.strip():
phrase = q.strip()
query = query.filter(
or_(
Question.question_text.ilike(f"%{phrase}%"),
cast(Question.options, String).ilike(f"%{phrase}%"),
)
)
total = query.count()
questions = query.order_by(Question.quiz_id, Question.id).offset(offset).limit(limit).all()
# Attach quiz title
quiz_cache: dict[int, str] = {}
result = []
for qu in questions:
if qu.quiz_id not in quiz_cache:
quiz = db.query(Quiz).filter(Quiz.id == qu.quiz_id).first()
quiz_cache[qu.quiz_id] = quiz.title if quiz else f"Quiz {qu.quiz_id}"
result.append({
"id": qu.id,
"quiz_id": qu.quiz_id,
"quiz_title": quiz_cache[qu.quiz_id],
"question_text": qu.question_text,
"question_type": qu.question_type,
"options": qu.options,
"correct_answer": qu.correct_answer,
"explanation": qu.explanation,
})
return {"total": total, "questions": result}
class CreateFromBankRequest(BaseModel):
title: str
question_ids: list[int]
mode: str = "timed"
time_limit_minutes: int | None = None
@router.post("/from-bank")
def create_quiz_from_bank(
data: CreateFromBankRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Create a new quiz by copying selected questions from the question bank."""
if not data.title.strip():
raise HTTPException(status_code=400, detail="Title is required")
if not data.question_ids:
raise HTTPException(status_code=400, detail="Select at least one question")
if data.mode not in ("timed", "learning"):
raise HTTPException(status_code=400, detail="Mode must be timed or learning")
# Fetch source questions
source_questions = db.query(Question).filter(Question.id.in_(data.question_ids)).all()
if not source_questions:
raise HTTPException(status_code=404, detail="No valid questions found")
# Use the section_id from the first source question's quiz
first_quiz = db.query(Quiz).filter(Quiz.id == source_questions[0].quiz_id).first()
if not first_quiz:
raise HTTPException(status_code=400, detail="Source quiz not found")
import json
new_quiz = Quiz(
section_id=first_quiz.section_id,
user_id=current_user.id,
title=data.title.strip(),
questions_count=len(source_questions),
mode=data.mode,
time_limit_minutes=data.time_limit_minutes,
)
db.add(new_quiz)
db.flush()
# Copy questions (independent copies — original questions untouched)
from app.services import embedding_service
for sq in source_questions:
new_q = Question(
quiz_id=new_quiz.id,
question_text=sq.question_text,
question_type=sq.question_type,
options=sq.options,
correct_answer=sq.correct_answer,
explanation=sq.explanation,
page_reference=sq.page_reference,
)
db.add(new_q)
db.flush()
try:
embedding_service.embed_question(new_q)
except Exception:
pass
db.commit()
db.refresh(new_quiz)
return {"id": new_quiz.id, "title": new_quiz.title, "questions_count": new_quiz.questions_count}