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