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
+86
View File
@@ -0,0 +1,86 @@
# =============================================================================
# Multi-Stage: builder → dev → runtime
# Dev-Image enthält zusätzlich debugpy, ipython, django-debug-toolbar.
# =============================================================================
# ---------- Stage 1: Build ----------------------------------------------------
FROM python:3.12-slim-bookworm AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev gcc \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY requirements.txt requirements-dev.txt ./
RUN pip wheel --wheel-dir=/wheels -r requirements.txt -r requirements-dev.txt
# ---------- Common Runtime Base ----------------------------------------------
FROM python:3.12-slim-bookworm AS runtime-base
ARG APP_UID=10001
ARG APP_GID=10001
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PYTHONPATH=/app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
libreoffice libreoffice-writer \
fonts-liberation fonts-dejavu \
tini curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/*
RUN groupadd -g ${APP_GID} app && \
useradd -u ${APP_UID} -g ${APP_GID} -m -s /bin/bash app
WORKDIR /app
COPY --from=builder /wheels /wheels
# ---------- Stage 2a: Runtime (Production) ------------------------------------
FROM runtime-base AS runtime
COPY requirements.txt .
RUN pip install --no-index --find-links=/wheels -r requirements.txt && \
rm -rf /wheels
COPY --chown=app:app . /app/
RUN mkdir -p /app/staticfiles /app/media && chown -R app:app /app
USER app
EXPOSE 8000
ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"]
CMD ["gunicorn", "config.wsgi:application", \
"--bind", "0.0.0.0:8000", \
"--workers", "3", "--threads", "2", \
"--worker-class", "gthread", "--worker-tmp-dir", "/tmp", \
"--access-logfile", "-", "--error-logfile", "-", \
"--timeout", "120"]
# ---------- Stage 2b: Dev -----------------------------------------------------
FROM runtime-base AS dev
# Dev-Tools für Container & VS Code
RUN apt-get update && apt-get install -y --no-install-recommends \
git bash-completion vim less procps iputils-ping \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY requirements.txt requirements-dev.txt ./
RUN pip install --no-index --find-links=/wheels -r requirements.txt -r requirements-dev.txt && \
rm -rf /wheels
# Code wird im Dev via Volume gemountet; nichts kopieren.
RUN mkdir -p /app/staticfiles /app/media && chown -R app:app /app
USER app
EXPOSE 8000 5678
ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"]
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
+3
View File
@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ["celery_app"]
+6
View File
@@ -0,0 +1,6 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
application = get_asgi_application()
+9
View File
@@ -0,0 +1,9 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
app = Celery("serienbrief")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
+144
View File
@@ -0,0 +1,144 @@
"""
Basis-Settings. Werden von dev.py und production.py erweitert.
"""
from pathlib import Path
import environ
BASE_DIR = Path(__file__).resolve().parent.parent.parent
env = environ.Env(
DJANGO_DEBUG=(bool, False),
USE_X_FORWARDED_HOST=(bool, True),
JOB_RETENTION_DAYS=(int, 30),
)
# --- Core --------------------------------------------------------------------
SECRET_KEY = env("DJANGO_SECRET_KEY", default="dev-insecure-change-me")
DEBUG = env("DJANGO_DEBUG")
ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"])
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
# Hinter dem äußeren Reverse-Proxy
USE_X_FORWARDED_HOST = env("USE_X_FORWARDED_HOST")
_proxy_header = env("SECURE_PROXY_SSL_HEADER", default="")
if _proxy_header:
name, value = _proxy_header.split(",", 1)
SECURE_PROXY_SSL_HEADER = (name.strip(), value.strip())
# --- Apps --------------------------------------------------------------------
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# 3rd party
"django_celery_beat",
"django_celery_results",
"django_htmx",
"axes",
# local
"mailmerge",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"axes.middleware.AxesMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "config.wsgi.application"
# --- Database ----------------------------------------------------------------
DATABASES = {
"default": env.db_url(
"DATABASE_URL", default="sqlite:///" + str(BASE_DIR / "db.sqlite3")
),
}
# --- Auth --------------------------------------------------------------------
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
"OPTIONS": {"min_length": 12}},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
]
AUTHENTICATION_BACKENDS = [
"axes.backends.AxesStandaloneBackend",
"django.contrib.auth.backends.ModelBackend",
]
LOGIN_URL = "/accounts/login/"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/accounts/login/"
# django-axes Brute-Force-Schutz
AXES_FAILURE_LIMIT = 5
AXES_COOLOFF_TIME = 1 # Stunde
AXES_LOCKOUT_PARAMETERS = ["username", "ip_address"]
# --- I18N / TZ ---------------------------------------------------------------
LANGUAGE_CODE = "de-at"
TIME_ZONE = "Europe/Vienna"
USE_I18N = True
USE_TZ = True
# --- Static / Media ----------------------------------------------------------
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
# --- Celery ------------------------------------------------------------------
CELERY_BROKER_URL = env("CELERY_BROKER_URL", default="redis://redis:6379/0")
CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", default="django-db")
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 600 # 10 Minuten Hard-Timeout
CELERY_TASK_SOFT_TIME_LIMIT = 540
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
CELERY_TIMEZONE = TIME_ZONE
# --- App ---------------------------------------------------------------------
JOB_RETENTION_DAYS = env("JOB_RETENTION_DAYS")
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# --- Security Defaults -------------------------------------------------------
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_HTTPONLY = False # Bleibt false, damit JS/HTMX-Forms funktionieren
CSRF_COOKIE_SAMESITE = "Lax"
X_FRAME_OPTIONS = "DENY"
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
+16
View File
@@ -0,0 +1,16 @@
from .base import * # noqa: F401,F403
from .base import INSTALLED_APPS, MIDDLEWARE
DEBUG = True
ALLOWED_HOSTS = ["*"]
# Debug-Toolbar nur lokal
INSTALLED_APPS = INSTALLED_APPS + ["debug_toolbar"]
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE]
INTERNAL_IPS = ["127.0.0.1"]
# Im Dev keine Auto-Lockouts beim Testen
AXES_ENABLED = False
# E-Mails nur in die Konsole
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
@@ -0,0 +1,32 @@
from .base import * # noqa: F401,F403
DEBUG = False
# Strikte Security-Defaults TLS macht der äußere Proxy.
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 0 # HSTS setzt der äußere Proxy
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
SECURE_HSTS_PRELOAD = False
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{asctime} {levelname} {name} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
},
},
"root": {"handlers": ["console"], "level": "INFO"},
"loggers": {
"django.security": {"handlers": ["console"], "level": "WARNING", "propagate": False},
"mailmerge": {"handlers": ["console"], "level": "INFO", "propagate": False},
},
}
+27
View File
@@ -0,0 +1,27 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.http import HttpResponse
from django.urls import include, path
def healthz(_request):
return HttpResponse("ok", content_type="text/plain")
urlpatterns = [
path("healthz", healthz),
path("admin/", admin.site.urls),
path("accounts/login/", auth_views.LoginView.as_view(), name="login"),
path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"),
path("", include("mailmerge.urls")),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
try:
import debug_toolbar
urlpatterns = [path("__debug__/", include(debug_toolbar.urls)), *urlpatterns]
except ImportError:
pass
+6
View File
@@ -0,0 +1,6 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
application = get_wsgi_application()
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
set -euo pipefail
ROLE="${ROLE:-web}"
if [[ "$ROLE" == "web" ]]; then
echo "[entrypoint] waiting for database..."
python manage.py wait_for_db --timeout 60 || true
echo "[entrypoint] running migrations..."
python manage.py migrate --noinput
if [[ "${DJANGO_DEBUG:-False}" != "True" ]]; then
echo "[entrypoint] collecting static files..."
python manage.py collectstatic --noinput --clear
fi
fi
echo "[entrypoint] starting role=$ROLE: $*"
exec "$@"
+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
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env python
"""Django management entry point."""
import os
import sys
def main() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Django konnte nicht importiert werden. Ist die venv aktiv?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
+17
View File
@@ -0,0 +1,17 @@
[tool.ruff]
line-length = 100
target-version = "py312"
extend-exclude = ["migrations", "staticfiles", "media"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "DJ", "S"]
ignore = ["E501", "S101"]
[tool.ruff.lint.per-file-ignores]
"**/tests/**" = ["S"]
"**/settings/**" = ["S105", "S106"]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings.dev"
python_files = ["test_*.py", "*_test.py", "tests.py"]
addopts = "--reuse-db -ra"
+8
View File
@@ -0,0 +1,8 @@
# Nur im dev-Build installieren
debugpy==1.8.7
django-debug-toolbar==4.4.6
ipython==8.29.0
ruff==0.7.4
pytest==8.3.3
pytest-django==4.9.0
factory-boy==3.3.1
+15
View File
@@ -0,0 +1,15 @@
Django==5.1.4
django-environ==0.11.2
django-celery-beat==2.7.0
django-celery-results==2.5.1
psycopg[binary]==3.2.3
gunicorn==23.0.0
celery[redis]==5.4.0
redis==5.2.0
docxtpl==0.19.0
python-docx==1.1.2
pypdf==5.1.0
argon2-cffi==23.1.0
django-axes==7.0.1
django-htmx==1.21.0
python-dateutil==2.9.0.post0
+41
View File
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Serienbrief{% endblock %}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 960px; margin: 2rem auto; padding: 0 1rem; color: #222; }
nav { display: flex; gap: 1rem; padding-bottom: 1rem; border-bottom: 1px solid #ddd; margin-bottom: 1.5rem; }
nav a { text-decoration: none; color: #0366d6; }
h1, h2 { color: #111; }
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; }
.status-pending { color: #888; }
.status-running { color: #d97706; }
.status-done { color: #059669; }
.status-failed { color: #dc2626; }
.btn { display: inline-block; padding: 0.4rem 0.8rem; background: #0366d6; color: white; border-radius: 4px; text-decoration: none; border: none; cursor: pointer; }
.messages li { padding: 0.5rem 1rem; background: #fef3c7; border-left: 4px solid #f59e0b; margin: 0.5rem 0; list-style: none; }
.log { font-family: monospace; font-size: 0.85rem; background: #f6f8fa; padding: 0.75rem; border-radius: 4px; }
.log .level-error { color: #dc2626; }
.log .level-warning { color: #d97706; }
form p { margin: 0.75rem 0; }
label { display: block; font-weight: 600; }
</style>
</head>
<body>
<nav>
<a href="{% url 'dashboard' %}">Übersicht</a>
<a href="{% url 'template-upload' %}">Neue Vorlage</a>
<a href="{% url 'job-create' %}">Neuer Serienbrief</a>
<span style="margin-left:auto">
{% if user.is_authenticated %}
{{ user.username }} · <a href="{% url 'logout' %}">Abmelden</a>
{% endif %}
</span>
</nav>
{% if messages %}<ul class="messages">{% for m in messages %}<li>{{ m }}</li>{% endfor %}</ul>{% endif %}
{% block content %}{% endblock %}
</body>
</html>
@@ -0,0 +1,25 @@
<div hx-get="{% url 'job-detail' job.id %}" hx-trigger="every 2s" hx-swap="outerHTML">
<p><strong>Vorlage:</strong> {{ job.template.name }}</p>
<p><strong>Status:</strong>
<span class="status-{{ job.status }}">{{ job.get_status_display }}</span>
</p>
<p><strong>Fortschritt:</strong> {{ job.processed_rows }} / {{ job.total_rows }}</p>
{% if job.status == "done" %}
<p><a class="btn" href="{% url 'job-download' job.id %}">PDF herunterladen</a></p>
{% endif %}
{% if job.error_message %}
<p style="color:#dc2626"><strong>Fehler:</strong> {{ job.error_message }}</p>
{% endif %}
<h3>Log</h3>
<div class="log">
{% for entry in logs %}
<div class="level-{{ entry.level }}">
[{{ entry.timestamp|date:"H:i:s" }}] {{ entry.level|upper }} {{ entry.message }}
</div>
{% empty %}
<em>Keine Einträge.</em>
{% endfor %}
</div>
</div>
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block content %}
<h1>Übersicht</h1>
<h2>Vorlagen</h2>
<a class="btn" href="{% url 'template-upload' %}">Neue Vorlage hochladen</a>
<table>
<thead><tr><th>Name</th><th>Platzhalter</th><th>Erstellt</th></tr></thead>
<tbody>
{% for t in templates %}
<tr>
<td><a href="{% url 'template-detail' t.id %}">{{ t.name }}</a></td>
<td>{{ t.placeholders|join:", " }}</td>
<td>{{ t.created_at|date:"d.m.Y H:i" }}</td>
</tr>
{% empty %}
<tr><td colspan="3"><em>Noch keine Vorlagen vorhanden.</em></td></tr>
{% endfor %}
</tbody>
</table>
<h2>Aufträge</h2>
<a class="btn" href="{% url 'job-create' %}">Neuen Serienbrief erstellen</a>
<table>
<thead><tr><th>ID</th><th>Vorlage</th><th>Status</th><th>Fortschritt</th><th>Erstellt</th></tr></thead>
<tbody>
{% for j in jobs %}
<tr>
<td><a href="{% url 'job-detail' j.id %}">{{ j.id|stringformat:"s"|slice:":8" }}…</a></td>
<td>{{ j.template.name }}</td>
<td class="status-{{ j.status }}">{{ j.get_status_display }}</td>
<td>{{ j.processed_rows }} / {{ j.total_rows }}</td>
<td>{{ j.created_at|date:"d.m.Y H:i" }}</td>
</tr>
{% empty %}
<tr><td colspan="5"><em>Noch keine Aufträge.</em></td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<h1>Auftrag {{ job.id|stringformat:"s"|slice:":8" }}…</h1>
<div hx-get="{% url 'job-detail' job.id %}"
hx-trigger="every 2s"
hx-swap="outerHTML">
{% include "mailmerge/_job_status.html" %}
</div>
<script src="https://unpkg.com/htmx.org@2.0.3" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" crossorigin="anonymous"></script>
{% endblock %}
@@ -0,0 +1,10 @@
{% 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">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn">Erstellen und starten</button>
</form>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ template.name }}</h1>
<p>{{ template.description }}</p>
<p><strong>Datei:</strong> {{ template.file.name }}</p>
<p><strong>Erkannte Platzhalter:</strong></p>
<ul>
{% for p in template.placeholders %}<li><code>{{ p }}</code></li>{% empty %}<li><em>Keine gefunden.</em></li>{% endfor %}
</ul>
<a class="btn" href="{% url 'job-create' %}">Serienbrief mit dieser Vorlage erstellen</a>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block content %}
<h1>Neue Vorlage hochladen</h1>
<p>DOCX-Datei mit Platzhaltern wie <code>&#123;&#123; vorname &#125;&#125;</code>, <code>&#123;&#123; nachname &#125;&#125;</code>, … Die Spaltennamen der späteren CSV müssen mit den Platzhaltern übereinstimmen.</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn">Hochladen</button>
</form>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}Anmelden{% endblock %}
{% block content %}
<h1>Anmelden</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn">Anmelden</button>
</form>
{% endblock %}