96 lines
2.8 KiB
Python
96 lines
2.8 KiB
Python
|
|
"""
|
|||
|
|
Single-Letter-Preview: rendert ausschließlich die erste CSV-Zeile gegen das
|
|||
|
|
Template und gibt das resultierende PDF als Bytes zurück. Synchroner Pfad,
|
|||
|
|
keine Celery-Beteiligung – die View ruft das direkt im Request-Kontext auf.
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import csv
|
|||
|
|
import io
|
|||
|
|
import logging
|
|||
|
|
import tempfile
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
from .docx_renderer import docx_to_pdf, extract_placeholders, render_docx
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class PreviewError(Exception):
|
|||
|
|
"""Erwartete Fehler beim Preview – werden dem User angezeigt."""
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class PreviewResult:
|
|||
|
|
pdf_bytes: bytes
|
|||
|
|
used_row: dict[str, str]
|
|||
|
|
placeholders: list[str]
|
|||
|
|
csv_columns: list[str]
|
|||
|
|
missing_columns: list[str]
|
|||
|
|
extra_columns: list[str]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _read_first_row(csv_file) -> tuple[list[str], dict[str, str]]:
|
|||
|
|
"""Liest CSV-Header + erste Datenzeile.
|
|||
|
|
|
|||
|
|
Akzeptiert UploadedFile oder Path-ähnliches. UTF-8, Komma-getrennt.
|
|||
|
|
"""
|
|||
|
|
if hasattr(csv_file, "read"):
|
|||
|
|
raw = csv_file.read()
|
|||
|
|
if isinstance(raw, bytes):
|
|||
|
|
text = raw.decode("utf-8-sig")
|
|||
|
|
else:
|
|||
|
|
text = raw
|
|||
|
|
else:
|
|||
|
|
text = Path(csv_file).read_text(encoding="utf-8-sig")
|
|||
|
|
|
|||
|
|
reader = csv.DictReader(io.StringIO(text))
|
|||
|
|
if not reader.fieldnames:
|
|||
|
|
raise PreviewError("CSV-Datei enthält keine Header-Zeile.")
|
|||
|
|
try:
|
|||
|
|
row = next(reader)
|
|||
|
|
except StopIteration as exc:
|
|||
|
|
raise PreviewError(
|
|||
|
|
"CSV-Datei enthält außer dem Header keine Datenzeile."
|
|||
|
|
) from exc
|
|||
|
|
return list(reader.fieldnames), row
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_preview(template_path: Path, csv_file) -> PreviewResult:
|
|||
|
|
"""Rendert die erste CSV-Zeile gegen das Template und liefert ein PDF.
|
|||
|
|
|
|||
|
|
Validiert vorab Header gegen die Template-Platzhalter und meldet
|
|||
|
|
fehlende / überflüssige Spalten.
|
|||
|
|
"""
|
|||
|
|
placeholders = extract_placeholders(template_path)
|
|||
|
|
columns, first_row = _read_first_row(csv_file)
|
|||
|
|
|
|||
|
|
placeholder_set = set(placeholders)
|
|||
|
|
column_set = set(columns)
|
|||
|
|
missing = sorted(placeholder_set - column_set)
|
|||
|
|
extra = sorted(column_set - placeholder_set)
|
|||
|
|
|
|||
|
|
if missing:
|
|||
|
|
raise PreviewError(
|
|||
|
|
"Folgende Platzhalter sind im Template enthalten, "
|
|||
|
|
"fehlen aber als Spalte in der CSV: "
|
|||
|
|
+ ", ".join(missing)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
with tempfile.TemporaryDirectory(prefix="serienbrief-preview-") as tmp:
|
|||
|
|
tmpdir = Path(tmp)
|
|||
|
|
docx_out = tmpdir / "preview.docx"
|
|||
|
|
render_docx(template_path, first_row, docx_out)
|
|||
|
|
pdf_path = docx_to_pdf(docx_out, tmpdir)
|
|||
|
|
pdf_bytes = pdf_path.read_bytes()
|
|||
|
|
|
|||
|
|
return PreviewResult(
|
|||
|
|
pdf_bytes=pdf_bytes,
|
|||
|
|
used_row=first_row,
|
|||
|
|
placeholders=placeholders,
|
|||
|
|
csv_columns=columns,
|
|||
|
|
missing_columns=missing,
|
|||
|
|
extra_columns=extra,
|
|||
|
|
)
|