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

View File

@@ -0,0 +1,101 @@
"""Der Plugin-Vertrag.
Ein Plugin ist eine Klasse, die von BasePlugin erbt, und ein Modulattribut
`plugin` mit einer Instanz davon. Der Kern ruft beim Start `on_load(kontext)`
auf, beim Herunterfahren `on_unload()`. Fachlogik gehört ausschließlich in
Plugins — der Kern kennt keine Plugin-Inhalte.
"""
from __future__ import annotations
import sys
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar
from fastapi import APIRouter
from sqlalchemy import Connection
if TYPE_CHECKING:
from fastapi.templating import Jinja2Templates
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
from redaktionskern.config import Settings
@dataclass(frozen=True)
class NavEntry:
"""Ein Eintrag in der Hauptnavigation."""
label: str
url: str
@dataclass(frozen=True)
class Migration:
"""Eine Schema-Migration des Plugins."""
version: str
up: Callable[[Connection], None]
class PluginContext:
"""Alles, was der Kern einem Plugin beim Laden übergibt."""
def __init__(
self,
engine: "Engine",
session_factory: "sessionmaker",
settings: "Settings",
templates: "Jinja2Templates",
) -> None:
self.engine = engine
self.session_factory = session_factory
self.settings = settings
self.templates = templates
class BasePlugin:
"""Basis-Klasse für alle Plugins (der Plugin-Vertrag).
Pflicht: die Klassenattribute `name`, `title`, `description`.
Optional überschreibbar: migrations(), navigation(), templates_dir(),
on_load(), on_unload().
"""
name: ClassVar[str]
title: ClassVar[str] = ""
description: ClassVar[str] = ""
version: ClassVar[str] = "0.1.0"
def __init__(self) -> None:
self.router = APIRouter()
self.context: PluginContext | None = None
self.loaded = False
def migrations(self) -> list[Migration]:
"""Eigene Schema-Migrationen des Plugins (leer = keine)."""
return []
def navigation(self) -> list[NavEntry]:
"""Einträge für die Hauptnavigation (leer = keiner)."""
return []
def templates_dir(self) -> Path | None:
"""Template-Ordner des Plugins (<Paket>/templates), falls vorhanden."""
modul = sys.modules.get(type(self).__module__)
datei = getattr(modul, "__file__", None) if modul else None
if datei is None:
return None
verzeichnis = Path(datei).parent / "templates"
return verzeichnis if verzeichnis.is_dir() else None
def on_load(self, context: PluginContext) -> None:
"""Lifecycle-Hook: wird beim Start des Kerns aufgerufen."""
self.context = context
self.loaded = True
def on_unload(self) -> None:
"""Lifecycle-Hook: wird beim Herunterfahren aufgerufen."""
self.loaded = False