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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user