Erste lauffähige Version
This commit is contained in:
@@ -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",)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MailmergeConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "mailmerge"
|
||||
verbose_name = "Serienbrief"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user