From 19997b9f9defffd102adf7cbf5c7f7d113506b36 Mon Sep 17 00:00:00 2001 From: Hans-Christian Payer Date: Fri, 22 May 2026 08:13:05 +0200 Subject: [PATCH] Vorschau erstellt --- app/mailmerge/services/preview.py | 95 ++++++++++++++++++++++ app/mailmerge/urls.py | 1 + app/mailmerge/views.py | 46 ++++++++++- app/templates/mailmerge/job_form.html | 110 +++++++++++++++++++++++++- 4 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 app/mailmerge/services/preview.py diff --git a/app/mailmerge/services/preview.py b/app/mailmerge/services/preview.py new file mode 100644 index 0000000..a8da971 --- /dev/null +++ b/app/mailmerge/services/preview.py @@ -0,0 +1,95 @@ +""" +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, + ) diff --git a/app/mailmerge/urls.py b/app/mailmerge/urls.py index 72a9acf..71a2acf 100644 --- a/app/mailmerge/urls.py +++ b/app/mailmerge/urls.py @@ -7,6 +7,7 @@ urlpatterns = [ path("templates/new/", views.template_upload, name="template-upload"), path("templates//", views.template_detail, name="template-detail"), path("jobs/new/", views.job_create, name="job-create"), + path("jobs/preview/", views.job_preview, name="job-preview"), path("jobs//", views.job_detail, name="job-detail"), path("jobs//download/", views.job_download, name="job-download"), ] diff --git a/app/mailmerge/views.py b/app/mailmerge/views.py index e6c548c..145456e 100644 --- a/app/mailmerge/views.py +++ b/app/mailmerge/views.py @@ -1,16 +1,21 @@ +import logging from pathlib import Path +from django.contrib import messages from django.contrib.auth.decorators import login_required from django.http import FileResponse, Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from django.views.decorators.http import require_http_methods +from django.views.decorators.http import require_http_methods, require_POST from .forms import LetterTemplateForm, MailMergeJobForm from .models import LetterTemplate, MailMergeJob from .services.docx_renderer import extract_placeholders +from .services.preview import PreviewError, build_preview from .tasks import run_mailmerge +logger = logging.getLogger(__name__) + @login_required def dashboard(request): @@ -54,6 +59,45 @@ def job_create(request): return render(request, "mailmerge/job_form.html", {"form": form}) +@login_required +@require_POST +def job_preview(request): + """Rendert nur die erste CSV-Zeile gegen das gewählte Template und + liefert das PDF inline aus. Persistiert nichts.""" + form = MailMergeJobForm(request.POST, request.FILES) + if not form.is_valid(): + # Form-Errors zurück ins Hauptformular leiten + return render(request, "mailmerge/job_form.html", + {"form": form, "preview_error": None}) + + template = form.cleaned_data["template"] + csv_upload = form.cleaned_data["recipients_csv"] + + try: + result = build_preview( + template_path=Path(template.file.path), + csv_file=csv_upload, + ) + except PreviewError as exc: + messages.error(request, str(exc)) + return render(request, "mailmerge/job_form.html", + {"form": form, "preview_error": str(exc)}) + except Exception as exc: # noqa: BLE001 – defensiv, breite Fehlerklasse von LibreOffice + logger.exception("Preview-Render fehlgeschlagen") + messages.error( + request, + "Vorschau konnte nicht erstellt werden: " + str(exc), + ) + return render(request, "mailmerge/job_form.html", + {"form": form, "preview_error": str(exc)}) + + response = HttpResponse(result.pdf_bytes, content_type="application/pdf") + response["Content-Disposition"] = 'inline; filename="vorschau.pdf"' + response["X-Preview-Placeholders"] = ",".join(result.placeholders) + response["X-Preview-Extra-Columns"] = ",".join(result.extra_columns) + return response + + @login_required def job_detail(request, pk): job = get_object_or_404( diff --git a/app/templates/mailmerge/job_form.html b/app/templates/mailmerge/job_form.html index ab7fe9b..b4353bb 100644 --- a/app/templates/mailmerge/job_form.html +++ b/app/templates/mailmerge/job_form.html @@ -1,10 +1,114 @@ {% extends "base.html" %} {% block content %}

Neuer Serienbrief

-

Vorlage und Empfänger-CSV auswählen. Die Spaltennamen der CSV müssen mit den Platzhaltern der Vorlage übereinstimmen (erste Zeile = Spaltennamen).

-
+

+ Vorlage und Empfänger-CSV auswählen. Die Spaltennamen der CSV müssen mit den + Platzhaltern der Vorlage übereinstimmen (erste Zeile = Spaltennamen). + Erstelle vorab eine Vorschau, um die Ausgabe mit der ersten + Datenzeile zu prüfen. +

+ +{% if preview_error %} +
+
  • {{ preview_error }}
+
+{% endif %} + + {% csrf_token %} {{ form.as_p }} - + +
+ + +
+ + + + + + {% endblock %}