""" 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, )