147 lines
5.0 KiB
Python
147 lines
5.0 KiB
Python
"""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()
|