Merge wt-export: Export-Plugin (Konflikte aufgelöst)
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()
|
||||
|
||||
266
plugins/export/exporte.py
Normal file
266
plugins/export/exporte.py
Normal 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 · Erstellt am {datum_text} Uhr ·
|
||||
{len(zeilen)} Einträ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}"
|
||||
@@ -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 — die Funktion wird in einer spä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 — direkt in Excel
|
||||
öffnbar) oder <strong>PDF</strong> (formatierte Tabelle mit Datum und
|
||||
Seitenzahlen). Admins und Redakteure kö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ä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ü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örige Plugin ist nicht geladen — kein Export möglich.</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-xs text-slate-400 italic">Nur für Admins und Redakteure — 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 — Umlaute und ß 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 %}
|
||||
|
||||
Reference in New Issue
Block a user