Vorschau erstellt

This commit is contained in:
2026-05-22 08:13:05 +02:00
parent ff386677a7
commit 19997b9f9d
4 changed files with 248 additions and 4 deletions
+95
View File
@@ -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,
)
+1
View File
@@ -7,6 +7,7 @@ urlpatterns = [
path("templates/new/", views.template_upload, name="template-upload"),
path("templates/<uuid:pk>/", 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/<uuid:pk>/", views.job_detail, name="job-detail"),
path("jobs/<uuid:pk>/download/", views.job_download, name="job-download"),
]
+45 -1
View File
@@ -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(