Erste lauffähige Version

This commit is contained in:
2026-05-21 10:36:16 +02:00
commit 6a103adac4
98 changed files with 4107 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
from django.contrib import admin
from .models import JobLogEntry, LetterTemplate, MailMergeJob
@admin.register(LetterTemplate)
class LetterTemplateAdmin(admin.ModelAdmin):
list_display = ("name", "created_by", "created_at")
search_fields = ("name",)
readonly_fields = ("id", "placeholders", "created_at", "updated_at")
@admin.register(MailMergeJob)
class MailMergeJobAdmin(admin.ModelAdmin):
list_display = ("id", "template", "status", "processed_rows", "total_rows",
"created_by", "created_at")
list_filter = ("status",)
readonly_fields = ("id", "created_at", "started_at", "finished_at",
"processed_rows", "total_rows", "error_message")
@admin.register(JobLogEntry)
class JobLogEntryAdmin(admin.ModelAdmin):
list_display = ("job", "level", "timestamp")
list_filter = ("level",)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class MailmergeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "mailmerge"
verbose_name = "Serienbrief"
+34
View File
@@ -0,0 +1,34 @@
from django import forms
from .models import LetterTemplate, MailMergeJob
class LetterTemplateForm(forms.ModelForm):
class Meta:
model = LetterTemplate
fields = ["name", "description", "file"]
widgets = {
"description": forms.Textarea(attrs={"rows": 3}),
}
def clean_file(self):
f = self.cleaned_data["file"]
if not f.name.lower().endswith(".docx"):
raise forms.ValidationError("Nur .docx-Dateien sind erlaubt.")
if f.size > 10 * 1024 * 1024:
raise forms.ValidationError("Datei zu groß (max. 10 MB).")
return f
class MailMergeJobForm(forms.ModelForm):
class Meta:
model = MailMergeJob
fields = ["template", "recipients_csv"]
def clean_recipients_csv(self):
f = self.cleaned_data["recipients_csv"]
if not f.name.lower().endswith(".csv"):
raise forms.ValidationError("Nur .csv-Dateien sind erlaubt.")
if f.size > 20 * 1024 * 1024:
raise forms.ValidationError("Datei zu groß (max. 20 MB).")
return f
@@ -0,0 +1,25 @@
"""Wartet, bis die DB Connections annimmt."""
import time
from django.core.management.base import BaseCommand
from django.db import OperationalError, connections
class Command(BaseCommand):
help = "Wartet, bis die Standard-DB verfügbar ist."
def add_arguments(self, parser):
parser.add_argument("--timeout", type=int, default=60)
def handle(self, *args, **opts):
deadline = time.time() + opts["timeout"]
while time.time() < deadline:
try:
connections["default"].ensure_connection()
self.stdout.write(self.style.SUCCESS("DB ist bereit."))
return
except OperationalError:
self.stdout.write("warte auf DB...")
time.sleep(2)
self.stderr.write(self.style.ERROR("DB nach Timeout nicht erreichbar."))
raise SystemExit(1)
+86
View File
@@ -0,0 +1,86 @@
"""
Datenmodell Templates, Jobs, Log-Einträge.
"""
import uuid
from pathlib import Path
from django.conf import settings
from django.db import models
def template_upload_path(instance, filename: str) -> str:
return f"templates/{instance.id}/{Path(filename).name}"
def csv_upload_path(instance, filename: str) -> str:
return f"jobs/{instance.id}/recipients/{Path(filename).name}"
def result_upload_path(instance, filename: str) -> str:
return f"jobs/{instance.id}/result/{Path(filename).name}"
class LetterTemplate(models.Model):
"""DOCX-Vorlage mit Jinja-Platzhaltern."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
file = models.FileField(upload_to=template_upload_path)
placeholders = models.JSONField(default=list, blank=True,
help_text="Aus dem DOCX extrahierte Variablen.")
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
related_name="templates")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return self.name
class MailMergeJob(models.Model):
class Status(models.TextChoices):
PENDING = "pending", "Wartet"
RUNNING = "running", "Läuft"
DONE = "done", "Fertig"
FAILED = "failed", "Fehlgeschlagen"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
template = models.ForeignKey(LetterTemplate, on_delete=models.PROTECT,
related_name="jobs")
recipients_csv = models.FileField(upload_to=csv_upload_path)
status = models.CharField(max_length=20, choices=Status.choices,
default=Status.PENDING)
result_pdf = models.FileField(upload_to=result_upload_path, null=True, blank=True)
total_rows = models.PositiveIntegerField(default=0)
processed_rows = models.PositiveIntegerField(default=0)
error_message = models.TextField(blank=True)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
related_name="jobs")
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(null=True, blank=True)
finished_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"Job {self.id} ({self.template.name})"
class JobLogEntry(models.Model):
class Level(models.TextChoices):
INFO = "info", "Info"
WARNING = "warning", "Warnung"
ERROR = "error", "Fehler"
job = models.ForeignKey(MailMergeJob, on_delete=models.CASCADE, related_name="logs")
level = models.CharField(max_length=10, choices=Level.choices, default=Level.INFO)
message = models.TextField()
timestamp = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["timestamp"]
@@ -0,0 +1,74 @@
"""
DOCX-Rendering und PDF-Konvertierung.
"""
from __future__ import annotations
import logging
import re
import subprocess
import tempfile
from pathlib import Path
from docx import Document
from docxtpl import DocxTemplate
logger = logging.getLogger(__name__)
PLACEHOLDER_RE = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)")
def extract_placeholders(docx_path: Path) -> list[str]:
"""Liest die Jinja-Platzhalter aus einem DOCX und gibt sie sortiert zurück."""
doc = Document(str(docx_path))
found: set[str] = set()
for para in doc.paragraphs:
for m in PLACEHOLDER_RE.finditer(para.text):
found.add(m.group(1))
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
for m in PLACEHOLDER_RE.finditer(para.text):
found.add(m.group(1))
return sorted(found)
def render_docx(template_path: Path, context: dict, out_path: Path) -> Path:
"""Füllt das DOCX-Template mit Kontext und schreibt das Ergebnis."""
tpl = DocxTemplate(str(template_path))
tpl.render(context)
tpl.save(str(out_path))
return out_path
def docx_to_pdf(docx_path: Path, out_dir: Path) -> Path:
"""Konvertiert DOCX nach PDF mit LibreOffice headless.
LibreOffice braucht ein eigenes Profilverzeichnis, sonst kollidieren
parallele Worker.
"""
out_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="lo-profile-") as profile_dir:
cmd = [
"soffice",
"--headless",
"--nologo",
"--norestore",
"--nolockcheck",
f"-env:UserInstallation=file://{profile_dir}",
"--convert-to", "pdf",
"--outdir", str(out_dir),
str(docx_path),
]
logger.info("LibreOffice convert: %s", " ".join(cmd))
result = subprocess.run( # noqa: S603
cmd, capture_output=True, text=True, timeout=120, check=False
)
if result.returncode != 0:
raise RuntimeError(
f"LibreOffice-Konvertierung fehlgeschlagen: {result.stderr}"
)
pdf_path = out_dir / (docx_path.stem + ".pdf")
if not pdf_path.exists():
raise FileNotFoundError(f"PDF nicht gefunden: {pdf_path}")
return pdf_path
@@ -0,0 +1,14 @@
from pathlib import Path
from pypdf import PdfWriter
def merge_pdfs(pdfs: list[Path], out_path: Path) -> Path:
writer = PdfWriter()
for pdf in pdfs:
writer.append(str(pdf))
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("wb") as f:
writer.write(f)
writer.close()
return out_path
+87
View File
@@ -0,0 +1,87 @@
"""
Celery-Tasks für die PDF-Erzeugung.
"""
from __future__ import annotations
import csv
import io
import logging
import tempfile
from pathlib import Path
from celery import shared_task
from django.core.files import File
from django.utils import timezone
from .models import JobLogEntry, MailMergeJob
from .services.docx_renderer import docx_to_pdf, render_docx
from .services.pdf_merge import merge_pdfs
logger = logging.getLogger(__name__)
def _log(job: MailMergeJob, level: str, msg: str) -> None:
JobLogEntry.objects.create(job=job, level=level, message=msg)
logger.log(getattr(logging, level.upper(), logging.INFO), "[job %s] %s", job.id, msg)
@shared_task(bind=True)
def run_mailmerge(self, job_id: str) -> str:
job = MailMergeJob.objects.select_related("template").get(pk=job_id)
job.status = MailMergeJob.Status.RUNNING
job.started_at = timezone.now()
job.save(update_fields=["status", "started_at"])
_log(job, "info", f"Job gestartet (task={self.request.id}).")
try:
# CSV einlesen
raw = job.recipients_csv.read().decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(raw))
rows = list(reader)
job.total_rows = len(rows)
job.save(update_fields=["total_rows"])
_log(job, "info", f"{len(rows)} Empfänger gefunden.")
if not rows:
raise ValueError("CSV enthält keine Datenzeilen.")
# CSV-Felder vs. Template-Platzhalter prüfen
csv_fields = set(reader.fieldnames or [])
placeholders = set(job.template.placeholders or [])
missing = placeholders - csv_fields
if missing:
_log(job, "warning",
f"CSV fehlen Spalten: {', '.join(sorted(missing))}")
# Jeden Brief rendern, alle zu einem PDF zusammenführen
with tempfile.TemporaryDirectory(prefix="mailmerge-") as tmpdir:
tmp = Path(tmpdir)
pdfs: list[Path] = []
template_path = Path(job.template.file.path)
for idx, row in enumerate(rows, start=1):
docx_out = tmp / f"letter_{idx:05d}.docx"
render_docx(template_path, row, docx_out)
pdf = docx_to_pdf(docx_out, tmp / "pdf")
pdfs.append(pdf)
job.processed_rows = idx
job.save(update_fields=["processed_rows"])
merged = tmp / f"serienbrief_{job.id}.pdf"
merge_pdfs(pdfs, merged)
with merged.open("rb") as f:
job.result_pdf.save(merged.name, File(f), save=False)
job.status = MailMergeJob.Status.DONE
job.finished_at = timezone.now()
job.save()
_log(job, "info", "Job erfolgreich abgeschlossen.")
return str(job.id)
except Exception as exc: # noqa: BLE001
job.status = MailMergeJob.Status.FAILED
job.error_message = str(exc)
job.finished_at = timezone.now()
job.save()
_log(job, "error", f"Job fehlgeschlagen: {exc}")
raise
+12
View File
@@ -0,0 +1,12 @@
from django.urls import path
from . import views
urlpatterns = [
path("", views.dashboard, name="dashboard"),
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/<uuid:pk>/", views.job_detail, name="job-detail"),
path("jobs/<uuid:pk>/download/", views.job_download, name="job-download"),
]
+94
View File
@@ -0,0 +1,94 @@
from pathlib import Path
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 .forms import LetterTemplateForm, MailMergeJobForm
from .models import LetterTemplate, MailMergeJob
from .services.docx_renderer import extract_placeholders
from .tasks import run_mailmerge
@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})
@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