"""FastAPI app factory and lifespan (DB + Qdrant init, settings seed).""" from __future__ import annotations import os from contextlib import asynccontextmanager from collections.abc import AsyncIterator from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from sqlalchemy import select, text from app import __version__ from app.api import admin, auth, misc, presets, sessions, worlds from app.config import get_settings from app.core.logging import configure_logging, get_logger from app.core.qdrant_client import dispose_qdrant_client, init_qdrant_collections from app.core.settings_service import ( get_admin_setup_token, seed_default_settings, ) from app.db import dispose_engine, get_sessionmaker from app.models import Setting, User _logger = get_logger(__name__) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Application startup / shutdown lifecycle.""" cfg = get_settings() configure_logging() _logger.info("app_starting", version=__version__, debug=cfg.debug) # Startup ------------------------------------------------------------- # Step 0: Create all DB tables (idempotent — equivalent to alembic upgrade head). # This must run BEFORE any settings query, otherwise seed_default_settings # crashes with "relation 'settings' does not exist". from app.db import get_engine from app.migrations.versions._001_initial_schema import create_all_tables try: engine = get_engine() await create_all_tables(engine) _logger.info("db_tables_ready") except Exception as e: # noqa: BLE001 _logger.error("db_tables_create_failed", error=str(e)) # Continue anyway — /api/health will reflect the broken state sm = get_sessionmaker() try: async with sm() as session: # 1) seed settings (idempotent) await seed_default_settings(session) # 2) ensure admin setup token token = await get_admin_setup_token(session) # 3) check if any admin exists from sqlalchemy import func admins_count = ( await session.execute( select(func.count(User.id)).where(User.is_admin.is_(True)) ) ).scalar_one() if admins_count == 0: _logger.warning( "no_admin_yet", setup_url=f"/register/admin?token={token}", ) print(f"\n=== AI-RPG Admin Setup ===") print(f"No admin user yet. Open this URL in your browser:") print(f" /register/admin?token={token}") print(f"===========================\n") else: _logger.info("admins_present", count=admins_count) # 4) seed builtin presets (idempotent) from app.migrations.seed import seed_builtin_presets try: await seed_builtin_presets(session) _logger.info("presets_seeded") except Exception as e: # noqa: BLE001 _logger.warning("presets_seed_failed", error=str(e)) # 5) Init Qdrant collections from app.core.settings_service import get_setting dim = int(await get_setting(session, "embeddings.dimension") or 256) try: await init_qdrant_collections(dim) _logger.info("qdrant_collections_ready", dimension=dim) except Exception as e: # noqa: BLE001 _logger.warning("qdrant_init_failed", error=str(e)) except Exception as e: # noqa: BLE001 _logger.error("startup_failed", error=str(e)) # Don't crash — let /api/health reflect the broken state pass yield # Shutdown ------------------------------------------------------------ _logger.info("app_stopping") await dispose_qdrant_client() await dispose_engine() def create_app() -> FastAPI: """Build and return the FastAPI application.""" cfg = get_settings() app = FastAPI( title=cfg.app_name, version=__version__, description="AI-RPG — text RPG with an LLM Game Master.", lifespan=lifespan, docs_url="/api/docs", redoc_url=None, openapi_url="/api/openapi.json", ) # CORS app.add_middleware( CORSMiddleware, allow_origins=cfg.cors_origins_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Static asset serving (icons / uploads) assets_dir = Path(cfg.assets_dir) assets_dir.mkdir(parents=True, exist_ok=True) app.mount("/static/assets", StaticFiles(directory=str(assets_dir)), name="assets") # Routers app.include_router(misc.router) app.include_router(auth.router) app.include_router(worlds.router) app.include_router(sessions.router) app.include_router(presets.router) app.include_router(admin.router) return app app = create_app()