"""App-Fabrik: baut die FastAPI-Anwendung aus Kern und Plugins zusammen. Ablauf bei create_app(): 1. Plugins entdecken (Entry-Points + plugins/-Verzeichnis) 2. Jinja-Umgebung aufbauen (Kern-Templates + Plugin-Templates) 3. Engine/Session erstellen, Kern- und Plugin-Migrationen ausführen 4. Plugin-Lifecycle on_load() aufrufen, Routen registrieren 5. Initialen Admin anlegen, falls die Benutzertabelle leer ist Beim Herunterfahren (Lifespan) werden on_unload() und die Engine freigegeben. """ from __future__ import annotations from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape from sqlalchemy import func, select from starlette.middleware.sessions import SessionMiddleware from redaktionskern.auth import routen as auth_routen from redaktionskern.auth.deps import AccessDenied, NotAuthenticated from redaktionskern.auth.models import ROLLEN_ANZEIGE, Role, User from redaktionskern.auth.security import hash_password from redaktionskern.config import Settings from redaktionskern.contracts import Migration, PluginContext from redaktionskern.db import make_engine, make_session_factory from redaktionskern.migrationen import run_migrations from redaktionskern.plugin_loader import discover_plugins CORE_TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" def _kern_migrationen() -> list[Migration]: def benutzer_tabelle(conn) -> None: User.__table__.create(conn, checkfirst=True) return [Migration(version="0001_benutzer", up=benutzer_tabelle)] def _initialen_admin_anlegen(session_factory, settings: Settings) -> None: with session_factory() as db: vorhanden = db.scalar(select(func.count()).select_from(User)) if vorhanden: return db.add( User( username="admin", display_name="Administration", password_hash=hash_password(settings.initial_admin_password), role=Role.ADMIN.value, active=True, ) ) db.commit() def create_app(settings: Settings | None = None) -> FastAPI: settings = settings or Settings() # 1) Plugins entdecken registry = discover_plugins(settings.plugins_dir, settings.entry_point_group) plugins = registry.all() # 2) Jinja-Umgebung: Kern zuerst, dann Plugin-Templates template_dirs = [CORE_TEMPLATE_DIR] template_dirs += [d for d in (p.templates_dir() for p in plugins) if d] env = Environment( loader=ChoiceLoader([FileSystemLoader(str(d)) for d in template_dirs]), autoescape=select_autoescape(default=True, default_for_string=True), ) templates = Jinja2Templates(env=env) # 3) Datenbank + Migrationen engine = make_engine(settings.database_url) session_factory = make_session_factory(engine) context = PluginContext( engine=engine, session_factory=session_factory, settings=settings, templates=templates, registry=registry, ) with engine.begin() as conn: run_migrations(conn, "core", _kern_migrationen()) for plugin in plugins: with engine.begin() as conn: run_migrations(conn, plugin.name, plugin.migrations()) # 4) Lifecycle + Router for plugin in plugins: plugin.on_load(context) @asynccontextmanager async def lifespan(_: FastAPI): yield for plugin in reversed(plugins): plugin.on_unload() engine.dispose() app = FastAPI( title="Spiele-Redaktion", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None, ) # Statische Kern-Dateien (z. B. „Powered by BGG“-Logo, Pflicht für # öffentliche BGG-XML-API-Anwendungen). statisch = Path(__file__).parent / "static" app.mount("/static", StaticFiles(directory=statisch), name="statisch") app.state.settings = settings app.state.engine = engine app.state.session_factory = session_factory app.state.templates = templates app.state.registry = registry templates.env.globals["app_name"] = "Spiele-Redaktion" templates.env.globals["rollen_anzeige"] = ROLLEN_ANZEIGE templates.env.globals["plugin_nav"] = [ {"label": eintrag.label, "url": eintrag.url} for plugin in plugins for eintrag in plugin.navigation() ] templates.env.globals["geladene_plugins"] = plugins app.include_router(auth_routen.router) app.include_router(auth_routen.admin_router) for plugin in plugins: app.include_router(plugin.router) app.add_middleware( SessionMiddleware, secret_key=settings.session_secret, max_age=settings.session_dauer_sekunden, same_site="lax", ) def _aktueller_benutzer_aus_session(request: Request) -> User | None: user_id = request.session.get("user_id") if user_id is None: return None with session_factory() as db: return db.get(User, user_id) @app.exception_handler(NotAuthenticated) async def nicht_angemeldet_handler(_: Request, __: NotAuthenticated): return RedirectResponse("/login", status_code=303) @app.exception_handler(AccessDenied) async def kein_zugriff_handler(request: Request, _: AccessDenied): return templates.TemplateResponse( request=request, name="403.html", context={"user": _aktueller_benutzer_aus_session(request)}, status_code=403, ) # 5) Initialer Admin _initialen_admin_anlegen(session_factory, settings) return app