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 
This commit is contained in:
Flo Hartmann
2026-08-21 20:26:00 +00:00
parent 0f12cd952b
commit 72fe3b7eda
10 changed files with 1727 additions and 25 deletions

View File

@@ -1,41 +1,278 @@
"""Plugin „archiv“ — Platzhalter gemäß Plugin-Vertrag.
"""Plugin „archiv“ — automatische Archivierung nach dem Sicherungsdatei-
Prinzip.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
Titel der Neuheitenliste und der Planungsliste, deren Erscheinungsdatum
bzw. Eintragsdatum länger als 12 Monate zurückliegt, werden vollständig in
die Archiv-Tabellen (`archiv_neuheiten`, `archiv_planung`) verschoben und
aus den aktiven Listen entfernt. Nichts wird gelöscht oder verändert —
Admins können jeden Eintrag über die Archiv-Ansicht jederzeit wieder in die
aktive Liste zurückverschieben.
Umfang:
- Eigene Migration mit beiden Spiegel-Tabellen.
- Täglicher Hintergrund-Job (APScheduler, Cron um „03:00“ lokal).
- Admin-Ansicht `/archiv` mit Suche, Herkunftsfilter und Wiederherstellen.
- Je ein Audit-Log-Eintrag pro Archivierung (Akteur „System“) und pro
Wiederherstellung (Akteur der Aktion).
Konfiguration über Umgebungsvariablen (gelesen beim Plugin-Start):
- SPIELE_ARCHIV_JOB_AKTIV „1“ (Standard) = täglicher Job an, „0“ = aus
- SPIELE_ARCHIV_JOB_UHRZEIT Tageszeit des Laufs im Format HH:MM (Standard 03:00)
Die Fachlogik liegt im Service (`service.py`) mit eingefrierbarer Uhr;
Partner-Tabellen werden wie in dedup/planung nur über die gemeinsame
SQLAlchemy-Metadata angesprochen. Der Kern bleibt unberührt.
"""
from __future__ import annotations
from fastapi import Depends, Request
import logging
import os
from urllib.parse import quote
from redaktionskern.auth.deps import require_user
from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry
from fastapi import Depends, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_roles
from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry
from .models import STATUS_ANZEIGE, ArchivNeuheit, ArchivPlanung
from .service import (
GUELTIGE_QUELLEN,
QUELLE_NEUHEITEN,
OBJEKT_TYPEN,
ArchivService,
)
_logger = logging.getLogger("plugins.archiv")
JOB_ID = "archiv-taeglich"
STANDARD_UHRZEIT = "03:00"
def _uhrzeit_parsen(roh: str | None) -> tuple[int, int]:
"""Parst „HH:MM“; bei Unsinn gilt der Standard 03:00."""
teile = (roh or "").strip().split(":")
try:
stunde = int(teile[0])
minute = int(teile[1]) if len(teile) > 1 else 0
except (ValueError, IndexError):
return 3, 0
if not (0 <= stunde <= 23 and 0 <= minute <= 59):
return 3, 0
return stunde, minute
def _tabelle_anlegen(conn) -> None:
"""Migration 0001: legt beide Spiegel-Tabellen an (portabel, idempotent)."""
ArchivNeuheit.__table__.create(conn, checkfirst=True)
ArchivPlanung.__table__.create(conn, checkfirst=True)
class ArchivPlugin(BasePlugin):
name = "archiv"
title = "Archiv"
description = "Automatische Archivierung von Titeln älter als 12 Monate (Platzhalter)."
description = (
"Verschiebt Titel älter als 12 Monate automatisch aus den aktiven "
"Listen in das Archiv — vollständig wiederherstellbar."
)
def __init__(self) -> None:
super().__init__()
self._scheduler = None # BackgroundScheduler, falls aktiviert
#: Einfrierbare Uhr für Tests (Callable → aware datetime); None = echte Zeit.
self._jetzt = None
self._routen_registrieren()
# ---------- Plugin-Vertrag ----------
def migrations(self) -> list[Migration]:
return [Migration(version="0001_archiv_tabellen", up=_tabelle_anlegen)]
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/archiv")]
def on_load(self, context) -> None:
super().on_load(context)
if os.environ.get("SPIELE_ARCHIV_JOB_AKTIV", "1").strip() == "1":
self._scheduler_starten(
os.environ.get("SPIELE_ARCHIV_JOB_UHRZEIT", STANDARD_UHRZEIT)
)
def on_unload(self) -> None:
if self._scheduler is not None:
self._scheduler.shutdown(wait=False)
self._scheduler = None
super().on_unload()
# ---------- Internas ----------
def _neuer_service(self) -> ArchivService:
"""Fabrik für den Service; die Uhr ist von Tests überschreibbar."""
return ArchivService(self.context.session_factory, jetzt=self._jetzt)
def _audit_plugin(self):
registry = getattr(self.context, "registry", None)
return registry.get("audit-log") if registry is not None else None
def _auditiere_archivierung(self, ereignisse) -> int:
"""Schreibt je archiviertem Titel einen Audit-Eintrag (Akteur: System)."""
audit = self._audit_plugin()
if audit is None:
return 0
geschrieben = 0
for ereignis in ereignisse:
try:
audit.log_sync(
None,
"verschoben",
ereignis.objekt_typ,
ereignis.objekt_id,
ereignis.details,
)
geschrieben += 1
except Exception: # noqa: BLE001 - Audit darf den Lauf nicht brechen
_logger.exception("Audit-Log-Eintrag für Archivierung fehlgeschlagen.")
return geschrieben
def _archiv_job(self) -> None:
"""Hintergrund-Job: läuft täglich und archiviert fällige Titel."""
service = self._neuer_service()
lauf, ereignisse = service.lauf()
self._auditiere_archivierung(ereignisse)
if lauf.gesamt:
_logger.info("Täglicher Archiv-Lauf abgeschlossen: %s", lauf.als_text())
def _scheduler_starten(self, uhrzeit_roh: str | None) -> None:
from apscheduler.schedulers.background import BackgroundScheduler
stunde, minute = _uhrzeit_parsen(uhrzeit_roh)
scheduler = BackgroundScheduler()
scheduler.add_job(
self._archiv_job,
trigger="cron",
hour=stunde,
minute=minute,
id=JOB_ID,
replace_existing=True,
)
scheduler.start()
self._scheduler = scheduler
_logger.info(
"Automatische Archivierung aktiv: täglich um %02d:%02d Uhr.",
stunde,
minute,
)
# ---------- Routen ----------
def _routen_registrieren(self) -> None:
@self.router.get("/archiv")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
def ansicht(
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_roles(Role.ADMIN.value)),
q: str = "",
quelle: str = "",
meldung: str = "",
fehler: str = "",
):
"""Archiv-Ansicht (nur Admin): Suche, Filter, Wiederherstellen."""
service = self._neuer_service()
eintraege = service.ansicht(db)
alle_neuheiten = sum(1 for e in eintraege if e["quelle"] == QUELLE_NEUHEITEN)
alle_planung = len(eintraege) - alle_neuheiten
such = q.strip().lower()
if such:
eintraege = [
e
for e in eintraege
if such in (e["titel"] or "").lower()
or such in (e["verlag"] or "").lower()
or such in (e["autor"] or "").lower()
]
if quelle in GUELTIGE_QUELLEN:
eintraege = [e for e in eintraege if e["quelle"] == quelle]
namen = {
benutzer.id: (benutzer.display_name or benutzer.username)
for benutzer in db.scalars(select(User)).all()
}
return self.context.templates.TemplateResponse(
request=request,
name="archiv/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
"eintraege": eintraege,
"namen": namen,
"status_anzeige": STATUS_ANZEIGE,
"q": q,
"quelle_filter": quelle if quelle in GUELTIGE_QUELLEN else "",
"anzahl": len(eintraege),
"anzahl_neuheiten": alle_neuheiten,
"anzahl_planung": alle_planung,
"meldung": meldung[:300],
"fehler": fehler[:300],
"job_aktiv": self._scheduler is not None and self._scheduler.running,
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/archiv")]
@self.router.post("/archiv/{quelle}/{archiv_id}/wiederherstellen")
async def wiederherstellen(
request: Request,
quelle: str,
archiv_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_roles(Role.ADMIN.value)),
):
"""Stellt einen archivierten Eintrag in die aktive Liste zurück."""
ziel_url = "/archiv"
if quelle not in GUELTIGE_QUELLEN:
return RedirectResponse(
f"{ziel_url}?fehler={quote('Unbekannte Herkunft.')}",
status_code=303,
)
service = self._neuer_service()
ergebnis = service.wiederherstellen(
db, quelle, archiv_id, ersatz_rezensent_id=user.id
)
if not ergebnis.ok:
return RedirectResponse(
f"{ziel_url}?fehler={quote(ergebnis.meldung)}", status_code=303
)
details = {
"titel": ergebnis.titel,
"von": "archiv",
"ziel": ergebnis.ziel,
"archiv_id": archiv_id,
}
if ergebnis.rezensent_ersetzt:
details["rezensent_ersetzt"] = True
audit = request.app.state.registry.get("audit-log")
if audit is not None:
try:
await audit.log(
user,
"verschoben",
OBJEKT_TYPEN[quelle],
ergebnis.neuer_id,
details,
)
except Exception: # noqa: BLE001 - Audit blockiert nicht
_logger.exception(
"Audit-Log-Eintrag für Wiederherstellung fehlgeschlagen."
)
return RedirectResponse(
f"{ziel_url}?meldung={quote(ergebnis.meldung)}", status_code=303
)
plugin = ArchivPlugin()

108
plugins/archiv/models.py Normal file
View File

@@ -0,0 +1,108 @@
"""Datenmodelle des Plugins „archiv“.
Das Archiv folgt dem Sicherungsdatei-Prinzip: Beim Archivieren wird jede
Zeile vollständig in eine eigene Archiv-Tabelle kopiert und aus der aktiven
Liste entfernt — nichts wird verändert oder gelöscht, ein Eintrag lässt sich
jederzeit unverändert zurück in die aktive Liste verschieben.
Es gibt zwei Spiegel-Tabellen (eine pro aktiver Liste):
- `archiv_neuheiten` ← Spiegel von `neuheiten`
- `archiv_planung` ← Spiegel von `planungsliste`
Bewusst ohne Fremdschlüssel: Archivierte Zeilen müssen auch dann erhalten
bleiben, wenn z. B. der zugeordnete Rezensent gelöscht wird. Bei der
Wiederherstellung wird die Gültigkeit geprüft und notfalls ein Ersatz-
Rezensent zugeordnet.
Bewusst keine zusätzlichen Indizes: Bei doppelt geladenem Modul (direkter
Import im Test + Plugin-Loader-Namensraum) würden sie doppelt angelegt; die
Nutzerzahl ist klein.
Die Archiv-Metadaten (wer/wann/warum) stehen in jeder Zeile: `quell_id`
(ursprüngliche ID in der aktiven Liste), `archiviert_am`, `archiviert_von`,
`grund` und `referenz_am` (Zeitpunkt, an dem die 12-Monats-Regel angesetzt
hat — Erscheinungstermin bzw. Eintragsdatum).
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, JSON, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from redaktionskern.db import Base
class _ArchivMeta:
"""Gemeinsame Archiv-Metadaten beider Spiegel-Tabellen (Mixin)."""
#: ID der Zeile in der ursprünglichen aktiven Liste.
quell_id: Mapped[int] = mapped_column(Integer)
#: Zeitpunkt der Archivierung (naive UTC, wie überall in der App).
archiviert_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
#: „System“ beim automatischen Lauf, sonst Benutzername der Wiederherstellung… # noqa
archiviert_von: Mapped[str] = mapped_column(String(100), default="System")
#: Menschlich lesbarer Grund, z. B. „älter als 12 Monate (Erscheinungsjahr 2023)“.
grund: Mapped[str] = mapped_column(String(200), default="")
#: Datum, an dem die 12-Monats-Frist angesetzt hat (Erscheinung bzw. Eintrag).
referenz_am: Mapped[datetime | None] = mapped_column(DateTime)
class ArchivNeuheit(_ArchivMeta, Base):
"""Archivierter Neuheiten-Eintrag (Spiegel der Tabelle `neuheiten`)."""
__tablename__ = "archiv_neuheiten"
# extend_existing: Das Plugin kann doppelt geladen werden (direkter Import
# im Test + Plugin-Loader-Namensraum) und teilt sich dieselbe Metadata.
__table_args__ = {"extend_existing": True}
id: Mapped[int] = mapped_column(primary_key=True)
titel: Mapped[str] = mapped_column(String(300))
verlag: Mapped[str | None] = mapped_column(String(300))
autor: Mapped[str | None] = mapped_column(String(300))
erscheinungsjahr: Mapped[int | None] = mapped_column(Integer)
bgg_id: Mapped[int | None] = mapped_column(Integer)
status: Mapped[str] = mapped_column(String(50), default="neuheit")
quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek")
erstellt_am: Mapped[datetime | None] = mapped_column(DateTime)
aktualisiert_am: Mapped[datetime | None] = mapped_column(DateTime)
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
return f"<ArchivNeuheit {self.titel!r}>"
class ArchivPlanung(_ArchivMeta, Base):
"""Archivierter Planungseintrag (Spiegel der Tabelle `planungsliste`)."""
__tablename__ = "archiv_planung"
__table_args__ = {"extend_existing": True}
id: Mapped[int] = mapped_column(primary_key=True)
titel: Mapped[str] = mapped_column(String(300))
verlag: Mapped[str | None] = mapped_column(String(300))
autor: Mapped[str | None] = mapped_column(String(300))
bgg_id: Mapped[int | None] = mapped_column(Integer)
ausgabe: Mapped[str] = mapped_column(String(100), default="")
#: Kein FK — Archiv-Zeilen überleben das Löschen des Benutzers.
rezensent_id: Mapped[int | None] = mapped_column(Integer)
status: Mapped[str] = mapped_column(String(50), default="offen")
notizen: Mapped[str | None] = mapped_column(Text)
quelle: Mapped[str] = mapped_column(String(50), default="manuell")
pruefung: Mapped[list | None] = mapped_column(JSON)
erstellt_am: Mapped[datetime | None] = mapped_column(DateTime)
aktualisiert_am: Mapped[datetime | None] = mapped_column(DateTime)
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
return f"<ArchivPlanung {self.titel!r}>"
#: Deutsche Anzeigenamen der Statuswerte (bewusst als Kopie ohne
#: Import-Abhängigkeit zu den Partner-Plugins, siehe planung/KONFLIKT_ANZEIGE).
STATUS_ANZEIGE: dict[str, str] = {
"neuheit": "Neuheit",
"planung": "In Planung",
"offen": "Offen",
"in_bearbeitung": "In Bearbeitung",
"abgeschlossen": "Abgeschlossen",
}

494
plugins/archiv/service.py Normal file
View File

@@ -0,0 +1,494 @@
"""Archivierungs-Logik des Plugins „archiv“.
Regeln (dokumentiert, von Tests mit eingefrorener Uhr abgedeckt):
- **Stichtag:** „jetzt minus 12 Monate“ in UTC. Archiviert wird, wer
*streng älter* als der Stichtag ist — genau 12 Monate gilt noch nicht als
„älter als 12 Monate“.
- **Referenzdatum:**
- Neuheiten: das Erscheinungsdatum. BoardGameGeek liefert nur das Jahr,
daher großzügig das Ende des Erscheinungsjahres (31.12., 23:59:59) — ein
Titel des Jahres J wird also erst nach dem 31.12. von J+1 archiviert.
Fehlt das Jahr, zählt das Eintragsdatum (`erstellt_am`).
- Planungseinträge: das Eintragsdatum (`erstellt_am`), unabhängig vom Status.
- **Zeitzonen:** Verglichen wird durchgängig in UTC. Zeitstempel ohne
Zeitzone aus der Datenbank gelten als UTC (SQLite CURRENT_TIMESTAMP ist
UTC; für Postgres werden naive Werte equally behandelt). Bewohnte
Zeitzonen werden vor dem Vergleich nach UTC verschoben.
- **Sicherungsdatei-Prinzip:** Zeilen werden vollständig in die Spiegel-
Tabellen kopiert und aus den aktiven Listen entfernt — nichts geht verloren.
"""
from __future__ import annotations
import calendar
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from sqlalchemy import delete, insert, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session, sessionmaker
from redaktionskern.db import Base
from .models import ArchivNeuheit, ArchivPlanung
_logger = logging.getLogger("plugins.archiv")
QUELLE_NEUHEITEN = "neuheiten"
QUELLE_PLANUNG = "planung"
GUELTIGE_QUELLEN: tuple[str, ...] = (QUELLE_NEUHEITEN, QUELLE_PLANUNG)
#: Objekttypen für Audit-Log-Einträge (deckungsgleich mit den Partner-Plugins).
OBJEKT_TYPEN: dict[str, str] = {
QUELLE_NEUHEITEN: "neuheit",
QUELLE_PLANUNG: "planungseintrag",
}
ARCHIV_ZIEL = "archiv"
GRUND_TEXT = "älter als 12 Monate"
STANDARD_MONATE = 12
def vor_monaten(moment: datetime, monate: int) -> datetime:
"""Zieht `monate` Kalendermonate ab; Monatstage werden geklemmt
(z. B. 29. Februar 12 Monate → 28. Februar des Vorjahres)."""
monat = moment.month - monate
jahr = moment.year
while monat <= 0:
monat += 12
jahr -= 1
tag = min(moment.day, calendar.monthrange(jahr, monat)[1])
return moment.replace(year=jahr, month=monat, day=tag)
def als_utc(moment: datetime | None) -> datetime | None:
"""Naive Zeitstempel gelten als UTC; zeitzonenbewusste → UTC."""
if moment is None:
return None
if moment.tzinfo is None:
return moment.replace(tzinfo=timezone.utc)
return moment.astimezone(timezone.utc)
def naiv_utc(moment: datetime | None) -> datetime | None:
"""Nach UTC normalisiert und ohne Zeitzone (Speicherformat der App)."""
utc = als_utc(moment)
return None if utc is None else utc.replace(tzinfo=None)
@dataclass
class ArchivLauf:
"""Zusammenfassung eines automatischen Archivierungs-Laufs."""
neuheiten_archiviert: int = 0
planung_archiviert: int = 0
@property
def gesamt(self) -> int:
return self.neuheiten_archiviert + self.planung_archiviert
def als_text(self) -> str:
return (
f"{self.neuheiten_archiviert} Neuheiten und "
f"{self.planung_archiviert} Planungseinträge archiviert"
)
@dataclass
class ArchivEreignis:
"""Ein archivierter Datensatz — Grundlage für je einen Audit-Log-Eintrag."""
objekt_typ: str
objekt_id: int
details: dict = field(default_factory=dict)
@dataclass
class Wiederherstellung:
"""Ergebnis eines Wiederherstellen-Versuchs."""
ok: bool
meldung: str
ziel: str = ""
neuer_id: int | None = None
rezensent_ersetzt: bool = False
titel: str = ""
class ArchivService:
"""Verschiebt veraltete Titel in die Archiv-Tabellen und zurück."""
def __init__(
self,
session_factory: sessionmaker,
jetzt: Callable[[], datetime] | None = None,
monate: int = STANDARD_MONATE,
) -> None:
self.session_factory = session_factory
# Einfrierbare Uhr (Tests); Standard: aktuelle UTC-Zeit.
self._jetzt_fabrik = jetzt or (lambda: datetime.now(timezone.utc))
self.monate = max(int(monate), 0)
# ---------- Zeitlogik ----------
def jetzt(self) -> datetime:
return als_utc(self._jetzt_fabrik()) # type: ignore[arg-type]
def stichtag(self) -> datetime:
"""„jetzt 12 Monate“; streng älter heißt archiviert."""
return vor_monaten(self.jetzt(), self.monate)
def referenz_neuheit(self, zeile) -> datetime:
"""Erscheinungsdatum (Ende des bekannten Jahres) oder Eintragsdatum."""
jahr = zeile["erscheinungsjahr"]
if jahr is not None:
return datetime(int(jahr), 12, 31, 23, 59, 59, tzinfo=timezone.utc)
erstellt = als_utc(zeile.get("erstellt_am"))
return erstellt or self.jetzt()
def referenz_planung(self, zeile) -> datetime:
"""Eintragsdatum des Planungseintrags."""
erstellt = als_utc(zeile.get("erstellt_am"))
return erstellt or self.jetzt()
def _grund_neuheit(self, zeile, referenz: datetime) -> str:
if zeile["erscheinungsjahr"] is not None:
return f"{GRUND_TEXT} (Erscheinungsjahr {zeile['erscheinungsjahr']})"
return f"{GRUND_TEXT} (eingetragen am {referenz:%d.%m.%Y})"
def _grund_planung(self, zeile, referenz: datetime) -> str:
return f"{GRUND_TEXT} (eingetragen am {referenz:%d.%m.%Y})"
# ---------- Automatischer Lauf ----------
def lauf(self) -> tuple[ArchivLauf, list[ArchivEreignis]]:
"""Verschiebt alle fälligen Titel in die Archiv-Tabellen.
Die Audit-Log-Einträge schreibt der Aufrufer (Route oder Hintergrund-
Job) über die öffentliche API des audit-log-Plugins — ein Ereignis
pro archiviertem Titel.
"""
stichtag = self.stichtag()
zeitpunkt = naiv_utc(self.jetzt())
lauf = ArchivLauf()
ereignisse: list[ArchivEreignis] = []
with self.session_factory() as db:
db: Session
neuheiten = Base.metadata.tables.get("neuheiten")
if neuheiten is not None:
try:
zeilen = db.execute(select(neuheiten)).mappings().all()
except SQLAlchemyError as exc:
_logger.warning(
"archiv: Tabelle 'neuheiten' nicht lesbar (%s) — "
"Quelle übersprungen.",
exc,
)
zeilen = []
for zeile in zeilen:
referenz = self.referenz_neuheit(zeile)
if referenz >= stichtag:
continue # noch keine 12 Monate alt (oder jünger)
db.execute(
insert(ArchivNeuheit).values(
quell_id=zeile["id"],
titel=zeile["titel"],
verlag=zeile["verlag"],
autor=zeile["autor"],
erscheinungsjahr=zeile["erscheinungsjahr"],
bgg_id=zeile["bgg_id"],
status=zeile["status"],
quelle=zeile["quelle"],
erstellt_am=zeile["erstellt_am"],
aktualisiert_am=zeile["aktualisiert_am"],
archiviert_am=zeitpunkt,
referenz_am=naiv_utc(referenz),
grund=self._grund_neuheit(zeile, referenz),
)
)
db.execute(
delete(neuheiten).where(neuheiten.c.id == zeile["id"])
)
lauf.neuheiten_archiviert += 1
ereignisse.append(
ArchivEreignis(
objekt_typ=OBJEKT_TYPEN[QUELLE_NEUHEITEN],
objekt_id=zeile["id"],
details={
"titel": zeile["titel"],
"herkunft": QUELLE_NEUHEITEN,
"ziel": ARCHIV_ZIEL,
"grund": self._grund_neuheit(zeile, referenz),
"referenz": f"{naiv_utc(referenz):%Y-%m-%d}",
},
)
)
planungsliste = Base.metadata.tables.get("planungsliste")
if planungsliste is not None:
try:
zeilen = db.execute(select(planungsliste)).mappings().all()
except SQLAlchemyError as exc:
_logger.warning(
"archiv: Tabelle 'planungsliste' nicht lesbar (%s) — "
"Quelle übersprungen.",
exc,
)
zeilen = []
for zeile in zeilen:
referenz = self.referenz_planung(zeile)
if referenz >= stichtag:
continue
db.execute(
insert(ArchivPlanung).values(
quell_id=zeile["id"],
titel=zeile["titel"],
verlag=zeile["verlag"],
autor=zeile["autor"],
bgg_id=zeile["bgg_id"],
ausgabe=zeile["ausgabe"],
rezensent_id=zeile["rezensent_id"],
status=zeile["status"],
notizen=zeile["notizen"],
quelle=zeile["quelle"],
pruefung=zeile["pruefung"],
erstellt_am=zeile["erstellt_am"],
aktualisiert_am=zeile["aktualisiert_am"],
archiviert_am=zeitpunkt,
referenz_am=naiv_utc(referenz),
grund=self._grund_planung(zeile, referenz),
)
)
db.execute(
delete(planungsliste).where(planungsliste.c.id == zeile["id"])
)
lauf.planung_archiviert += 1
ereignisse.append(
ArchivEreignis(
objekt_typ=OBJEKT_TYPEN[QUELLE_PLANUNG],
objekt_id=zeile["id"],
details={
"titel": zeile["titel"],
"herkunft": QUELLE_PLANUNG,
"ziel": ARCHIV_ZIEL,
"grund": self._grund_planung(zeile, referenz),
"referenz": f"{naiv_utc(referenz):%Y-%m-%d}",
},
)
)
db.commit()
if lauf.gesamt:
_logger.info("Automatische Archivierung: %s", lauf.als_text())
return lauf, ereignisse
# ---------- Ansicht ----------
def ansicht(self, db: Session) -> list[dict]:
"""Vereinigte Archiv-Ansicht beider Tabellen, neueste zuerst."""
eintraege: list[dict] = []
for a in db.scalars(
select(ArchivNeuheit).order_by(
ArchivNeuheit.archiviert_am.desc(), ArchivNeuheit.id.desc()
)
):
eintraege.append(
{
"quelle": QUELLE_NEUHEITEN,
"id": a.id,
"titel": a.titel,
"verlag": a.verlag,
"autor": a.autor,
"bgg_id": a.bgg_id,
"jahr": a.erscheinungsjahr,
"ausgabe": "",
"status": a.status,
"rezensent_id": None,
"erstellt_am": a.erstellt_am,
"archiviert_am": a.archiviert_am,
"referenz_am": a.referenz_am,
"grund": a.grund,
}
)
for a in db.scalars(
select(ArchivPlanung).order_by(
ArchivPlanung.archiviert_am.desc(), ArchivPlanung.id.desc()
)
):
eintraege.append(
{
"quelle": QUELLE_PLANUNG,
"id": a.id,
"titel": a.titel,
"verlag": a.verlag,
"autor": a.autor,
"bgg_id": a.bgg_id,
"jahr": None,
"ausgabe": a.ausgabe,
"status": a.status,
"rezensent_id": a.rezensent_id,
"erstellt_am": a.erstellt_am,
"archiviert_am": a.archiviert_am,
"referenz_am": a.referenz_am,
"grund": a.grund,
}
)
# Neueste Archivierung zuerst; bei Gleichstand die höhere ID zuerst.
return sorted(
eintraege,
key=lambda e: (e["archiviert_am"] or datetime.min, e["id"]),
reverse=True,
)
# ---------- Wiederherstellen ----------
def wiederherstellen(
self,
db: Session,
quelle: str,
archiv_id: int,
*,
ersatz_rezensent_id: int | None = None,
jetzt: datetime | None = None,
) -> Wiederherstellung:
"""Schiebt einen archivierten Eintrag zurück in die aktive Liste.
Konflikte werden abgelehnt (keine Datenänderung im Archiv): existiert
in der Neuheitenliste bereits ein aktiver Eintrag mit derselben BGG-ID,
wird nicht wiederhergestellt. Existiert der zugeordnete Rezensent
eines Planungseintrags nicht mehr, übernimmt `ersatz_rezensent_id`
die Zuordnung (wird gemeldet und auditiert).
"""
zeitpunkt = naiv_utc(jetzt or self.jetzt())
if quelle == QUELLE_NEUHEITEN:
return self._wiederherstelle_neuheit(db, archiv_id, zeitpunkt)
if quelle == QUELLE_PLANUNG:
return self._wiederherstelle_planung(
db, archiv_id, zeitpunkt, ersatz_rezensent_id
)
return Wiederherstellung(False, f"Unbekannte Herkunft „{quelle}“.")
def _wiederherstelle_neuheit(
self, db: Session, archiv_id: int, zeitpunkt: datetime
) -> Wiederherstellung:
archiv = db.get(ArchivNeuheit, archiv_id)
if archiv is None:
return Wiederherstellung(
False, "Der archivierte Eintrag wurde nicht gefunden."
)
aktiv = Base.metadata.tables.get("neuheiten")
if aktiv is None:
return Wiederherstellung(
False,
"Die Neuheitenliste ist nicht verfügbar — "
"Wiederherstellen nicht möglich.",
)
if archiv.bgg_id is not None:
vorhanden = db.execute(
select(aktiv.c.id, aktiv.c.titel).where(
aktiv.c.bgg_id == archiv.bgg_id
)
).first()
if vorhanden is not None:
return Wiederherstellung(
False,
f"Es gibt bereits einen aktiven Neuheiten-Eintrag mit der "
f"BGG-ID {archiv.bgg_id} („{vorhanden.titel}“) — "
"Wiederherstellen abgebrochen.",
)
ergebnis = db.execute(
insert(aktiv).values(
titel=archiv.titel,
verlag=archiv.verlag,
autor=archiv.autor,
erscheinungsjahr=archiv.erscheinungsjahr,
bgg_id=archiv.bgg_id,
status=archiv.status,
quelle=archiv.quelle,
erstellt_am=archiv.erstellt_am,
aktualisiert_am=zeitpunkt,
)
)
neuer_id = ergebnis.inserted_primary_key[0]
titel = archiv.titel
db.delete(archiv)
db.commit()
return Wiederherstellung(
True,
f"{titel}“ wurde zurück in die Neuheitenliste verschoben.",
ziel=QUELLE_NEUHEITEN,
neuer_id=int(neuer_id),
titel=titel,
)
def _wiederherstelle_planung(
self,
db: Session,
archiv_id: int,
zeitpunkt: datetime,
ersatz_rezensent_id: int | None,
) -> Wiederherstellung:
archiv = db.get(ArchivPlanung, archiv_id)
if archiv is None:
return Wiederherstellung(
False, "Der archivierte Eintrag wurde nicht gefunden."
)
aktiv = Base.metadata.tables.get("planungsliste")
if aktiv is None:
return Wiederherstellung(
False,
"Die Planungsliste ist nicht verfügbar — "
"Wiederherstellen nicht möglich.",
)
rezensent_id = archiv.rezensent_id
rezensent_ersetzt = False
benutzer = Base.metadata.tables.get("users")
if rezensent_id is not None and benutzer is not None:
existiert = db.execute(
select(benutzer.c.id).where(benutzer.c.id == rezensent_id)
).first()
if existiert is None and ersatz_rezensent_id is not None:
rezensent_id = ersatz_rezensent_id
rezensent_ersetzt = True
ergebnis = db.execute(
insert(aktiv).values(
titel=archiv.titel,
verlag=archiv.verlag,
autor=archiv.autor,
bgg_id=archiv.bgg_id,
ausgabe=archiv.ausgabe,
rezensent_id=rezensent_id,
status=archiv.status,
notizen=archiv.notizen,
quelle=archiv.quelle,
pruefung=archiv.pruefung,
erstellt_am=archiv.erstellt_am,
aktualisiert_am=zeitpunkt,
)
)
neuer_id = ergebnis.inserted_primary_key[0]
titel = archiv.titel
db.delete(archiv)
db.commit()
meldung = f"{titel}“ wurde zurück in die Planungsliste verschoben."
if rezensent_ersetzt:
meldung += (
" Der ursprüngliche Rezensent existierte nicht mehr — "
"der Eintrag wurde dir zugeordnet."
)
return Wiederherstellung(
True,
meldung,
ziel=QUELLE_PLANUNG,
neuer_id=int(neuer_id),
rezensent_ersetzt=rezensent_ersetzt,
titel=titel,
)

View File

@@ -2,9 +2,120 @@
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-2xl">
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
in Version {{ version }} ist geladen.
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
<p class="text-slate-600 max-w-3xl mb-4">
Sicherungsdatei der Redaktion: Titel, deren Erscheinungs- bzw. Eintragsdatum
mehr als 12 Monate zur&uuml;ckliegt, werden t&auml;glich automatisch aus den
aktiven Listen hierher verschoben. Es geht nichts verloren &mdash; jeder
Eintrag l&auml;sst sich unver&auml;ndert zur&uuml;ck in die aktive Liste verschieben.
</p>
<div class="flex items-center gap-4 mb-4 text-xs text-slate-500">
<span class="inline-flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full {{ 'bg-emerald-500' if job_aktiv else 'bg-slate-300' }}"></span>
T&auml;glicher Archiv-Job: {{ 'aktiv' if job_aktiv else 'aus' }}
</span>
<span>{{ anzahl_neuheiten }} archivierte Neuheiten</span>
<span>{{ anzahl_planung }} archivierte Planungseintr&auml;ge</span>
</div>
{% if meldung %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div>
{% endif %}
{% if fehler %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div>
{% endif %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<form method="get" action="/archiv" class="flex flex-wrap items-end gap-3">
<div>
<label for="q" class="block text-xs font-medium text-slate-500 mb-1">Suche (Titel, Verlag, Autor)</label>
<input type="text" id="q" name="q" value="{{ q }}"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-72 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label for="quelle" class="block text-xs font-medium text-slate-500 mb-1">Herkunft</label>
<select id="quelle" name="quelle"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
onchange="this.form.submit()">
<option value="">Alle Listen</option>
<option value="neuheiten" {% if quelle_filter == 'neuheiten' %}selected{% endif %}>Neuheiten</option>
<option value="planung" {% if quelle_filter == 'planung' %}selected{% endif %}>Planung</option>
</select>
</div>
<button type="submit"
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
Suchen
</button>
{% if q or quelle_filter %}
<a href="/archiv" class="text-sm text-slate-500 hover:text-emerald-700 px-2 py-2">Zur&uuml;cksetzen</a>
{% endif %}
</form>
</div>
<p class="text-xs text-slate-400 mb-3">{{ anzahl }} Eintr&auml;ge im Archiv</p>
<div class="overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Spieltitel</th>
<th class="px-4 py-3 font-medium">Herkunft</th>
<th class="px-4 py-3 font-medium">Verlag</th>
<th class="px-4 py-3 font-medium">Jahr / Ausgabe</th>
<th class="px-4 py-3 font-medium">Status</th>
<th class="px-4 py-3 font-medium">Rezensent</th>
<th class="px-4 py-3 font-medium">Archiviert am</th>
<th class="px-4 py-3 font-medium">Grund</th>
<th class="px-4 py-3 font-medium">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for eintrag in eintraege %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium">
{{ eintrag.titel }}
{% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if eintrag.quelle == 'neuheiten' %}
<span class="inline-block px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 text-xs">Neuheiten</span>
{% else %}
<span class="inline-block px-2 py-0.5 rounded-full bg-violet-100 text-violet-800 text-xs">Planung</span>
{% endif %}
</td>
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ eintrag.jahr if eintrag.jahr else (eintrag.ausgabe or '—') }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ status_anzeige.get(eintrag.status, eintrag.status) }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ namen.get(eintrag.rezensent_id, '—') if eintrag.rezensent_id else '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap text-slate-500">
{{ eintrag.archiviert_am.strftime('%d.%m.%Y %H:%M') if eintrag.archiviert_am else '—' }}
</td>
<td class="px-4 py-3 text-slate-500">{{ eintrag.grund }}</td>
<td class="px-4 py-3">
<form method="post" action="/archiv/{{ eintrag.quelle }}/{{ eintrag.id }}/wiederherstellen">
<button type="submit"
class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2.5 py-1.5 rounded-lg whitespace-nowrap"
title="Eintrag unverändert zurück in die aktive Liste verschieben">
&#8635; Wiederherstellen
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="9" class="px-4 py-8 text-center text-slate-400">
{% if q or quelle_filter %}
Keine Treffer f&uuml;r diese Suche.
{% else %}
Das Archiv ist leer — nichts zu sehen.
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}