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,8 @@
"""Spiele-Redaktion — schlanker Kern mit Plugin-Architektur.
Der Kern stellt nur Infrastruktur bereit: App-Fabrik, Datenbank,
Migrations-Laufzeit, Plugin-Loader und Authentifizierung.
Fachliche Funktionen liegen ausschließlich in Plugins.
"""
__version__ = "0.1.0"

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

View File

@@ -0,0 +1 @@
"""Authentifizierung und Autorisierung (Kern-Infrastruktur)."""

View File

@@ -0,0 +1,48 @@
"""FastAPI-Abhängigkeiten für Authentifizierung und Rollen."""
from __future__ import annotations
from fastapi import Depends, Request
from sqlalchemy.orm import Session
from redaktionskern.auth.models import User
class NotAuthenticated(Exception):
"""Benutzer ist nicht (mehr) angemeldet → Weiterleitung zum Login."""
class AccessDenied(Exception):
"""Benutzer hat die nötige Rolle nicht → 403-Seite."""
def get_db(request: Request):
"""Stellt eine Datenbank-Session pro Anfrage bereit."""
db: Session = request.app.state.session_factory()
try:
yield db
finally:
db.close()
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
user_id = request.session.get("user_id")
if user_id is None:
return None
return db.get(User, user_id)
def require_user(user: User | None = Depends(get_current_user)) -> User:
if user is None or not user.active:
raise NotAuthenticated
return user
def require_roles(*roles: str):
"""Abhängigkeits-Fabrik: verlangt eine der angegebenen Rollen."""
def abhaengigkeit(user: User = Depends(require_user)) -> User:
if user.role not in roles:
raise AccessDenied(f"Rolle '{user.role}' hat keinen Zugriff.")
return user
return abhaengigkeit

View File

@@ -0,0 +1,39 @@
"""Benutzer-Model des Kerns."""
from __future__ import annotations
import enum
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from redaktionskern.db import Base
class Role(str, enum.Enum):
ADMIN = "admin"
REDAKTEUR = "redakteur"
REZENSENT = "rezensent"
ROLEN: list[str] = [rolle.value for rolle in Role]
ROLLEN_ANZEIGE: dict[str, str] = {
Role.ADMIN.value: "Administrator",
Role.REDAKTEUR.value: "Redakteur",
Role.REZENSENT.value: "Rezensent",
}
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
display_name: Mapped[str] = mapped_column(String(200))
password_hash: Mapped[str] = mapped_column(String(300))
role: Mapped[str] = mapped_column(String(20), default=Role.REDAKTEUR.value)
active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)

View File

@@ -0,0 +1,232 @@
"""Routen: Login/Logout, Dashboard und Benutzerverwaltung (nur Admin)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_current_user, get_db, require_roles, require_user
from redaktionskern.auth.models import ROLEN, Role, User
from redaktionskern.auth.security import hash_password, verify_password
router = APIRouter()
admin_router = APIRouter(
prefix="/admin", dependencies=[Depends(require_roles(Role.ADMIN.value))]
)
def _render(request: Request, name: str, status_code: int = 200, **kontext):
return request.app.state.templates.TemplateResponse(
request=request, name=name, context=kontext, status_code=status_code
)
# ---------------- Dashboard ----------------
@router.get("/")
def dashboard(request: Request, user: User = Depends(require_user)):
return _render(request, "dashboard.html", user=user)
# ---------------- Login / Logout ----------------
@router.get("/login")
def login_form(
request: Request, user: User | None = Depends(get_current_user)
):
if user is not None:
return RedirectResponse("/", status_code=303)
return _render(request, "login.html", user=None, fehler=None, username="")
@router.post("/login")
def login(
request: Request,
db: Session = Depends(get_db),
username: str = Form(...),
password: str = Form(...),
):
username = username.strip()
user = db.scalar(select(User).where(User.username == username))
fehler: str | None
if user is None or not verify_password(user.password_hash, password):
fehler = "Benutzername oder Passwort ist falsch."
elif not user.active:
fehler = "Dieses Konto ist deaktiviert."
else:
fehler = None
if fehler is not None:
return _render(
request, "login.html", user=None, fehler=fehler, username=username
)
request.session["user_id"] = user.id
return RedirectResponse("/", status_code=303)
@router.post("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
# ---------------- Benutzerverwaltung (Admin) ----------------
def _formular_werte(benutzer: User | None) -> dict:
if benutzer is None:
return {"username": "", "display_name": "", "role": Role.REDAKTEUR.value}
return {
"username": benutzer.username,
"display_name": benutzer.display_name,
"role": benutzer.role,
}
@admin_router.get("/benutzer")
def benutzer_liste(
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
benutzer = db.scalars(select(User).order_by(User.username)).all()
return _render(
request, "benutzer/liste.html", user=user, benutzer=benutzer, fehler=None
)
@admin_router.get("/benutzer/neu")
def benutzer_neu_form(request: Request, user: User = Depends(require_user)):
return _render(
request,
"benutzer/formular.html",
user=user,
aktion="neu",
ziel=None,
form=_formular_werte(None),
fehler=None,
)
def _validiere_neuen_benutzer(
db: Session, username: str, role: str, password: str
) -> str | None:
if len(username) < 3:
return "Der Benutzername muss mindestens 3 Zeichen lang sein."
if db.scalar(select(User).where(User.username == username)) is not None:
return "Dieser Benutzername ist bereits vergeben."
if role not in ROLEN:
return "Unbekannte Rolle."
if len(password) < 8:
return "Das Passwort muss mindestens 8 Zeichen lang sein."
return None
@admin_router.post("/benutzer/neu")
def benutzer_neu(
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
username: str = Form(...),
display_name: str = Form(""),
role: str = Form(...),
password: str = Form(...),
):
username = username.strip()
fehler = _validiere_neuen_benutzer(db, username, role, password)
if fehler is not None:
return _render(
request,
"benutzer/formular.html",
status_code=400,
user=user,
aktion="neu",
ziel=None,
form={"username": username, "display_name": display_name, "role": role},
fehler=fehler,
)
db.add(
User(
username=username,
display_name=display_name.strip() or username,
password_hash=hash_password(password),
role=role,
active=True,
)
)
db.commit()
return RedirectResponse("/admin/benutzer", status_code=303)
def _lade_oder_404(db: Session, benutzer_id: int) -> User:
ziel = db.get(User, benutzer_id)
if ziel is None:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden.")
return ziel
@admin_router.get("/benutzer/{benutzer_id}")
def benutzer_bearbeiten_form(
benutzer_id: int,
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
ziel = _lade_oder_404(db, benutzer_id)
return _render(
request,
"benutzer/formular.html",
user=user,
aktion="bearbeiten",
ziel=ziel,
form=_formular_werte(ziel),
fehler=None,
)
@admin_router.post("/benutzer/{benutzer_id}")
def benutzer_bearbeiten(
benutzer_id: int,
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
display_name: str = Form(""),
role: str = Form(...),
active: str = Form(""),
password: str = Form(""),
):
ziel = _lade_oder_404(db, benutzer_id)
aktiv = active == "on"
fehler: str | None
if role not in ROLEN:
fehler = "Unbekannte Rolle."
elif password and len(password) < 8:
fehler = "Das Passwort muss mindestens 8 Zeichen lang sein."
elif ziel.id == user.id and (role != Role.ADMIN.value or not aktiv):
fehler = "Sie können Ihre eigene Rolle oder Ihren eigenen Status nicht ändern."
else:
fehler = None
if fehler is not None:
return _render(
request,
"benutzer/formular.html",
status_code=400,
user=user,
aktion="bearbeiten",
ziel=ziel,
form={"username": ziel.username, "display_name": display_name, "role": role},
fehler=fehler,
)
ziel.display_name = display_name.strip() or ziel.username
ziel.role = role
ziel.active = aktiv
if password:
ziel.password_hash = hash_password(password)
db.commit()
return RedirectResponse("/admin/benutzer", status_code=303)
@admin_router.get("/benutzern-anzahl")
def benutzer_anzahl(db: Session = Depends(get_db)):
"""Kleine Diagnose-Route für den Admin (zeigt, dass die DB lebt)."""
anzahl = db.scalar(select(func.count()).select_from(User))
return {"anzahl": anzahl}

View File

@@ -0,0 +1,19 @@
"""Passwort-Hashing mit Argon2id."""
from __future__ import annotations
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
_hasher = PasswordHasher()
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
try:
_hasher.verify(password_hash, password)
except (VerifyMismatchError, InvalidHashError):
return False
return True

View File

@@ -0,0 +1,44 @@
"""Konfiguration über Umgebungsvariablen mit sinnvollen Dev-Defaults."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
_PROJEKT_WURZEL = Path(__file__).resolve().parents[2]
ENTRY_POINT_GRUPPE = "spiele_redaktion.plugins"
def _standard_plugins_verzeichnis() -> Path:
umgebung = os.environ.get("SPIELE_PLUGINS_DIR")
if umgebung:
return Path(umgebung)
lokal = _PROJEKT_WURZEL / "plugins"
if lokal.is_dir():
return lokal
return Path("plugins")
@dataclass
class Settings:
"""Laufzeit-Einstellungen. Alle Felder sind per Umgebungsvariable überschreibbar."""
database_url: str = field(
default_factory=lambda: os.environ.get(
"SPIELE_DATABASE_URL", "sqlite:///./data/spiele-redaktion.db"
)
)
session_secret: str = field(
default_factory=lambda: os.environ.get(
"SPIELE_SESSION_SECRET", "dev-secret-nur-fuer-lokal-aendern"
)
)
session_dauer_sekunden: int = field(
default_factory=lambda: int(os.environ.get("SPIELE_SESSION_DAUER", "43200"))
)
initial_admin_password: str = field(
default_factory=lambda: os.environ.get("SPIELE_INITIAL_ADMIN_PASSWORD", "admin")
)
plugins_dir: Path = field(default_factory=_standard_plugins_verzeichnis)
entry_point_group: str = field(default_factory=lambda: ENTRY_POINT_GRUPPE)

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

32
src/redaktionskern/db.py Normal file
View File

@@ -0,0 +1,32 @@
"""Datenbank-Grundlagen: Engine, Session-Fabrik, deklarative Basis.
SQLite ist der Standard; über SPIELE_DATABASE_URL ist auch Postgres möglich.
Alle Models nutzen portable Spaltentypen, damit Migrationen auf beiden
Datenbanken laufen.
"""
from __future__ import annotations
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
class Base(DeclarativeBase):
"""Gemeinsame deklarative Basis für Kern- und Plugin-Models."""
def make_engine(database_url: str) -> Engine:
"""Erzeugt eine Engine; legt für SQLite das Elternverzeichnis an."""
if database_url.startswith("sqlite"):
pfad = database_url.split("///", 1)[-1]
if pfad and pfad != ":memory:":
Path(pfad).parent.mkdir(parents=True, exist_ok=True)
# check_same_thread=False: FastAPI führt sync-Endpunkte im Threadpool aus.
return create_engine(database_url, connect_args={"check_same_thread": False})
return create_engine(database_url)
def make_session_factory(engine: Engine) -> sessionmaker[Session]:
return sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)

View File

@@ -0,0 +1,53 @@
"""Migrations-Laufzeit.
Jedes Plugin (und der Kern selbst) liefert geordnete Migrationen als
Liste von Migration(version, up). Der Stand wird pro Plugin in der
Tabelle `schema_migrations` festgehalten. Die `up`-Funktionen bekommen
eine SQLAlchemy-Connection und können bei Bedarf pro Dialekt verzweigen —
so bleiben die Migrationen SQLite- und Postgres-tauglich.
"""
from __future__ import annotations
from collections.abc import Callable
from sqlalchemy import Column, Connection, DateTime, MetaData, Table, String, func, select
from redaktionskern.contracts import Migration
_metadata = MetaData()
schema_migrations = Table(
"schema_migrations",
_metadata,
Column("plugin", String(100), primary_key=True),
Column("version", String(200), primary_key=True),
Column("applied_at", DateTime, nullable=False, server_default=func.now()),
)
def run_migrations(
conn: Connection, plugin: str, migrations: list[Migration]
) -> list[str]:
"""Führt noch nicht angewendete Migrationen aus und gibt deren Versionen zurück.
Der Aufrufer ist für die Transaktion verantwortlich (engine.begin()).
"""
schema_migrations.create(conn, checkfirst=True)
angewendet = {
version
for (version,) in conn.execute(
select(schema_migrations.c.version).where(
schema_migrations.c.plugin == plugin
)
)
}
neu_angewendet: list[str] = []
for migration in sorted(migrations, key=lambda m: m.version):
if migration.version in angewendet:
continue
migration.up(conn)
conn.execute(
schema_migrations.insert().values(plugin=plugin, version=migration.version)
)
neu_angewendet.append(migration.version)
return neu_angewendet

View File

@@ -0,0 +1,113 @@
"""Plugin-Loader.
Plugins werden aus zwei Quellen geladen:
1. Installierte Pakete mit Entry-Points der Gruppe `spiele_redaktion.plugins`
(via importlib.metadata) — für später ausgelieferte Plugin-Pakete.
2. Ein lokales `plugins/`-Verzeichnis — jeder Unterordner mit __init__.py
ist ein Plugin (Modulattribut `plugin` = BasePlugin-Instanz).
"""
from __future__ import annotations
import importlib.metadata
import importlib.util
import sys
from importlib.metadata import entry_points
from pathlib import Path
from redaktionskern.contracts import BasePlugin
ENTRY_POINT_GROUP = "spiele_redaktion.plugins"
_MODUL_PRAEFIX = "spiele_redaktion_plugins"
class PluginError(Exception):
"""Fehler beim Laden oder Registrieren eines Plugins."""
def _als_plugin(obj: object) -> BasePlugin:
"""Akzeptiert eine Instanz oder eine Klasse (wird instanziert)."""
if isinstance(obj, type) and issubclass(obj, BasePlugin):
obj = obj()
if not isinstance(obj, BasePlugin):
raise PluginError(
f"Erwartet wurde eine BasePlugin-Instanz, erhalten: {type(obj).__name__}"
)
return obj
def _aus_entry_points(group: str) -> list[BasePlugin]:
plugins: list[BasePlugin] = []
for entry_point in entry_points(group=group):
try:
plugins.append(_als_plugin(entry_point.load()))
except PluginError:
raise
except Exception as exc: # Entry-Point kann nicht geladen werden
raise PluginError(
f"Entry-Point '{entry_point.name}' (Gruppe {group}) "
f"konnte nicht geladen werden: {exc}"
) from exc
return plugins
def _aus_verzeichnis(verzeichnis: Path) -> list[BasePlugin]:
plugins: list[BasePlugin] = []
if not verzeichnis.is_dir():
return plugins
for eintrag in sorted(verzeichnis.iterdir()):
if not eintrag.is_dir() or eintrag.name.startswith(("_", ".")):
continue
init = eintrag / "__init__.py"
if not init.is_file():
continue
modulname = f"{_MODUL_PRAEFIX}.{eintrag.name}"
spec = importlib.util.spec_from_file_location(
modulname, init, submodule_search_locations=[str(eintrag)]
)
if spec is None or spec.loader is None:
raise PluginError(f"Plugin '{eintrag.name}' kann nicht geladen werden.")
modul = importlib.util.module_from_spec(spec)
sys.modules[modulname] = modul
spec.loader.exec_module(modul)
if not hasattr(modul, "plugin"):
raise PluginError(
f"Plugin '{eintrag.name}' definiert kein Modulattribut 'plugin'."
)
plugins.append(_als_plugin(modul.plugin))
return plugins
class PluginRegistry:
"""Verwaltet geladene Plugins; Plugin-Namen sind eindeutig."""
def __init__(self) -> None:
self._plugins: dict[str, BasePlugin] = {}
def register(self, plugin: BasePlugin) -> None:
if plugin.name in self._plugins:
raise PluginError(
f"Doppeltes Plugin '{plugin.name}' — Plugin-Namen müssen eindeutig sein."
)
self._plugins[plugin.name] = plugin
def all(self) -> list[BasePlugin]:
return list(self._plugins.values())
def get(self, name: str) -> BasePlugin | None:
return self._plugins.get(name)
def __len__(self) -> int:
return len(self._plugins)
def discover_plugins(
plugins_dir: Path | None, entry_point_group: str = ENTRY_POINT_GROUP
) -> PluginRegistry:
"""Lädt Plugins aus Entry-Points und dem lokalen Verzeichnis."""
registry = PluginRegistry()
for plugin in _aus_entry_points(entry_point_group):
registry.register(plugin)
if plugins_dir is not None:
for plugin in _aus_verzeichnis(plugins_dir):
registry.register(plugin)
return registry

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block titel %}Kein Zugriff &mdash; {{ app_name }}{% endblock %}
{% block inhalt %}
<div class="bg-white border border-slate-200 rounded-xl p-8 max-w-lg">
<h1 class="text-xl font-bold text-red-700 mb-2">403 &mdash; Kein Zugriff</h1>
<p class="text-slate-600 mb-4">
Ihre Rolle{% if user %} ({{ rollen_anzeige[user.role] or user.role }}){% endif %} erlaubt den Zugriff auf diese Seite nicht.
</p>
<a href="/" class="text-emerald-700 font-medium">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,48 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block titel %}{{ app_name }}{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
</head>
<body class="bg-slate-100 text-slate-800 min-h-screen flex flex-col">
<header class="bg-white border-b border-slate-200">
<nav class="max-w-6xl mx-auto px-4 py-3 flex items-center gap-5" aria-label="Hauptnavigation">
<a href="/" class="font-bold text-emerald-700 whitespace-nowrap">&#127923; {{ app_name }}</a>
{% for eintrag in plugin_nav %}
<a href="{{ eintrag.url }}" class="text-sm text-slate-600 hover:text-emerald-700">{{ eintrag.label }}</a>
{% endfor %}
<div class="ml-auto relative" x-data="{ offen: false }">
{% if user %}
<button type="button" @click="offen = !offen"
class="text-sm font-medium px-2 py-1 rounded hover:bg-slate-100">
{{ user.display_name }} &#9662;
</button>
<div x-show="offen" x-cloak @click.outside="offen = false"
class="absolute right-0 mt-2 w-56 bg-white border border-slate-200 rounded-lg shadow-lg p-3 text-sm z-10">
<p class="text-slate-500 mb-2">Angemeldet als<br><span class="font-medium text-slate-800">{{ user.username }}</span></p>
<p class="text-slate-500 mb-3">Rolle: <span class="font-medium text-slate-800">{{ rollen_anzeige[user.role] or user.role }}</span></p>
{% if user.role == 'admin' %}
<a href="/admin/benutzer" class="block px-2 py-1 rounded hover:bg-slate-100 mb-1">Benutzerverwaltung</a>
{% endif %}
<form method="post" action="/logout">
<button type="submit" class="w-full text-left px-2 py-1 rounded hover:bg-red-50 text-red-600">Abmelden</button>
</form>
</div>
{% else %}
<a href="/login" class="text-sm font-medium text-emerald-700">Anmelden</a>
{% endif %}
</div>
</nav>
</header>
<main class="flex-1 max-w-6xl w-full mx-auto px-4 py-6">
{% block inhalt %}{% endblock %}
</main>
<footer class="text-center text-xs text-slate-400 py-4">
{{ app_name }} &middot; Plugin-Anzahl: {{ geladene_plugins|length }}
</footer>
</body>
</html>

View File

@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block titel %}{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer bearbeiten{% endif %} &mdash; {{ app_name }}{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-4">
{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer &bdquo;{{ ziel.username }}&ldquo; bearbeiten{% endif %}
</h1>
{% if fehler %}
<div class="mb-4 max-w-lg rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2" role="alert">
{{ fehler }}
</div>
{% endif %}
<form method="post"
action="{% if aktion == 'neu' %}/admin/benutzer/neu{% else %}/admin/benutzer/{{ ziel.id }}{% endif %}"
class="bg-white border border-slate-200 rounded-xl p-6 max-w-lg space-y-4">
<div>
<label for="username" class="block text-sm font-medium mb-1">Benutzername</label>
<input id="username" name="username" value="{{ form.username }}" required minlength="3"
{% if aktion != 'neu' %}disabled{% endif %}
class="w-full border border-slate-300 rounded-lg px-3 py-2 {% if aktion != 'neu' %}bg-slate-100{% endif %}">
{% if aktion != 'neu' %}
<p class="text-xs text-slate-400 mt-1">Der Benutzername kann nicht ge&auml;ndert werden.</p>
{% endif %}
</div>
<div>
<label for="display_name" class="block text-sm font-medium mb-1">Anzeigename</label>
<input id="display_name" name="display_name" value="{{ form.display_name }}"
class="w-full border border-slate-300 rounded-lg px-3 py-2">
</div>
<div>
<label for="role" class="block text-sm font-medium mb-1">Rolle</label>
<select id="role" name="role"
{% if ziel and ziel.id == user.id %}disabled title="Eigene Rolle kann nicht ge&auml;ndert werden"{% endif %}
class="w-full border border-slate-300 rounded-lg px-3 py-2 bg-white">
{% for rollen_wert, rollen_text in rollen_anzeige.items() %}
<option value="{{ rollen_wert }}" {% if form.role == rollen_wert %}selected{% endif %}>{{ rollen_text }}</option>
{% endfor %}
</select>
</div>
{% if aktion == 'bearbeiten' %}
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="active" {% if ziel.active %}checked{% endif %}
{% if ziel and ziel.id == user.id %}disabled{% endif %}>
Aktiv
</label>
{% endif %}
<div>
<label for="password" class="block text-sm font-medium mb-1">
{% if aktion == 'neu' %}Passwort{% else %}Neues Passwort (leer = unver&auml;ndert){% endif %}
</label>
<input id="password" name="password" type="password" {% if aktion == 'neu' %}required{% endif %} minlength="8"
class="w-full border border-slate-300 rounded-lg px-3 py-2">
</div>
<div class="flex gap-3 pt-2">
<button type="submit"
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg px-4 py-2">
Speichern
</button>
<a href="/admin/benutzer"
class="border border-slate-300 rounded-lg px-4 py-2 text-sm hover:bg-slate-50">Abbrechen</a>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block titel %}Benutzerverwaltung &mdash; {{ app_name }}{% endblock %}
{% block inhalt %}
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold">Benutzerverwaltung</h1>
<a href="/admin/benutzer/neu"
class="bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg px-3 py-2 text-sm font-medium">
+ Neuer Benutzer
</a>
</div>
<div class="bg-white rounded-xl border border-slate-200 overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-2 font-medium">Benutzername</th>
<th class="px-4 py-2 font-medium">Anzeigename</th>
<th class="px-4 py-2 font-medium">Rolle</th>
<th class="px-4 py-2 font-medium">Status</th>
<th class="px-4 py-2 font-medium">Erstellt am</th>
<th class="px-4 py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for b in benutzer %}
<tr>
<td class="px-4 py-2 font-medium">{{ b.username }}</td>
<td class="px-4 py-2">{{ b.display_name }}</td>
<td class="px-4 py-2">{{ rollen_anzeige[b.role] or b.role }}</td>
<td class="px-4 py-2">
{% if b.active %}
<span class="text-emerald-700">aktiv</span>
{% else %}
<span class="text-red-600">deaktiviert</span>
{% endif %}
</td>
<td class="px-4 py-2 text-slate-500">{{ b.created_at.strftime('%d.%m.%Y') if b.created_at else '&ndash;' }}</td>
<td class="px-4 py-2 text-right">
<a class="text-emerald-700 hover:underline" href="/admin/benutzer/{{ b.id }}">Bearbeiten</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block titel %}&Uuml;bersicht &mdash; {{ app_name }}{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-1">Willkommen, {{ user.display_name }}!</h1>
<p class="text-slate-600 mb-6">
Rolle: <span class="font-medium">{{ rollen_anzeige[user.role] or user.role }}</span>
</p>
<section class="mb-8">
<h2 class="text-lg font-semibold mb-1">Geladene Plugins</h2>
<p class="text-sm text-slate-500 mb-3">
Der Kern ist schlank &mdash; alle Funktionen kommen aus Plugins.
</p>
<div class="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
{% for plugin in geladene_plugins %}
<div class="px-4 py-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span class="font-medium">{{ plugin.title or plugin.name }}</span>
<span class="text-xs text-slate-400">v{{ plugin.version }}</span>
<span class="text-sm text-slate-500">{{ plugin.description }}</span>
</div>
{% endfor %}
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,36 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anmelden &mdash; {{ app_name }}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-100 text-slate-800 min-h-screen flex items-center justify-center px-4">
<div class="bg-white shadow-md rounded-xl p-8 w-full max-w-sm">
<h1 class="text-xl font-bold mb-1">&#127923; {{ app_name }}</h1>
<p class="text-sm text-slate-500 mb-6">Bitte anmelden.</p>
{% if fehler %}
<div class="mb-4 rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2" role="alert">
{{ fehler }}
</div>
{% endif %}
<form method="post" action="/login" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium mb-1">Benutzername</label>
<input id="username" name="username" value="{{ username }}" required autocomplete="username"
class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label for="password" class="block text-sm font-medium mb-1">Passwort</label>
<input id="password" name="password" type="password" required autocomplete="current-password"
class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<button type="submit"
class="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg px-3 py-2">
Anmelden
</button>
</form>
</div>
</body>
</html>