pdf-quiz-generator/backend/app/main.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

230 lines
9.1 KiB
Python

import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions
from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
def seed_admin():
"""Create default admin user if none exists."""
from app.models.user import User
from app.models.email_verification import EmailVerification
from app.models.password_reset import PasswordReset
db = SessionLocal()
try:
admin_exists = db.query(User).filter(User.role == "admin").first()
if not admin_exists:
admin_user = User(
email="admin@quizapp.com",
hashed_password=get_password_hash("admin123"),
name="Admin",
role="admin",
)
db.add(admin_user)
db.flush()
# Auto-verify seeded admin
from datetime import datetime
db.add(EmailVerification(
user_id=admin_user.id,
token="seeded",
expires_at=datetime.utcnow(),
verified_at=datetime.utcnow(),
))
db.commit()
else:
# Ensure existing admin has a verified email record
from app.models.email_verification import EmailVerification
from datetime import datetime
existing_v = db.query(EmailVerification).filter(EmailVerification.user_id == admin_exists.id).first()
if not existing_v:
db.add(EmailVerification(
user_id=admin_exists.id,
token=f"legacy_{admin_exists.id}",
expires_at=datetime.utcnow(),
verified_at=datetime.utcnow(),
))
db.commit()
finally:
db.close()
def seed_default_models():
"""Seed default AI model configs if none exist."""
from app.models.ai_model_config import AIModelConfig
db = SessionLocal()
try:
if db.query(AIModelConfig).count() == 0:
defaults = [
AIModelConfig(name="Claude Haiku 4.5", model_id="claude-haiku-4.5", task="extraction", is_active=True, is_default=True),
AIModelConfig(name="Claude Sonnet 4.6", model_id="claude-sonnet-4.6", task="extraction", is_active=True, is_default=False),
AIModelConfig(name="Gemini 2.5 Flash", model_id="gemini-2.5-flash", task="extraction", is_active=True, is_default=False),
AIModelConfig(name="Titan Embed v2 (Embedding)", model_id="titan-embed-v2", task="general", is_active=True, is_default=False),
]
db.add_all(defaults)
db.commit()
# Always ensure OpenAI TTS voice models exist (idempotent)
tts_voices = [
# OpenAI (work with OPENAI_API_KEY)
("OpenAI Alloy", "tts-1:alloy", True),
("OpenAI Nova", "tts-1:nova", False),
("OpenAI Echo", "tts-1:echo", False),
("OpenAI Shimmer", "tts-1:shimmer", False),
("OpenAI Onyx", "tts-1:onyx", False),
("OpenAI Fable", "tts-1:fable", False),
("OpenAI Alloy HD", "tts-1-hd:alloy", False),
("OpenAI Nova HD", "tts-1-hd:nova", False),
# ElevenLabs (work with ELEVENLABS_API_KEY)
("ElevenLabs Adam", "elevenlabs/adam", False),
# Google Cloud TTS (work with GOOGLE_TTS_API_KEY)
("Google Wavenet F (en-US)", "google/en-US-Wavenet-F", False),
("Google Wavenet D (en-US)", "google/en-US-Wavenet-D", False),
("Google Studio O (en-US)", "google/en-US-Studio-O", False),
("Google Studio Q (en-US)", "google/en-US-Studio-Q", False),
("Google Chirp 3 HD (en-US)", "google/en-US-Chirp3-HD-Aoede", False),
# AWS Polly Neural (work with AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY)
("AWS Polly Joanna (en-US)", "polly/Joanna", False),
("AWS Polly Matthew (en-US)", "polly/Matthew", False),
("AWS Polly Amy (en-GB)", "polly/Amy", False),
("AWS Polly Brian (en-GB)", "polly/Brian", False),
]
# Deactivate old generic tts-1 / tts-1-hd entries (no voice encoded)
for old_id in ("tts-1", "tts-1-hd"):
old = db.query(AIModelConfig).filter(AIModelConfig.model_id == old_id).first()
if old:
old.is_active = False
old.is_default = False
has_default_tts = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts", AIModelConfig.is_default == True, AIModelConfig.is_active == True,
).first() is not None
for name, model_id, _ in tts_voices:
exists = db.query(AIModelConfig).filter(AIModelConfig.model_id == model_id).first()
if not exists:
is_def = not has_default_tts
db.add(AIModelConfig(name=name, model_id=model_id, task="tts", is_active=True, is_default=is_def))
if is_def:
has_default_tts = True
db.commit()
finally:
db.close()
def setup_pgvector():
"""Enable pgvector, add new columns/tables, run schema migrations."""
from sqlalchemy import text
# Import new model so create_all picks it up
from app.models import quiz_category # noqa
with engine.connect() as conn:
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS embedding vector(1024)"))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS questions_embedding_hnsw
ON questions USING hnsw (embedding vector_cosine_ops)
"""))
# Quiz categories
conn.execute(text("""
CREATE TABLE IF NOT EXISTS quiz_categories (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
user_id INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
)
"""))
conn.execute(text("""
ALTER TABLE quizzes
ADD COLUMN IF NOT EXISTS category_id INTEGER REFERENCES quiz_categories(id) ON DELETE SET NULL
"""))
conn.commit()
def backfill_embeddings():
"""Generate embeddings for questions that don't have one yet (background, best-effort)."""
import threading
from app.models.question import Question
from app.services import embedding_service
def _run():
db = SessionLocal()
try:
missing = db.query(Question).filter(Question.embedding.is_(None)).all()
if not missing:
return
import logging
log = logging.getLogger(__name__)
log.info(f"Backfilling embeddings for {len(missing)} questions...")
ok = 0
for q in missing:
try:
if embedding_service.embed_question(q):
ok += 1
except Exception:
pass
db.commit()
log.info(f"Backfill complete: {ok}/{len(missing)} embedded")
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Embedding backfill failed: {e}")
finally:
db.close()
threading.Thread(target=_run, daemon=True).start()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
Base.metadata.create_all(bind=engine)
setup_pgvector()
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
os.makedirs(os.path.join(settings.UPLOAD_DIR, "images"), exist_ok=True)
os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
seed_admin()
seed_default_models()
backfill_embeddings()
start_scheduler()
yield
# Shutdown
stop_scheduler()
app = FastAPI(
title="PedQuiz",
description="Pediatric Knowledge Quiz Platform",
version="2.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://quiz.danvics.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Serve uploaded images as static files
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(documents.router, prefix="/api/documents", tags=["documents"])
app.include_router(quizzes.router, prefix="/api/quizzes", tags=["quizzes"])
app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"])
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
app.include_router(tts.router, prefix="/api/tts", tags=["tts"])
app.include_router(nextcloud.router, prefix="/api/nextcloud", tags=["nextcloud"])
app.include_router(categories.router, prefix="/api/categories", tags=["categories"])
app.include_router(questions.router, prefix="/api/questions", tags=["questions"])
@app.get("/api/health")
def health_check():
return {"status": "ok"}