Phase 1: Kern mit Plugin-System, Auth/Rollen, Migrations, Plugin-Stubs

- redaktionskern (src/): schlanker Kern — App-Fabrik, Plugin-Loader
  (Entry-Points + plugins/-Verzeichnis), Migrations-Laufzeit
  (schema_migrations pro Plugin, SQLite-/Postgres-portabel),
  Auth mit Argon2id + Session-Cookies, Rollen admin/redakteur/rezensent,
  Benutzerverwaltung für Admins
- plugins/: 8 ladbare Stubs (neuheiten, dedup, planung, archiv,
  erinnerung, benachrichtigung, audit-log, export) nach Plugin-Vertrag
- Frontend: Jinja2 + Tailwind (CDN) + HTMX + Alpine.js, UI deutsch
- Tests: 28 pytest-Fälle (Loader, Lifecycle, Entry-Points, Migrations-
  Idempotenz, Login/Logout, Rollen-Zugriff, Benutzerverwaltung)
- Docker/Podman: Compose (Traefik-Labels) + Dockerfile (uv)
- README.md mit Setup-Anleitung
This commit is contained in:
Flo Hartmann
2026-08-21 18:35:09 +00:00
parent 72f016a263
commit 96a37e878b
50 changed files with 3343 additions and 0 deletions

160
src/redaktionskern/app.py Normal file
View File

@@ -0,0 +1,160 @@
"""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,
)
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