Merge wt-export: Export-Plugin (Konflikte aufgelöst)

This commit is contained in:
Flo Hartmann
2026-08-21 20:38:34 +00:00
9 changed files with 1246 additions and 54 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()

266
plugins/export/exporte.py Normal file
View File

@@ -0,0 +1,266 @@
"""Export-Logik des Plugins „export“ — Listen als CSV und PDF.
Diese Datei enthält die komplette Fachlogik des Export-Plugins:
- **CSV** über das Standardmodul ``csv``, Semikolon-getrennt für Excel mit
deutschem Gebietsschema, kodiert als UTF-8 mit Byte-Order-Mark (BOM).
- **PDF** über WeasyPrint: sauber formatierte Tabellen mit deutscher
Kopfzeile (auf jeder Seite wiederholt), Erstell-Datum und Seitenzahlen.
WeasyPrint wird erst beim PDF-Aufruf importiert (lazy) — ohne die
System-Bibliotheken (Pango usw.) bleiben CSV-Exporte und die App nutzbar.
Die Datenzugriffe lesen die Tabellen der Partner-Plugins über die gemeinsame
SQLAlchemy-Metadata (``Base.metadata.tables``) — genau wie das planung-Plugin
es vorlebt. Fehlt ein Partner-Plugin, meldet der jeweilige Lader ``None``;
die Liste erscheint dann im Export als nicht verfügbar.
"""
from __future__ import annotations
import csv
import html
import io
from dataclasses import dataclass, field
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from redaktionskern.auth.models import User
from redaktionskern.db import Base
# ---------------- Status-Anzeigenamen (lokale Kopien, keine Plugin-Importe) ----------------
NEUHEITEN_STATUS_ANZEIGE: dict[str, str] = {
"neuheit": "Neuheit",
"planung": "In Planung",
"archiv": "Archiviert",
}
PLANUNG_STATUS_ANZEIGE: dict[str, str] = {
"offen": "Offen",
"in_bearbeitung": "In Bearbeitung",
"abgeschlossen": "Abgeschlossen",
}
#: Titel, die das archiv-Plugin später automatisch setzt (12-Monats-Regel).
STATUS_ARCHIV = "archiv"
def _datum_de(wert: datetime | None) -> str:
"""Deutsches Kurzformat (TT.MM.JJJJ HH:MM); leer bei None."""
return wert.strftime("%d.%m.%Y %H:%M") if wert else ""
def _oder_leer(wert) -> str:
"""None/Leer → Leerstring, alles andere als Text."""
return "" if wert is None else str(wert)
# ---------------- Listen-Definitionen ----------------
@dataclass(frozen=True)
class ListenDefinition:
"""Eine exportierbare Liste: Anzeige-Texte, Spalten und Daten-Lader."""
key: str
titel: str
beschreibung: str
nur_redaktion: bool
spalten: list[str] = field(default_factory=list)
lader: object = None # Callable[[Session], list[list[str]] | None]
def _lade_neuheiten(db: Session, *, nur_archiv: bool = False) -> list[list[str]] | None:
"""Zeilen der Neuheiten-Tabelle (Archiv = Filter auf Status „archiv“).
Gibt ``None`` zurück, wenn das neuheiten-Plugin nicht geladen ist.
"""
tabelle = Base.metadata.tables.get("neuheiten")
if tabelle is None:
return None
abfrage = select(tabelle)
if nur_archiv:
abfrage = abfrage.where(tabelle.c.status == STATUS_ARCHIV)
zeilen: list[list[str]] = []
# Core-Select auf die Tabelle: jede Zeile ist ein Row mit Attributzugriff.
for zeile in db.execute(abfrage.order_by(tabelle.c.titel.asc(), tabelle.c.id.asc())):
zeilen.append(
[
zeile.titel,
_oder_leer(zeile.verlag),
_oder_leer(zeile.autor),
_oder_leer(zeile.erscheinungsjahr),
NEUHEITEN_STATUS_ANZEIGE.get(zeile.status, zeile.status),
_datum_de(zeile.aktualisiert_am),
]
)
return zeilen
def _lade_planung(db: Session) -> list[list[str]] | None:
"""Zeilen der Planungsliste inklusive Rezensenten-Namen.
Gibt ``None`` zurück, wenn das planung-Plugin nicht geladen ist.
"""
tabelle = Base.metadata.tables.get("planungsliste")
if tabelle is None:
return None
namen = {
benutzer.id: (benutzer.display_name or benutzer.username)
for benutzer in db.scalars(select(User)).all()
}
zeilen: list[list[str]] = []
abfrage = select(tabelle).order_by(tabelle.c.titel.asc(), tabelle.c.id.asc())
for zeile in db.execute(abfrage):
zeilen.append(
[
zeile.titel,
_oder_leer(zeile.verlag),
_oder_leer(zeile.autor),
_oder_leer(zeile.ausgabe),
namen.get(zeile.rezensent_id, ""),
PLANUNG_STATUS_ANZEIGE.get(zeile.status, zeile.status),
_oder_leer(zeile.notizen),
_datum_de(zeile.aktualisiert_am),
]
)
return zeilen
NEUHEITEN_SPALTEN = ["Titel", "Verlag", "Autor", "Erscheinungsjahr", "Status", "Aktualisiert am"]
PLANUNG_SPALTEN = ["Titel", "Verlag", "Autor", "Ausgabe", "Rezensent", "Status", "Notizen", "Aktualisiert am"]
#: Alle exportierbaren Listen (Reihenfolge = Reihenfolge auf der Export-Seite).
LISTEN: dict[str, ListenDefinition] = {
definition.key: definition
for definition in (
ListenDefinition(
key="neuheiten",
titel="Neuheitenliste",
beschreibung=(
"Aktuelle Neuheiten aus der BoardGameGeek-Synchronisation — "
"inklusive Status (Neuheit, in Planung, archiviert)."
),
nur_redaktion=True,
spalten=NEUHEITEN_SPALTEN,
lader=lambda db: _lade_neuheiten(db),
),
ListenDefinition(
key="planung",
titel="Planungsliste",
beschreibung=(
"Geplante Rezensionen mit Zuordnung an Rezensentin/Rezensent, "
"Ausgabe und Bearbeitungsstatus."
),
nur_redaktion=False,
spalten=PLANUNG_SPALTEN,
lader=_lade_planung,
),
ListenDefinition(
key="archiv",
titel="Archiv",
beschreibung=(
"Archivierte Titel (älter als 12 Monate, Status „archiviert“) — "
"gefüllt durch den Autopilot des archiv-Plugins."
),
nur_redaktion=True,
spalten=list(NEUHEITEN_SPALTEN),
lader=lambda db: _lade_neuheiten(db, nur_archiv=True),
),
)
}
# ---------------- Formaterzeugung ----------------
def csv_erzeugen(kopfzeilen: list[str], zeilen: list[list[str]]) -> bytes:
"""CSV-Dokument als Bytes: Semikolon-getrennt, UTF-8 mit BOM (Excel-DE)."""
puffer = io.StringIO(newline="")
writer = csv.writer(puffer, delimiter=";")
writer.writerow(kopfzeilen)
writer.writerows(zeilen)
# utf-8-sig schreibt das Byte-Order-Mark (EF BB BF) — Excel erkennt
# daran die UTF-8-Kodierung auch ohne Datei-Import-Assistenten.
return puffer.getvalue().encode("utf-8-sig")
def _zelle(wert: str) -> str:
return html.escape(_oder_leer(wert))
def _html_tabelle(listen_titel: str, kopfzeilen: list[str], zeilen: list[list[str]], erstellt_am: datetime) -> str:
"""Baut das HTML-Gerüst für den PDF-Druck (deutsche Kopfzeile, Datum, Seitenzahlen)."""
kopf = "".join(f"<th>{_zelle(spalte)}</th>" for spalte in kopfzeilen)
koerper = "".join(
"<tr>" + "".join(f"<td>{_zelle(zelle)}</td>" for zelle in zeile) + "</tr>"
for zeile in zeilen
)
leer = "" if zeilen else '<p class="hinweis">Diese Liste enthält zurzeit keine Einträge.</p>'
datum_text = erstellt_am.strftime("%d.%m.%Y, %H:%M")
return f"""<!doctype html>
<html lang="de">
<head><meta charset="utf-8"><title>{html.escape(listen_titel)}</title>
<style>
@page {{
size: A4;
margin: 20mm 15mm 18mm 15mm;
@bottom-center {{
content: "Seite " counter(page) " von " counter(pages);
font-size: 9pt;
color: #64748b;
}}
}}
body {{ font-family: sans-serif; color: #0f172a; font-size: 10pt; }}
h1 {{ font-size: 17pt; margin: 0 0 2mm 0; }}
.meta {{ font-size: 9pt; color: #475569; margin: 0 0 6mm 0; }}
table {{ width: 100%; border-collapse: collapse; }}
thead {{ display: table-header-group; }} /* Kopfzeile auf jeder Seite */
th {{
background-color: #ecfdf5;
border-bottom: 1pt solid #047857;
text-align: left;
padding: 2mm 2.5mm;
font-size: 9.5pt;
}}
td {{
border-bottom: 0.4pt solid #cbd5e1;
padding: 1.8mm 2.5mm;
vertical-align: top;
word-wrap: break-word;
}}
tr {{ page-break-inside: avoid; }}
.hinweis {{ color: #475569; font-style: italic; }}
</style></head>
<body>
<h1>{html.escape(listen_titel)}</h1>
<p class="meta">Spiele-Redaktion &middot; Erstellt am {datum_text} Uhr &middot;
{len(zeilen)} Eintr&auml;ge</p>
{leer}
<table>
<thead><tr>{kopf}</tr></thead>
<tbody>{koerper}</tbody>
</table>
</body></html>"""
def pdf_erzeugen(
listen_titel: str,
kopfzeilen: list[str],
zeilen: list[list[str]],
erstellt_am: datetime | None = None,
) -> bytes:
"""PDF-Dokument über WeasyPrint (Import lazy, damit CSV ohne System-Bibliotheken bleibt)."""
from weasyprint import HTML # noqa: PLC0415 — bewusst spät
dokument = _html_tabelle(
listen_titel, kopfzeilen, zeilen, erstellt_am or datetime.now()
)
return HTML(string=dokument).write_pdf()
def dateiname(liste: str, endung: str, jetzt: datetime | None = None) -> str:
"""ASCII-sicherer Download-Name, z. B. ``spiele-planung-export-20250821-1430.csv``."""
stempel = (jetzt or datetime.now()).strftime("%Y%m%d-%H%M")
return f"spiele-{liste}-export-{stempel}.{endung}"

View File

@@ -2,9 +2,60 @@
{% 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 mb-4 max-w-3xl">
Redaktionslisten als Datei herunterladen:
<strong>CSV</strong> (Semikolon-getrennt, UTF-8 mit BOM &mdash; direkt in Excel
&ouml;ffnbar) oder <strong>PDF</strong> (formatierte Tabelle mit Datum und
Seitenzahlen). Admins und Redakteure k&ouml;nnen alle Listen exportieren;
Rezensentinnen und Rezensenten die Planungsliste.
</p>
{% 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="grid gap-4 md:grid-cols-3 mb-6">
{% for karte in karten %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 flex flex-col
{% if not karte.verfuegbar %}opacity-60{% endif %}">
<div class="flex items-start justify-between mb-2">
<h2 class="font-semibold text-slate-800">{{ karte.titel }}</h2>
{% if karte.verfuegbar %}
<span class="text-xs text-slate-500 bg-slate-100 rounded-full px-2 py-0.5 whitespace-nowrap">{{ karte.anzahl }} Eintr&auml;ge</span>
{% else %}
<span class="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5 whitespace-nowrap">Nicht verf&uuml;gbar</span>
{% endif %}
</div>
<p class="text-sm text-slate-600 mb-4 flex-1">{{ karte.beschreibung }}</p>
{% if karte.erlaubt %}
{% if karte.verfuegbar %}
<div class="flex gap-2">
<a href="/export/{{ karte.key }}/csv"
class="flex-1 text-center bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-3 py-2 rounded-lg text-sm">
CSV herunterladen
</a>
<a href="/export/{{ karte.key }}/pdf"
class="flex-1 text-center bg-slate-700 hover:bg-slate-800 text-white font-medium px-3 py-2 rounded-lg text-sm">
PDF herunterladen
</a>
</div>
{% else %}
<p class="text-xs text-slate-500">Das zugeh&ouml;rige Plugin ist nicht geladen &mdash; kein Export m&ouml;glich.</p>
{% endif %}
{% else %}
<p class="text-xs text-slate-400 italic">Nur f&uuml;r Admins und Redakteure &mdash; du hast hier keinen Export-Zugriff.</p>
{% endif %}
</div>
{% endfor %}
</div>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 text-sm text-slate-600">
<h3 class="font-semibold text-slate-800 mb-1">Hinweise zu den Formaten</h3>
<ul class="list-disc list-inside space-y-1">
<li><strong>CSV:</strong> Semikolon als Trennzeichen (Excel mit deutschem Gebietsschema), UTF-8 mit Byte-Order-Mark &mdash; Umlaute und &szlig; bleiben erhalten.</li>
<li><strong>PDF:</strong> A4, deutsche Kopfzeile auf jeder Seite, Erstell-Datum und Seitenzahlen („Seite X von Y“).</li>
<li>Jeder Download wird im <a href="/audit-log" class="text-emerald-700 underline">Audit-Log</a> protokolliert (nur Admins einsehbar).</li>
</ul>
</div>
{% endblock %}