pdf-quiz-generator/backend/app/main.py
Daniel cf2f42975e
Some checks failed
Tests / backend (push) Failing after 8s
Tests / frontend (push) Successful in 33s
Tests / e2e (push) Failing after 33s
feat: no invite codes and no email sign-in codes — that is the provider's job
Both were this app doing an identity provider's work. Sign-in and
sign-up happen at sso.pedshub.com now: it takes the address, sends the
code, checks it, and knows about second factors — none of which belongs
here, and two of which were never done here at all.

Gone: services/invites.py, services/login_codes.py,
routers/login_code.py, the two models, the three admin invite routes,
the invite_only flag and its switch, the invite field on both sign-up
forms, and the code half of the sign-in page — which was the primary way
in and is now a button that says "Sign in with PedsHub SSO". The
password form stays for a site with no provider configured.

Migration r7b8c9d0e1f2 drops invite_codes (three spent rows) and
login_codes (empty). The dump beside it has both.

585 tests, and the contract snapshot is 320 routes — five fewer, all
five named in the diff so the removal is reviewable rather than
discovered later by a client.

Also: "Make a deck" in the signed-in menu and on the landing page, going
to the scribe's My Resources at app.pedshub.com/#resources. Same
sign-in on both sides; the arrow says it leaves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 13:56:48 +02:00

649 lines
30 KiB
Python

import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
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.api import errors as api_errors
from app.api.versioning import VERSIONED_ROOT, VersionAlias
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, mynote, exams
from app.routers import access
from app.routers import feedback
from app.routers import folders
from app.routers import study_tools, uploads, articles, share, collections, study_plans, media, search, ai_mode, drafts, public
from app.utils.auth import get_password_hash
def seed_admin():
"""Create the bootstrap admin if there is none, and say how to sign in as it."""
import logging
import secrets
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:
log.info("No admin exists; skipping bootstrap admin seed. First registered user will become admin.")
return
# A password from the environment if one is set, otherwise one
# generated here and printed once. A fresh stack with no way in is
# a fresh stack nobody can use; a fresh stack with a *guessable*
# way in is worse. This is the third option: unguessable, and
# written where whoever started it is already looking.
password = (settings.DEFAULT_ADMIN_PASSWORD or "").strip()
generated = not password
if generated:
password = secrets.token_urlsafe(15)
elif len(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(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()
if generated:
# Loud, and once: this is the only time it exists in plain
# text. `docker compose logs backend | grep -A3 "FIRST ADMIN"`.
log.warning(
"\n%s\nFIRST ADMIN CREATED — change this password after signing in\n"
" email: %s\n password: %s\n%s",
"=" * 62, admin_user.email, password, "=" * 62)
else:
log.info("First admin created from DEFAULT_ADMIN_PASSWORD: %s", admin_user.email)
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, refresh_token # noqa
from app.models import category_grant, conversation, exam, media, question_media, study_plan # noqa
from app.models import folder # 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
"""))
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
"""))
# 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.execute(text("""
CREATE TABLE IF NOT EXISTS question_classification_snapshots (
id SERIAL PRIMARY KEY,
job_id VARCHAR(64),
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
reason VARCHAR(120) NOT NULL DEFAULT 'before_ai_classification',
question_count INTEGER NOT NULL DEFAULT 0,
link_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS ix_question_classification_snapshots_created_at
ON question_classification_snapshots (created_at DESC)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS question_classification_snapshot_links (
snapshot_id INTEGER NOT NULL REFERENCES question_classification_snapshots(id) ON DELETE CASCADE,
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
tag_name VARCHAR(200) NOT NULL,
tag_type VARCHAR(50) NOT NULL,
PRIMARY KEY (snapshot_id, question_id, tag_name, tag_type)
)
"""))
# 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 questions ADD COLUMN IF NOT EXISTS explanation_image_path VARCHAR"))
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 _create_vector_extension():
"""`CREATE EXTENSION vector`, before anything declares a column of that type."""
from sqlalchemy import text
import logging
try:
with engine.connect() as conn:
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
conn.commit()
except Exception as exc:
# Not fatal on its own: a database where the extension cannot be
# installed will fail loudly at create_all a moment later, and saying
# so twice helps nobody read the traceback.
logging.getLogger(__name__).warning("Could not create the vector extension: %s", exc)
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:
# The extension first, and on its own. Several tables declare a
# `vector` column, so `create_all` against a database that has
# never had pgvector installed dies on the first of them — which
# is every fresh deploy, and was invisible here because the
# long-lived database had the extension already.
_create_vector_extension()
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()
# The backfill must run in one worker only, or several race each other.
if _acquire_singleton_lock():
backfill_embeddings()
yield
app = FastAPI(
title="PedsHub",
description=(
"The API behind PedsHub: a question bank sat as timed or study "
"sessions, a library of articles and cards written against it, and the "
"analysis of what a learner actually knows.\n\n"
"Every route lives under `/api/v1`. `/api/...` reaches the same route "
"and always will — it is the address the web app was written against — "
"but a client written today should say the version, so that the day "
"`/api/v2` exists it keeps the behaviour it was built on.\n\n"
"Errors carry an `error` object: `code` is a stable word to switch on, "
"`message` is one line for a person, and `fields` says which inputs "
"were wrong when that is the problem."
),
version="3.0.0",
lifespan=lifespan,
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
)
api_errors.install(app)
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).
#
# The OIDC state and nonce live in this cookie for the length of one redirect,
# and they are what stops somebody replaying an authorization response at you.
# `lax` because the provider sends the browser back with a top-level GET, which
# lax allows and `strict` would drop — taking the state with it and failing
# every sign-in. Secure whenever the site is served over https, which is what
# APP_URL says; a plain-http development host keeps a cookie it can actually
# set.
from starlette.middleware.sessions import SessionMiddleware
app.add_middleware(
SessionMiddleware,
secret_key=settings.SECRET_KEY,
same_site="lax",
https_only=str(getattr(settings, "APP_URL", "")).startswith("https://"),
)
# 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)
# Outermost, so that everything downstream — routing, logging, the docs — sees
# one address per endpoint however the caller spelled it.
app.add_middleware(VersionAlias)
# Serve uploaded images as static files. Not versioned: an image is at a URL
# that gets written into markdown and shared, and those must not move.
app.include_router(uploads.router)
app.include_router(auth.router, prefix=f"{VERSIONED_ROOT}/auth", tags=["auth"])
# Counts the landing page states about itself. No auth: a stranger reads it.
app.include_router(public.router, prefix=f"{VERSIONED_ROOT}/public", tags=["public"])
app.include_router(articles.router, prefix=f"{VERSIONED_ROOT}/articles", tags=["articles"])
app.include_router(access.router, prefix=f"{VERSIONED_ROOT}/access", tags=["access"])
app.include_router(feedback.router, prefix=f"{VERSIONED_ROOT}/feedback", tags=["feedback"])
app.include_router(folders.router, prefix=f"{VERSIONED_ROOT}/folders", tags=["folders"])
app.include_router(exams.router, prefix=f"{VERSIONED_ROOT}/exams", tags=["exams"])
app.include_router(study_plans.router, prefix=f"{VERSIONED_ROOT}/study-plans", tags=["study-plans"])
app.include_router(drafts.router, prefix=f"{VERSIONED_ROOT}/drafts", tags=["drafts"])
app.include_router(media.router, prefix=f"{VERSIONED_ROOT}/media", tags=["media"])
app.include_router(share.router, prefix=f"{VERSIONED_ROOT}/share", tags=["share"])
app.include_router(collections.router, prefix=f"{VERSIONED_ROOT}/collections", tags=["collections"])
app.include_router(documents.router, prefix=f"{VERSIONED_ROOT}/documents", tags=["documents"])
app.include_router(quizzes.router, prefix=f"{VERSIONED_ROOT}/quizzes", tags=["quizzes"])
app.include_router(attempts.router, prefix=f"{VERSIONED_ROOT}/attempts", tags=["attempts"])
app.include_router(admin.router, prefix=f"{VERSIONED_ROOT}/admin", tags=["admin"])
app.include_router(tts.router, prefix=f"{VERSIONED_ROOT}/tts", tags=["tts"])
app.include_router(nextcloud.router, prefix=f"{VERSIONED_ROOT}/nextcloud", tags=["nextcloud"])
app.include_router(categories.router, prefix=f"{VERSIONED_ROOT}/categories", tags=["categories"])
app.include_router(questions.router, prefix=f"{VERSIONED_ROOT}/questions", tags=["questions"])
app.include_router(question_categories.router, prefix=f"{VERSIONED_ROOT}/question-categories", tags=["question-categories"])
app.include_router(favorites.router, prefix=f"{VERSIONED_ROOT}/favorites", tags=["favorites"])
app.include_router(teach.router, prefix=f"{VERSIONED_ROOT}/teach", tags=["teach"])
app.include_router(contact.router, prefix=f"{VERSIONED_ROOT}/contact", tags=["contact"])
app.include_router(tags.router, prefix=f"{VERSIONED_ROOT}/tags", tags=["tags"])
app.include_router(flashcards.router, prefix=f"{VERSIONED_ROOT}/flashcards", tags=["flashcards"])
app.include_router(mynote.router, prefix=f"{VERSIONED_ROOT}/mynote", tags=["mynote"])
app.include_router(study_tools.router, prefix=f"{VERSIONED_ROOT}/study-tools", tags=["study-tools"])
app.include_router(search.router, prefix=f"{VERSIONED_ROOT}/search", tags=["search"])
app.include_router(ai_mode.router, prefix=f"{VERSIONED_ROOT}/ai", tags=["ai-mode"])
@app.get("/api/health", tags=["health"])
@app.get(f"{VERSIONED_ROOT}/health", tags=["health"], include_in_schema=False)
def health_check():
"""Is the process up. Deliberately unversioned as well as versioned: the
thing that watches this is a monitor nobody edits for a year."""
return {"status": "ok"}