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:
160
src/redaktionskern/app.py
Normal file
160
src/redaktionskern/app.py
Normal 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
|
||||
Reference in New Issue
Block a user