Files
spiele-redaktion/src/redaktionskern/contracts.py
Flo Hartmann 72fe3b7eda Plugin archiv: 12-Monats-Autopilot mit Sicherungsdatei-Prinzip
- Eigene Migration 0001_archiv_tabellen: Spiegel-Tabellen archiv_neuheiten
  und archiv_planung (ohne FK, damit Archiv-Zeilen Benutzerlöschung überleben)
- Service mit eingefrierbarer Uhr: Stichtag „jetzt minus 12 Monate“ in UTC,
  strenger Vergleich (genau 12 Monate bleibt aktiv); Neuheiten nach
  Erscheinungsjahr (großzügig Jahresende) sonst Eintragsdatum, Planung nach
  Eintragsdatum; Monatsarithmetik mit Klemmung (29. Februar)
- Täglicher APScheduler-Cron-Job (Standard 03:00, konfigurierbar über
  SPIELE_ARCHIV_JOB_UHRZEIT / SPIELE_ARCHIV_JOB_AKTIV); je archiviertem
  Titel ein Audit-Log-Eintrag als System
- Admin-Ansicht /archiv: vereinigte Liste beider Tabellen, Suche über
  Titel/Verlag/Autor, Herkunftsfilter, Wiederherstellen in die aktive Liste
  (BGG-Konflikt wird abgelehnt, verwaiste Rezensentenzuordnung übernimmt der
  Admin — gemeldet und auditiert); Rollen: nur Admin
- Kern minimal erweitert: PluginContext.registry stellt Hintergrund-Jobs die
  Plugin-Registry bereit (keine Fachlogik im Kern)
- 30 neue Tests mit eingefrorener Uhr (Grenzfälle, Zeitzonen, Schaltjahr,
  Job-Lauf, Wiederherstellen, Rollen, Suche/Filter); 171 Tests grün
- README aktualisiert: neues Plugin-Kapitel, Env-Variablen, Fortschritt #7 
2026-08-21 20:26:00 +00:00

108 lines
3.2 KiB
Python

"""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
from redaktionskern.plugin_loader import PluginRegistry
@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",
registry: "PluginRegistry | None" = None,
) -> None:
self.engine = engine
self.session_factory = session_factory
self.settings = settings
self.templates = templates
#: Registry aller geladenen Plugins — damit auch Hintergrund-Jobs
#: eines Plugins die öffentliche API anderer Plugins erreichen
#: (in Anfragen geht das üblicherweise über request.app.state.registry).
self.registry = registry
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