Vorschau erstellt
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -1,10 +1,114 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Neuer Serienbrief</h1>
|
||||
<p>Vorlage und Empfänger-CSV auswählen. Die Spaltennamen der CSV müssen mit den Platzhaltern der Vorlage übereinstimmen (erste Zeile = Spaltennamen).</p>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<p>
|
||||
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 <strong>Vorschau</strong>, um die Ausgabe mit der ersten
|
||||
Datenzeile zu prüfen.
|
||||
</p>
|
||||
|
||||
{% if preview_error %}
|
||||
<div class="messages">
|
||||
<ul><li>{{ preview_error }}</li></ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form id="job-form" method="post" action="{% url 'job-create' %}" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit" class="btn">Erstellen und starten</button>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" id="btn-preview" class="btn btn-secondary">
|
||||
Vorschau (erste Zeile)
|
||||
</button>
|
||||
<button type="submit" class="btn">
|
||||
Job starten
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section id="preview-section" hidden>
|
||||
<h2>Vorschau</h2>
|
||||
<p id="preview-status" class="muted"></p>
|
||||
<iframe id="preview-frame"
|
||||
title="Vorschau-PDF"
|
||||
style="width:100%; height:70vh; border:1px solid #ddd; border-radius:4px;"></iframe>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const form = document.getElementById("job-form");
|
||||
const btn = document.getElementById("btn-preview");
|
||||
const section = document.getElementById("preview-section");
|
||||
const status = document.getElementById("preview-status");
|
||||
const frame = document.getElementById("preview-frame");
|
||||
const previewUrl = "{% url 'job-preview' %}";
|
||||
const csrfToken = form.querySelector("[name=csrfmiddlewaretoken]").value;
|
||||
|
||||
let lastBlobUrl = null;
|
||||
|
||||
btn.addEventListener("click", async function () {
|
||||
btn.disabled = true;
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = "Erzeuge Vorschau…";
|
||||
section.hidden = false;
|
||||
status.textContent = "Bitte warten, LibreOffice rendert die erste Zeile…";
|
||||
status.classList.remove("error");
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const resp = await fetch(previewUrl, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: { "X-CSRFToken": csrfToken },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
// Server hat das Form-Template zurückgegeben → Fehlertext extrahieren
|
||||
const text = await resp.text();
|
||||
const match = text.match(/<li>([\s\S]*?)<\/li>/);
|
||||
const msg = match ? match[1].trim() : `Vorschau fehlgeschlagen (HTTP ${resp.status}).`;
|
||||
status.textContent = msg;
|
||||
status.classList.add("error");
|
||||
frame.removeAttribute("src");
|
||||
return;
|
||||
}
|
||||
|
||||
const ct = resp.headers.get("Content-Type") || "";
|
||||
if (!ct.includes("application/pdf")) {
|
||||
status.textContent = "Unerwartete Antwort vom Server (kein PDF).";
|
||||
status.classList.add("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await resp.blob();
|
||||
if (lastBlobUrl) URL.revokeObjectURL(lastBlobUrl);
|
||||
lastBlobUrl = URL.createObjectURL(blob);
|
||||
frame.src = lastBlobUrl;
|
||||
|
||||
const extra = resp.headers.get("X-Preview-Extra-Columns");
|
||||
let hint = "Vorschau bereit. Wenn alles passt, oben auf »Job starten« klicken.";
|
||||
if (extra && extra.trim().length > 0) {
|
||||
hint += " Hinweis: CSV enthält Spalten, die im Template nicht verwendet werden: " + extra + ".";
|
||||
}
|
||||
status.textContent = hint;
|
||||
} catch (err) {
|
||||
status.textContent = "Netzwerk- oder Browser-Fehler: " + err.message;
|
||||
status.classList.add("error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.form-actions { display: flex; gap: 0.75rem; margin-top: 1rem; }
|
||||
.btn-secondary { background: #6b7280; }
|
||||
.muted { color: #6b7280; font-size: 0.95rem; }
|
||||
.muted.error { color: #dc2626; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user