pdf-quiz-generator/backend/app/main.py
Daniel 84f5927e5d Add Nextcloud integration, TTS key fix, Settings/Upload improvements
- Nextcloud backend proxy (WebDAV PROPFIND/GET) avoiding CORS
  - /api/nextcloud/test — credential validation
  - /api/nextcloud/files — browse folders and list PDFs
  - /api/nextcloud/download — stream file for upload
- UploadPage: Local/Nextcloud tabs, inline Nextcloud file browser
- SettingsPage: Test Connection button with live feedback
- AWS Polly Joanna (neural) set as default TTS voice
- TTS stops on question navigation AND answer selection (key prop fix)
- Dashboard: Good morning/afternoon/evening greeting with first name
- Favicon: 🩺 emoji SVG
- Navbar: cleaned up — username removed, ⚙ Settings link added
- manage.py CLI: reset-password, list-users, reembed commands

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

217 lines
8.4 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
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 extension and add embedding column if missing."""
from sqlalchemy import text
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)
"""))
# HNSW index for fast cosine similarity — created only if not exists
conn.execute(text("""
CREATE INDEX IF NOT EXISTS questions_embedding_hnsw
ON questions USING hnsw (embedding vector_cosine_ops)
"""))
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.get("/api/health")
def health_check():
return {"status": "ok"}