Files
spiele-redaktion/src/redaktionskern/app.py
Flo Hartmann 72fe3b7eda Plugin archiv: 12-Monats-Autopilot mit Sicherungsdatei-Prinzip
- Eigene Migration 0001_archiv_tabellen: Spiegel-Tabellen archiv_neuheiten
  und archiv_planung (ohne FK, damit Archiv-Zeilen Benutzerlöschung überleben)
- Service mit eingefrierbarer Uhr: Stichtag „jetzt minus 12 Monate“ in UTC,
  strenger Vergleich (genau 12 Monate bleibt aktiv); Neuheiten nach
  Erscheinungsjahr (großzügig Jahresende) sonst Eintragsdatum, Planung nach
  Eintragsdatum; Monatsarithmetik mit Klemmung (29. Februar)
- Täglicher APScheduler-Cron-Job (Standard 03:00, konfigurierbar über
  SPIELE_ARCHIV_JOB_UHRZEIT / SPIELE_ARCHIV_JOB_AKTIV); je archiviertem
  Titel ein Audit-Log-Eintrag als System
- Admin-Ansicht /archiv: vereinigte Liste beider Tabellen, Suche über
  Titel/Verlag/Autor, Herkunftsfilter, Wiederherstellen in die aktive Liste
  (BGG-Konflikt wird abgelehnt, verwaiste Rezensentenzuordnung übernimmt der
  Admin — gemeldet und auditiert); Rollen: nur Admin
- Kern minimal erweitert: PluginContext.registry stellt Hintergrund-Jobs die
  Plugin-Registry bereit (keine Fachlogik im Kern)
- 30 neue Tests mit eingefrorener Uhr (Grenzfälle, Zeitzonen, Schaltjahr,
  Job-Lauf, Wiederherstellen, Rollen, Suche/Filter); 171 Tests grün
- README aktualisiert: neues Plugin-Kapitel, Env-Variablen, Fortschritt #7 
2026-08-21 20:26:00 +00:00

162 lines
5.4 KiB
Python

"""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.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,
)
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