- 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 ✅
108 lines
3.2 KiB
Python
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
|