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
649 lines
30 KiB
Python
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"}
|