2026-05-22 08:13:05 +02:00
|
|
|
|
import logging
|
2026-05-21 10:36:16 +02:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
2026-05-22 08:13:05 +02:00
|
|
|
|
from django.contrib import messages
|
2026-05-21 10:36:16 +02:00
|
|
|
|
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
|
2026-05-22 08:13:05 +02:00
|
|
|
|
from django.views.decorators.http import require_http_methods, require_POST
|
2026-05-21 10:36:16 +02:00
|
|
|
|
|
|
|
|
|
|
from .forms import LetterTemplateForm, MailMergeJobForm
|
|
|
|
|
|
from .models import LetterTemplate, MailMergeJob
|
|
|
|
|
|
from .services.docx_renderer import extract_placeholders
|
2026-05-22 08:13:05 +02:00
|
|
|
|
from .services.preview import PreviewError, build_preview
|
2026-05-21 10:36:16 +02:00
|
|
|
|
from .tasks import run_mailmerge
|
|
|
|
|
|
|
2026-05-22 08:13:05 +02:00
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
2026-05-21 10:36:16 +02:00
|
|
|
|
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def dashboard(request):
|
|
|
|
|
|
templates = LetterTemplate.objects.all()[:20]
|
|
|
|
|
|
jobs = MailMergeJob.objects.select_related("template")[:20]
|
|
|
|
|
|
return render(request, "mailmerge/dashboard.html",
|
|
|
|
|
|
{"templates": templates, "jobs": jobs})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
@require_http_methods(["GET", "POST"])
|
|
|
|
|
|
def template_upload(request):
|
|
|
|
|
|
form = LetterTemplateForm(request.POST or None, request.FILES or None)
|
|
|
|
|
|
if request.method == "POST" and form.is_valid():
|
|
|
|
|
|
tpl = form.save(commit=False)
|
|
|
|
|
|
tpl.created_by = request.user
|
|
|
|
|
|
tpl.save()
|
|
|
|
|
|
# Platzhalter extrahieren – Datei liegt jetzt auf Disk
|
|
|
|
|
|
tpl.placeholders = extract_placeholders(Path(tpl.file.path))
|
|
|
|
|
|
tpl.save(update_fields=["placeholders"])
|
|
|
|
|
|
return redirect(reverse("template-detail", args=[tpl.id]))
|
|
|
|
|
|
return render(request, "mailmerge/template_form.html", {"form": form})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def template_detail(request, pk):
|
|
|
|
|
|
tpl = get_object_or_404(LetterTemplate, pk=pk)
|
|
|
|
|
|
return render(request, "mailmerge/template_detail.html", {"template": tpl})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
@require_http_methods(["GET", "POST"])
|
|
|
|
|
|
def job_create(request):
|
|
|
|
|
|
form = MailMergeJobForm(request.POST or None, request.FILES or None)
|
|
|
|
|
|
if request.method == "POST" and form.is_valid():
|
|
|
|
|
|
job = form.save(commit=False)
|
|
|
|
|
|
job.created_by = request.user
|
|
|
|
|
|
job.save()
|
|
|
|
|
|
run_mailmerge.delay(str(job.id))
|
|
|
|
|
|
return redirect(reverse("job-detail", args=[job.id]))
|
|
|
|
|
|
return render(request, "mailmerge/job_form.html", {"form": form})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 08:13:05 +02:00
|
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 10:36:16 +02:00
|
|
|
|
@login_required
|
|
|
|
|
|
def job_detail(request, pk):
|
|
|
|
|
|
job = get_object_or_404(
|
|
|
|
|
|
MailMergeJob.objects.select_related("template"), pk=pk
|
|
|
|
|
|
)
|
|
|
|
|
|
logs = job.logs.all()
|
|
|
|
|
|
# HTMX partials: nur das Status-Fragment ausliefern
|
|
|
|
|
|
if request.headers.get("HX-Request"):
|
|
|
|
|
|
return render(request, "mailmerge/_job_status.html",
|
|
|
|
|
|
{"job": job, "logs": logs})
|
|
|
|
|
|
return render(request, "mailmerge/job_detail.html",
|
|
|
|
|
|
{"job": job, "logs": logs})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def job_download(request, pk):
|
|
|
|
|
|
"""PDF-Download – via X-Accel-Redirect in Production, direkter Stream im Dev."""
|
|
|
|
|
|
job = get_object_or_404(MailMergeJob, pk=pk)
|
|
|
|
|
|
if not job.result_pdf:
|
|
|
|
|
|
raise Http404("Kein Ergebnis-PDF vorhanden.")
|
|
|
|
|
|
|
|
|
|
|
|
# In Dev (settings.DEBUG) direkt streamen
|
|
|
|
|
|
from django.conf import settings
|
|
|
|
|
|
if settings.DEBUG:
|
|
|
|
|
|
return FileResponse(job.result_pdf.open("rb"),
|
|
|
|
|
|
as_attachment=True,
|
|
|
|
|
|
filename=Path(job.result_pdf.name).name)
|
|
|
|
|
|
|
|
|
|
|
|
# In Production: Nginx serviert die Datei via internem Mount.
|
|
|
|
|
|
response = HttpResponse()
|
|
|
|
|
|
response["Content-Type"] = "application/pdf"
|
|
|
|
|
|
response["Content-Disposition"] = (
|
|
|
|
|
|
f'attachment; filename="{Path(job.result_pdf.name).name}"'
|
|
|
|
|
|
)
|
|
|
|
|
|
# Pfad relativ zu MEDIA_ROOT, gemappt auf /protected-media/
|
|
|
|
|
|
relative = job.result_pdf.name
|
|
|
|
|
|
response["X-Accel-Redirect"] = f"/protected-media/{relative}"
|
|
|
|
|
|
return response
|