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.logging_config import setup_logging # Configure structured JSON logging before anything else setup_logging(settings.LOG_LEVEL) 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, flashcards, courses, mobile, mynote from app.utils.auth import get_password_hash from app.utils.scheduler import start_scheduler, stop_scheduler def seed_admin(): """Optionally create a configured bootstrap admin user if none exists.""" import logging from datetime import datetime from app.models.user import User from app.models.email_verification import EmailVerification log = logging.getLogger(__name__) db = SessionLocal() try: admin_exists = db.query(User).filter(User.role == "admin").first() if not admin_exists: if not settings.DEFAULT_ADMIN_EMAIL or not settings.DEFAULT_ADMIN_PASSWORD: log.info("No admin exists; skipping bootstrap admin seed. First registered user will become admin.") return if len(settings.DEFAULT_ADMIN_PASSWORD) < 8: log.warning("DEFAULT_ADMIN_PASSWORD is too short; skipping bootstrap admin seed.") return admin_user = User( email=settings.DEFAULT_ADMIN_EMAIL.lower().strip(), hashed_password=get_password_hash(settings.DEFAULT_ADMIN_PASSWORD), name="Admin", role="admin", ) db.add(admin_user) db.flush() # Auto-verify seeded admin 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 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 sqlalchemy import text 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 LiteLLM-routed local speech models exist (idempotent). tts_voices = [ ("Kokoro Adam", "local-kokoro-tts:am_adam", True), ("Kokoro Michael", "local-kokoro-tts:am_michael", False), ("Kokoro Bella", "local-kokoro-tts:af_bella", False), ("Kokoro Nicole", "local-kokoro-tts:af_nicole", False), ("Kokoro Emma", "local-kokoro-tts:bf_emma", False), ("Kokoro Lewis", "local-kokoro-tts:bm_lewis", False), ] stt_models = [ ("Parakeet STT (LiteLLM)", "local-parakeet-v3", True), ("Groq Whisper Turbo", "groq-whisper-large-v3-turbo", False), ("Groq Whisper Large v3", "groq-whisper-large-v3", False), ] # Deactivate external/direct legacy TTS entries; speech should route through LiteLLM. for old in db.query(AIModelConfig).filter(AIModelConfig.task == "tts").all(): if old.model_id and not old.model_id.startswith("local-"): old.is_active = False old.is_default = False if old.model_id == "local-kokoro-tts": old.is_active = False old.is_default = False if old.model_id == "local-chatterbox-turbo": old.is_active = False old.is_default = False if old.model_id == "local-qwen3-tts": old.is_active = False old.is_default = False for name, model_id, _ in tts_voices: db.execute(text(""" INSERT INTO ai_model_configs (name, model_id, task, is_active, is_default, created_at) VALUES (:name, :model_id, 'tts', true, false, NOW()) ON CONFLICT (model_id, task) DO NOTHING """), {"name": name, "model_id": model_id}) # LiteLLM Kokoro is the intended default for read-aloud; admins can change it later. db.query(AIModelConfig).filter(AIModelConfig.task == "tts").update({"is_default": False}) local_default = db.query(AIModelConfig).filter( AIModelConfig.task == "tts", AIModelConfig.model_id == "local-kokoro-tts:am_adam", ).first() if local_default: local_default.is_active = True local_default.is_default = True for task, rows in (("stt", stt_models),): has_default = db.query(AIModelConfig).filter( AIModelConfig.task == task, AIModelConfig.is_default == True, AIModelConfig.is_active == True, ).first() is not None for name, model_id, preferred_default in rows: exists = db.query(AIModelConfig).filter( AIModelConfig.model_id == model_id, AIModelConfig.task == task, ).first() if not exists: is_def = preferred_default and not has_default db.execute(text(""" INSERT INTO ai_model_configs (name, model_id, task, is_active, is_default, created_at) VALUES (:name, :model_id, :task, true, :is_default, NOW()) ON CONFLICT (model_id, task) DO NOTHING """), {"name": name, "model_id": model_id, "task": task, "is_default": is_def}) if is_def: has_default = 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 from app.models import flashcard, course # 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 """)) # Course quiz flag conn.execute(text(""" ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS course_id INTEGER REFERENCES courses(id) ON DELETE CASCADE """)) conn.execute(text(""" ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS max_attempts INTEGER """)) conn.execute(text(""" ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS questions_per_attempt INTEGER """)) conn.execute(text(""" ALTER TABLE quiz_attempts ADD COLUMN IF NOT EXISTS selected_question_ids JSON """)) conn.execute(text(""" ALTER TABLE quiz_attempts ADD COLUMN IF NOT EXISTS expired INTEGER DEFAULT 0 """)) # Question ownership conn.execute(text(""" ALTER TABLE questions ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE SET NULL """)) conn.execute(text(""" ALTER TABLE questions ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 1 """)) # 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) ) """)) # Flashcard decks and cards conn.execute(text(""" CREATE TABLE IF NOT EXISTS flashcard_decks ( id SERIAL PRIMARY KEY, title VARCHAR NOT NULL, section_id INTEGER REFERENCES sections(id) ON DELETE SET NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, card_count INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT NOW() ) """)) conn.execute(text(""" CREATE TABLE IF NOT EXISTS flashcards ( id SERIAL PRIMARY KEY, deck_id INTEGER NOT NULL REFERENCES flashcard_decks(id) ON DELETE CASCADE, front TEXT NOT NULL, back TEXT NOT NULL, page_reference INTEGER, image_path VARCHAR, created_at TIMESTAMP DEFAULT NOW() ) """)) conn.execute(text(""" CREATE TABLE IF NOT EXISTS flashcard_tag_links ( flashcard_id INTEGER NOT NULL REFERENCES flashcards(id) ON DELETE CASCADE, tag_id INTEGER NOT NULL REFERENCES question_tags(id) ON DELETE CASCADE, PRIMARY KEY (flashcard_id, tag_id) ) """)) # Soft-delete + sharing for flashcard decks conn.execute(text("ALTER TABLE flashcard_decks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP")) conn.execute(text("ALTER TABLE flashcard_decks ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 0")) # Deck ratings conn.execute(text(""" CREATE TABLE IF NOT EXISTS flashcard_deck_ratings ( id SERIAL PRIMARY KEY, deck_id INTEGER NOT NULL REFERENCES flashcard_decks(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), created_at TIMESTAMP DEFAULT NOW(), UNIQUE (user_id, deck_id) ) """)) # Quiz sharing conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 0")) conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS allow_review INTEGER DEFAULT 1")) # ── Course / LMS tables ────────────────────────────────────── conn.execute(text(""" CREATE TABLE IF NOT EXISTS courses ( id SERIAL PRIMARY KEY, title VARCHAR NOT NULL, description TEXT, thumbnail_path VARCHAR, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, status VARCHAR DEFAULT 'draft', is_featured INTEGER DEFAULT 0, requires_subscription INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ) """)) conn.execute(text(""" CREATE TABLE IF NOT EXISTS course_modules ( id SERIAL PRIMARY KEY, course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, title VARCHAR NOT NULL, description TEXT, position INTEGER DEFAULT 0 ) """)) conn.execute(text(""" CREATE TABLE IF NOT EXISTS course_lessons ( id SERIAL PRIMARY KEY, module_id INTEGER NOT NULL REFERENCES course_modules(id) ON DELETE CASCADE, title VARCHAR NOT NULL, description TEXT, position INTEGER DEFAULT 0, lesson_type VARCHAR NOT NULL, content_text TEXT, video_url VARCHAR, video_provider VARCHAR, local_file_path VARCHAR, quiz_id INTEGER REFERENCES quizzes(id) ON DELETE SET NULL, live_session_url VARCHAR, live_session_start TIMESTAMP, live_session_end TIMESTAMP, bbb_meeting_id VARCHAR, duration_minutes INTEGER, is_required INTEGER DEFAULT 1 ) """)) conn.execute(text(""" CREATE TABLE IF NOT EXISTS course_enrollments ( id SERIAL PRIMARY KEY, course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, enrolled_at TIMESTAMP DEFAULT NOW(), completed_at TIMESTAMP, progress_pct FLOAT DEFAULT 0.0, status VARCHAR DEFAULT 'enrolled', UNIQUE (course_id, user_id) ) """)) conn.execute(text(""" CREATE TABLE IF NOT EXISTS course_lesson_progress ( id SERIAL PRIMARY KEY, enrollment_id INTEGER NOT NULL REFERENCES course_enrollments(id) ON DELETE CASCADE, lesson_id INTEGER NOT NULL REFERENCES course_lessons(id) ON DELETE CASCADE, status VARCHAR DEFAULT 'not_started', started_at TIMESTAMP, completed_at TIMESTAMP, score FLOAT, time_spent_seconds INTEGER DEFAULT 0, UNIQUE (enrollment_id, lesson_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 # Stable int64 for pg_advisory_lock — arbitrary but must not collide with other uses. _STARTUP_DDL_LOCK_KEY = 8472931 def _run_startup_ddl(): """Serialize startup DDL across uvicorn workers using a Postgres advisory lock. Without this, N workers run ALTER TABLE / CREATE TABLE in parallel at startup and occasionally acquire AccessExclusiveLocks in different orders, tripping Postgres's deadlock detector and killing one worker. The DDL itself is idempotent (IF NOT EXISTS / create_all), so the losing worker runs it again as a no-op after the winner releases the lock. """ from sqlalchemy import text import logging log = logging.getLogger(__name__) # The advisory lock is session-scoped, held for the lifetime of this connection. with engine.connect() as lock_conn: log.info("Acquiring startup DDL advisory lock...") lock_conn.execute(text("SELECT pg_advisory_lock(:k)"), {"k": _STARTUP_DDL_LOCK_KEY}) lock_conn.commit() try: Base.metadata.create_all(bind=engine) setup_pgvector() finally: lock_conn.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": _STARTUP_DDL_LOCK_KEY}) lock_conn.commit() log.info("Startup DDL complete.") @asynccontextmanager async def lifespan(app: FastAPI): # Startup — all workers gate DDL behind a Postgres advisory lock to avoid deadlocks. _run_startup_ddl() 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 ) # Session middleware for OIDC state (authlib needs it) from starlette.middleware.sessions import SessionMiddleware app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY) # Add token refresh middleware AFTER CORS (middleware applies in reverse) from app.utils.auth import TokenRefreshMiddleware app.add_middleware(TokenRefreshMiddleware) # Request logging middleware — logs every request with user, duration, status from app.middleware.request_logging import RequestLoggingMiddleware app.add_middleware(RequestLoggingMiddleware) # 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.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcards"]) app.include_router(courses.router, prefix="/api/courses", tags=["courses"]) app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"]) app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"]) @app.get("/api/health") def health_check(): return {"status": "ok"}