Plugin export: Listen als CSV (Excel-DE) und PDF (WeasyPrint)

- CSV über stdlib csv: Semikolon-getrennt, UTF-8 mit BOM, korrektes
  Quoting; PDF über WeasyPrint mit deutscher Kopfzeile (je Seite
  wiederholt), Erstell-Datum und Seitenzahlen (Seite X von Y)
- Drei Listen: Neuheiten, Planung, Archiv (Neuheiten gefiltert auf
  Status „archiviert“); Partner-Tabellen über gemeinsame Metadata,
  ohne Plugin-Importe — fehlende Plugins degradieren sauber
- Rollen: Admin/Redakteur alles, Rezensent nur Planungsliste;
  serverseitige Prüfung an den Download-Endpunkten (StreamingResponse),
  UI-Karten für nicht erlaubte Listen ausgegraut
- Jeder Download best effort im Audit-Log (Aktion „exportiert“)
- WeasyPrint lazy importiert: ohne System-Bibliotheken bleiben App und
  CSV nutzbar; Tests skippen PDF-Fälle nach Installationsversuch
- 20 neue Tests (CSV-Inhalt/BOM/Umlaute/Quoting, PDF-Gültigkeit,
  Rollen, Audit, Degradierung); 161 gesamt grün
- README: Plugin-Dokumentation, Fortschritt export auf 
This commit is contained in:
Flo Hartmann
2026-08-21 20:30:19 +00:00
parent 0f12cd952b
commit d024f42f3c
9 changed files with 1248 additions and 25 deletions

View File

@@ -1,41 +1,212 @@
"""Plugin „export“ — Platzhalter gemäß Plugin-Vertrag.
"""Plugin „export“ — Listen als CSV und PDF herunterladen.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
Exportiert die drei Redaktionslisten **Neuheiten**, **Planung** und
**Archiv**:
- **CSV** (stdlib ``csv``): Semikolon-getrennt für Excel mit deutschem
Gebietsschema, UTF-8 mit Byte-Order-Mark.
- **PDF** (WeasyPrint): formatierte Tabellen mit deutscher Kopfzeile,
Erstell-Datum und Seitenzahlen; die Kopfzeile wiederholt sich auf jeder
Seite. WeasyPrint wird erst beim PDF-Download importiert — fehlen die
System-Bibliotheken, bleiben App und CSV-Export voll nutzbar.
Rollen: Admin/Redakteur dürfen alles exportieren, Rezensenten nur die
Planungsliste. Die Prüfung passiert serverseitig am Download-Endpunkt;
auf der Export-Seite werden nicht erlaubte Formate ausgegraut angezeigt.
Jeder Download wird best effort ins Audit-Log geschrieben (Aktion
„exportiert“). Eigene Routen/Templates, keine Logik im Kern — die
Datenzugriffe laufen über die gemeinsame SQLAlchemy-Metadata, ohne
Partner-Plugins zu importieren (siehe exporte.py).
"""
from __future__ import annotations
from fastapi import Depends, Request
import logging
from datetime import datetime
from redaktionskern.auth.deps import require_user
from redaktionskern.auth.models import User
from fastapi import Depends, HTTPException, Request
from fastapi.responses import RedirectResponse, StreamingResponse
from sqlalchemy.orm import Session
from redaktionskern.auth.deps import AccessDenied, get_db, require_user
from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, NavEntry
from .exporte import LISTEN, csv_erzeugen, dateiname, pdf_erzeugen
_logger = logging.getLogger("plugins.export")
REDAKTION = (Role.ADMIN.value, Role.REDAKTEUR.value)
#: Medien-Typen der Downloads.
MEDIA_CSV = "text/csv; charset=utf-8"
MEDIA_PDF = "application/pdf"
def _als_stream(daten: bytes, blockgroesse: int = 64 * 1024):
"""Zerlegt ein fertiges Dokument in Blöcke für die StreamingResponse."""
for start in range(0, len(daten), blockgroesse):
yield daten[start : start + blockgroesse]
def _download(daten: bytes, medientyp: str, name: str) -> StreamingResponse:
return StreamingResponse(
_als_stream(daten),
media_type=medientyp,
headers={"Content-Disposition": f'attachment; filename="{name}"'},
)
class ExportPlugin(BasePlugin):
name = "export"
title = "Export"
description = "Listen als CSV und PDF (Platzhalter)."
description = (
"Listen (Neuheiten, Planung, Archiv) als CSV für Excel oder als "
"PDF zum Drucken herunterladen."
)
def __init__(self) -> None:
super().__init__()
self._routen_registrieren()
# ---------- Plugin-Vertrag ----------
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/export")]
# ---------- Hilfsfunktionen ----------
@staticmethod
def _definition(liste: str):
definition = LISTEN.get((liste or "").lower())
if definition is None:
raise HTTPException(status_code=404, detail="Unbekannte Liste.")
return definition
@staticmethod
def _pruefe_zugriff(user: User, definition) -> None:
"""Admin/Redakteur dürfen alles; Rezensenten nur die Planungsliste."""
if definition.nur_redaktion and user.role not in REDAKTION:
raise AccessDenied(
f"Die Liste „{definition.titel}“ darf nur von Admins und "
"Redakteuren exportiert werden."
)
def _lade_zeilen(self, db: Session, definition):
"""Holt die Zeilen über den Lader der Liste (None = Partner fehlt)."""
return definition.lader(db)
def _audit_export(
self,
request: Request,
user: User,
liste: str,
format_name: str,
anzahl: int,
) -> None:
"""Schreibt einen Audit-Eintrag „exportiert“ (best effort)."""
audit = request.app.state.registry.get("audit-log")
if audit is None:
return
try:
audit.log_sync(
user,
"exportiert",
f"liste-{liste}",
None,
details={"format": format_name, "anzahl": anzahl},
ip_adresse=request.client.host if request.client else None,
)
except Exception:
_logger.exception("Audit-Log-Eintrag für Export fehlgeschlagen.")
# ---------- Routen ----------
def _routen_registrieren(self) -> None:
@self.router.get("/export")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
def seite(
request: Request,
db: Session = Depends(get_db),
fehler: str = "",
user: User = Depends(require_user),
):
"""Export-Seite: Karten je Liste mit Format-Buttons."""
karten = []
for definition in LISTEN.values():
zeilen = self._lade_zeilen(db, definition)
karten.append(
{
"key": definition.key,
"titel": definition.titel,
"beschreibung": definition.beschreibung,
"erlaubt": not definition.nur_redaktion or user.role in REDAKTION,
"verfuegbar": zeilen is not None,
"anzahl": len(zeilen) if zeilen is not None else None,
}
)
return self.context.templates.TemplateResponse(
request=request,
name="export/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
"karten": karten,
"ist_redaktion": user.role in REDAKTION,
"fehler": fehler[:300],
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/export")]
@self.router.get("/export/{liste}/csv")
def csv_download(
request: Request,
liste: str,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
"""Liste als Semikolon-getrennte CSV (UTF-8 mit BOM) herunterladen."""
definition = self._definition(liste)
self._pruefe_zugriff(user, definition)
zeilen = self._lade_zeilen(db, definition)
if zeilen is None:
return RedirectResponse("/export?fehler=Liste+nicht+verfügbar", status_code=303)
daten = csv_erzeugen(definition.spalten, zeilen)
self._audit_export(request, user, definition.key, "csv", len(zeilen))
return _download(
daten,
MEDIA_CSV,
dateiname(definition.key, "csv"),
)
@self.router.get("/export/{liste}/pdf")
def pdf_download(
request: Request,
liste: str,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
"""Liste als PDF (WeasyPrint) herunterladen."""
definition = self._definition(liste)
self._pruefe_zugriff(user, definition)
zeilen = self._lade_zeilen(db, definition)
if zeilen is None:
return RedirectResponse("/export?fehler=Liste+nicht+verfügbar", status_code=303)
try:
daten = pdf_erzeugen(definition.titel, definition.spalten, zeilen)
except Exception:
# Fehlende System-Bibliotheken (Pango & Co.) dürfen nur diesen
# Download betreffen — App und CSV-Export bleiben nutzbar.
_logger.exception("PDF-Erzeugung für Liste '%s' fehlgeschlagen.", definition.key)
return RedirectResponse(
"/export?fehler=PDF-Erzeugung+fehlgeschlagen+%28WeasyPrint%2FSystem-Bibliotheken+fehlen%3F%29",
status_code=303,
)
self._audit_export(request, user, definition.key, "pdf", len(zeilen))
return _download(
daten,
MEDIA_PDF,
dateiname(definition.key, "pdf", datetime.now()),
)
plugin = ExportPlugin()