pdf-quiz-generator/backend/app/main.py
Daniel 2cbbfe00c3 Tag filtering, multi-category, bug fixes, image validation, docs
- Fix tag filtering (sa_text import shadowing caused UnboundLocalError)
- Add TagBrowser component with per-section search
- Multi-category selection (OR within categories, AND with tags)
- AI image validation: has_figure field in extraction prompt
- Skip known branding images by MD5 hash + dimension filters
- Fix quiz timer auto-submit (wrong useEffect dependency)
- Fix QuizResponse schema: section_id nullable
- Fix Question.quiz_id → source_quiz_id attribute name
- Fix SQL injection in quizzes.py vector search
- Add PDF processing progress steps via Redis
- Add delete user from admin panel
- Admin page: no spinner flash on data refresh
- Upload progress: axios 1.x e.progress, remove manual Content-Type
- Duplicate model error: 409 with clear message
- Backend startup: retry DDL migration on lock timeout
- Replace all silent except:pass with warning logs
- Comprehensive multi-page documentation (docs/)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:48:26 +02:00

387 lines
16 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, question_categories, favorites, teach, contact, tags
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),
]
db.add_all(defaults)
db.commit()
# Clean up legacy titan-embed-v2 "general" entry (was mistakenly seeded)
titan = db.query(AIModelConfig).filter(
AIModelConfig.model_id == "titan-embed-v2",
AIModelConfig.task == "general",
).first()
if titan:
db.delete(titan)
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 models so create_all picks them up
from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa
# Kill stale idle-in-transaction connections from previous killed startups.
# They hold DDL locks and cause ALTER TABLE below to hang indefinitely.
with engine.connect() as conn:
conn.execute(text("""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = current_database()
AND state = 'idle in transaction'
AND query_start < NOW() - INTERVAL '30 seconds'
AND pid != pg_backend_pid()
"""))
conn.commit()
# Retry schema migration up to 3 times — Celery tasks may hold locks briefly
for attempt in range(3):
try:
with engine.connect() as conn:
conn.execute(text("SET lock_timeout = '15s'"))
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS embedding vector(1024)"))
conn.commit()
break
except Exception as e:
if attempt < 2 and "lock" in str(e).lower():
import logging; logging.getLogger(__name__).warning(f"Schema migration lock timeout (attempt {attempt+1}/3), retrying in 5s...")
import time; time.sleep(5)
continue
raise
with engine.connect() as conn:
conn.execute(text("SET lock_timeout = '15s'"))
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
"""))
# Question categories (separate from quiz categories)
conn.execute(text("""
CREATE TABLE IF NOT EXISTS question_categories (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
description TEXT,
user_id INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
)
"""))
conn.execute(text("""
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS question_category_id INTEGER
REFERENCES question_categories(id) ON DELETE SET NULL
"""))
# Fix email collation to avoid en_US.utf8 B-tree index corruption
conn.execute(text('ALTER TABLE users ALTER COLUMN email TYPE varchar COLLATE "C"'))
# Fix: section deletion must not cascade-delete quizzes
conn.execute(text("ALTER TABLE quizzes ALTER COLUMN section_id DROP NOT NULL"))
try:
conn.execute(text("ALTER TABLE quizzes DROP CONSTRAINT IF EXISTS quizzes_section_id_fkey"))
conn.execute(text("ALTER TABLE quizzes ADD CONSTRAINT quizzes_section_id_fkey FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE SET NULL"))
except Exception as e:
import logging
logging.getLogger(__name__).debug(f"FK constraint migration (may already exist): {e}")
# Soft delete + publish control for quizzes
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP"))
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS is_published INTEGER DEFAULT 1"))
# Junction table: quiz ↔ question many-to-many
conn.execute(text("""
CREATE TABLE IF NOT EXISTS quiz_question_links (
quiz_id INTEGER NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE,
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
position INTEGER DEFAULT 0,
PRIMARY KEY (quiz_id, question_id)
)
"""))
# Populate junction from existing questions (idempotent via ON CONFLICT DO NOTHING)
conn.execute(text("""
INSERT INTO quiz_question_links (quiz_id, question_id, position)
SELECT quiz_id, id,
ROW_NUMBER() OVER (PARTITION BY quiz_id ORDER BY id) - 1
FROM questions
WHERE quiz_id IS NOT NULL
ON CONFLICT DO NOTHING
"""))
# Make quiz_id on questions nullable (it becomes informational "source_quiz_id")
conn.execute(text("ALTER TABLE questions ALTER COLUMN quiz_id DROP NOT NULL"))
# Favorites table
conn.execute(text("""
CREATE TABLE IF NOT EXISTS favorites (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, question_id)
)
"""))
# Unthrottle flag for users (exempt from AI/TTS rate limits)
conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS is_unthrottled INTEGER DEFAULT 0"))
# Contact form submissions table
conn.execute(text("""
CREATE TABLE IF NOT EXISTS contact_submissions (
id SERIAL PRIMARY KEY,
name VARCHAR(120) NOT NULL,
email VARCHAR NOT NULL,
type VARCHAR NOT NULL,
message TEXT NOT NULL,
read INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
)
"""))
# AI question tags (subjects, diseases, keywords)
conn.execute(text("""
CREATE TABLE IF NOT EXISTS question_tags (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
type VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_tag_name_type ON question_tags(LOWER(name), type)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS question_tag_links (
question_id INTEGER REFERENCES questions(id) ON DELETE CASCADE,
tag_id INTEGER REFERENCES question_tags(id) ON DELETE CASCADE,
PRIMARY KEY (question_id, tag_id)
)
"""))
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 as e:
log.warning(f"Embedding failed for question {q.id}: {e}")
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()
def _acquire_singleton_lock() -> bool:
"""Returns True if this worker wins the startup lock for singleton tasks (scheduler, backfill).
Uses Redis SETNX with a 5-minute TTL. Degrades gracefully if Redis is unavailable."""
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=2)
return bool(r.set("startup:singleton_lock", "1", nx=True, ex=300))
except Exception:
return True # Redis unavailable — assume single worker, run everything
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup — all workers run DDL/seeds (idempotent), only one runs scheduler/backfill
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()
# Scheduler and backfill must only run in one worker to avoid duplicate jobs / race conditions
is_primary = _acquire_singleton_lock()
if is_primary:
backfill_embeddings()
start_scheduler()
yield
# Shutdown
if is_primary:
stop_scheduler()
app = FastAPI(
title="PedsHub",
description="Pediatric Knowledge Quiz Platform",
version="2.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://quiz.danvics.com", "https://pedshub.com", "https://www.pedshub.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-New-Token"], # Allow frontend to read this header
)
# Add token refresh middleware AFTER CORS (middleware applies in reverse)
from app.utils.auth import TokenRefreshMiddleware
app.add_middleware(TokenRefreshMiddleware)
# 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.include_router(question_categories.router, prefix="/api/question-categories", tags=["question-categories"])
app.include_router(favorites.router, prefix="/api/favorites", tags=["favorites"])
app.include_router(teach.router, prefix="/api/teach", tags=["teach"])
app.include_router(contact.router, prefix="/api/contact", tags=["contact"])
app.include_router(tags.router, prefix="/api/tags", tags=["tags"])
@app.get("/api/health")
def health_check():
return {"status": "ok"}