Files
spiele-redaktion/plugins/archiv/__init__.py
Flo Hartmann 96d6af8163 Coverbilder (bild_url) + responsives Layout für Mobil/Tablet
Teil 1 — Coverbilder:
- Migration 0004_bild_url (neuheiten), 0002_bild_url (planung, archiv
  mit beiden Spiegel-Tabellen); nullable VARCHAR(500), SQLite↔Postgres
  portabel, bestehende Zeilen bleiben erhalten
- BGG-Client extrahiert Cover aus der Thing-Antwort (bevorzugt <image>,
  sonst <thumbnail>; protokoll-relative URLs → https)
- Sync-Upsert pflegt bild_url beim Update; fehlendes Bild löscht keines
- Web-Quellen übernehmen img-URLs aus den Listenelementen (src/data-src,
  relative Pfade gegen die Seiten-URL aufgelöst); SPIEL-Essen nutzt das
  BILDER-Feld des JSON-Endpunkts
- Verschiebung Neuheiten→Planung und Archivierung/Wiederherstellen
  schreiben bild_url mit
- Anzeige: kleine Vorschaubilder (40x56) in den Listen, groß (max 200px)
  in Planung-Bearbeiten/Prüf-Dialog; Platzhalter-Div ohne Bild;
  Hotlinking mit loading=lazy + decoding=async

Teil 2 — Responsives Layout:
- Basislayout: Burger-Menü (Alpine.js) auf Mobil, Desktop-Leiste ab md:
- Listen kollabieren <768px zu Karten-Ansichten (Cover links, Titel +
  Metadaten rechts, Status-Badge oben); Desktop behält Tabellenform
- Touch-Ziele >= 44px auf Mobil, Formulare vollbreit mit Labels oben
- Sync-Übersicht/Audit-Log bleiben horizontal scrollbare Tabellen

Tests: 15 neue Fälle (tests/test_covers.py) — Migration inkl.
Daten-Erhalt, BGG-Parsing gemockt, Quellen-Übernahme, Template-Render;
316 Tests grün. README aktualisiert.
2026-08-24 16:54:56 +00:00

309 lines
11 KiB
Python

"""Plugin „archiv“ — automatische Archivierung nach dem Sicherungsdatei-
Prinzip.
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
import logging
import os
from urllib.parse import quote
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)
def _spalte_existiert(conn, tabelle: str, spalte: str) -> bool:
"""Dialekt-agnostische Spaltenprüfung (PRAGMA bzw. information_schema)."""
if conn.dialect.name == "sqlite":
info = conn.exec_driver_sql(f"PRAGMA table_info({tabelle})").fetchall()
return any(zeile[1] == spalte for zeile in info)
existiert = conn.exec_driver_sql(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = :tabelle AND column_name = :spalte",
{"tabelle": tabelle, "spalte": spalte},
).scalar()
return existiert is not None
def _bild_url_spalten(conn) -> None:
"""Migration 0002: Spalte bild_url in beiden Spiegel-Tabellen.
`ALTER TABLE … ADD COLUMN` mit nullable VARCHAR ohne Default ist
SQLite- und Postgres-portabel und erhält bestehende Zeilen.
"""
for tabelle in ("archiv_neuheiten", "archiv_planung"):
if _spalte_existiert(conn, tabelle, "bild_url"):
continue
conn.exec_driver_sql(
f"ALTER TABLE {tabelle} ADD COLUMN bild_url VARCHAR(500)"
)
class ArchivPlugin(BasePlugin):
name = "archiv"
title = "Archiv"
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),
Migration(version="0002_bild_url", up=_bild_url_spalten),
]
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 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,
"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,
},
)
@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()