30 KiB
Serienbrief – DOCX‑Mailmerge mit Django, Celery & LibreOffice
Web‑Anwendung zur Erstellung personalisierter Serienbriefe aus DOCX‑Vorlagen und CSV‑Empfängerlisten. Ergebnis ist ein zusammengeführtes PDF mit einem Brief pro Empfänger:in.
Zielumgebung: internes LAN, hinter zentralem Reverse‑Proxy (TLS‑Terminierung extern). Produktionsnah konzipiert: Hardening, Resource‑Limits, Healthchecks, Backup‑Job.
Inhaltsverzeichnis
- Funktionsumfang
- Architekturübersicht
- Tech‑Stack
- Komponenten im Detail
- Verzeichnisstruktur
- Datenfluss eines Serienbrief‑Jobs
- Single‑Letter‑Preview
- Datenmodell
- Sicherheitskonzept
- Netzwerk & Ports
- Volumes & Persistenz
- Konfiguration
- Inbetriebnahme
- Entwicklung in VS Code
- Tests
- Betrieb & Wartung
- Datenschutz & Compliance
- Bekannte Einschränkungen & Roadmap
Funktionsumfang
- DOCX‑Vorlagen mit Jinja‑artigen Platzhaltern (
{{ feldname }}) via docxtpl - CSV‑Upload als Empfängerliste (UTF‑8, Komma‑Trenner)
- Single‑Letter‑Preview vor dem Komplett‑Render: rendert nur die erste CSV‑Zeile synchron als PDF und liefert es inline aus — kein Job, keine Persistenz, kein Queue‑Roundtrip
- Asynchrone Verarbeitung des Komplett‑Jobs über Celery — UI bleibt responsiv, Status‑Polling per HTMX
- DOCX → PDF Konversion mit headless LibreOffice
- Zusammenführung aller Einzel‑PDFs via pypdf
- Authentifizierung über Djangos Auth‑System, Brute‑Force‑Schutz mit django‑axes
- Logout per POST‑Formular (Django 5 Pflicht)
- Geschütztes Ausliefern generierter PDFs via X‑Accel‑Redirect (Nginx)
- Audit‑Log pro Job (Status‑Wechsel, Fehler, abgearbeitete Zeilen)
- Tägliches Backup (pg_dump + Media‑Tar, 14 Tage Retention)
- Test‑Suite mit pytest‑django (Service‑Unit, View‑Layer, Integration mit echtem LibreOffice) — siehe TEST.md
Architekturübersicht
┌──────────────────────────────────────────────┐
│ Externer LAN‑Proxy (Nginx, TLS‑Terminierung)│
│ https://serienbrief.lan │
└────────────────────┬─────────────────────────┘
│ HTTP (intern)
▼
┌───────────────────────────────────────────────────────────┐
│ Compose‑Stack »serienbrief« │
│ │
│ ┌───────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ nginx │──▶│ web (Django) │──▶│ db (Postgres 16) │ │
│ │ App‑Proxy │ │ Gunicorn │ │ │ │
│ └─────┬─────┘ └──────┬───────┘ └──────────────────┘ │
│ │ X‑Accel │ enqueue │
│ │ static/media ▼ │
│ │ ┌──────────────┐ ┌──────────────────┐ │
│ └────────▶│ redis (Broker)│◀─│ worker (Celery) │ │
│ └──────────────┘ │ LibreOffice+pypdf│ │
│ └──────────────────┘ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ beat (Cron) │ │ backup (pg_dump) │ │
│ └──────────────┘ └──────────────────┘ │
└───────────────────────────────────────────────────────────┘
Zwei Bridge‑Netze trennen Verantwortlichkeiten:
- frontend – nur
nginx↔web. Einziges Netz mit Port‑Bind nach außen. - backend –
web,worker,beat,db,redis,backup. Kein direktes Internet‑/LAN‑Exposing.
Tech‑Stack
| Schicht | Komponente | Version |
|---|---|---|
| Host‑OS | Ubuntu Server LTS | 22.04 / 24.04 |
| Container Runtime | Docker Engine | 27.x+ |
| Orchestrierung | Docker Compose v2 | — |
| App‑Framework | Django | 5.1.4 |
| WSGI (Prod) | Gunicorn | — |
| Task Queue | Celery | 5.4 |
| Broker / Result Backend | Redis | 7‑alpine |
| Datenbank | PostgreSQL | 16‑alpine |
| DOCX‑Templating | docxtpl | 0.19 |
| DOCX→PDF | LibreOffice (headless) | bundled in Image |
| PDF‑Merge | pypdf | 5.1 |
| App‑Proxy | Nginx | 1.27‑alpine |
| Auth‑Hardening | django‑axes | aktuell |
| UI‑Interaktion | django‑htmx | aktuell |
| DB‑Adapter | psycopg | 3.2.3 (binary) |
| Python | CPython | 3.12 (slim‑bookworm) |
| Tests | pytest, pytest‑django | 8.3 / 4.9 |
Komponenten im Detail
nginx – App‑interner Reverse‑Proxy
- Terminiert kein TLS (übernimmt der externe Proxy)
- Liefert Static‑ und Media‑Dateien direkt aus den Volumes aus
- Setzt
X‑Accel‑Redirectfür Auth‑geschützte PDFs um: Django prüft die Berechtigung, Nginx streamt die Datei ohne Python im Pfad - Hört auf
${APP_BIND_IP:-127.0.0.1}:${APP_BIND_PORT:-8080}(Default Loopback) - Healthcheck gegen
/healthz
web – Django + Gunicorn
- Rolle
web(perROLE‑Env unterschieden, gleiches Image wie worker/beat) - Stateless, read‑only Root‑FS,
/tmpals tmpfs - Settings‑Split:
base.py,dev.py,production.py - Entrypoint wartet auf DB‑Readiness, führt
migrateund (in Prod)collectstaticaus - Healthcheck via
/healthz - Synchroner Preview‑Endpoint (
/jobs/preview/) rendert über LibreOffice direkt imweb‑Container — kein Celery‑Roundtrip
worker – Celery
- Verarbeitet
process_mailmerge_job‑Tasks - LibreOffice‑User‑Profil im tmpfs (
/tmp/lo_profile_<task_id>) → keine Leaks zwischen Jobs --concurrency=2,--max-tasks-per-child=50(RAM‑Leak‑Schutz bei LibreOffice)
beat – Celery Scheduler
- Nutzt
DatabaseSchedulervon django‑celery‑beat - Periodic Tasks werden über Django‑Migrationen registriert (reproduzierbar, single source of truth)
- Aktiv:
- Retention‑Cleanup (
mailmerge.cleanup_expired_jobs) — täglich 03:15 (CELERY_TIMEZONE)
- Retention‑Cleanup (
db – PostgreSQL 16
- Eigenes Volume
postgres_data - Passwort via Docker‑Secret (
/run/secrets/postgres_password) - Init‑Skripte unter
./postgres/init(z.B. zusätzliche Extensions) shm_size: 256mbfür komplexere Queries
redis – Broker & Result Backend
- Passwortgeschützt (
REDIS_PASSWORDaus.env) - AOF persistence + RDB snapshot (
save 900 1) maxmemory 256mbmitallkeys‑lru
backup – nightly pg_dump + media tar
- Custom‑Loop mit
sleep 86400(kein systemd nötig) - pg_dump im Custom‑Format (
-Fc) →pg_restore‑kompatibel - Media‑Tar aus
media_files‑Volume (read‑only Mount) - Retention 14 Tage (konfigurierbar)
- Schreibt nach
./backupsauf dem Host
Verzeichnisstruktur
serienbrief/
├── app/ # Django‑Projekt
│ ├── Dockerfile # Multi‑Stage: builder → runtime‑base → runtime / dev
│ ├── entrypoint.sh # wait_for_db → migrate → collectstatic → exec CMD
│ ├── manage.py
│ ├── requirements.txt
│ ├── requirements-dev.txt # debugpy, ipython, ruff, pytest, debug-toolbar
│ ├── config/ # Project package
│ │ ├── celery.py
│ │ ├── urls.py
│ │ ├── wsgi.py
│ │ └── settings/
│ │ ├── base.py
│ │ ├── dev.py
│ │ └── production.py
│ ├── mailmerge/ # Hauptapp
│ │ ├── models.py # LetterTemplate, MailMergeJob, JobLogEntry
│ │ ├── views.py # incl. job_preview (synchron)
│ │ ├── admin.py
│ │ ├── forms.py
│ │ ├── urls.py # incl. "jobs/preview/"
│ │ ├── tasks.py # Celery tasks
│ │ ├── services/
│ │ │ ├── docx_renderer.py # docxtpl + LibreOffice
│ │ │ ├── pdf_merge.py # pypdf
│ │ │ └── preview.py # Single-Letter-Preview (synchron)
│ │ ├── tests/ # pytest-django Tests
│ │ │ ├── conftest.py
│ │ │ ├── test_preview_service.py
│ │ │ ├── test_preview_view.py
│ │ │ └── test_preview_integration.py # marker: integration
│ │ └── management/commands/
│ │ └── wait_for_db.py
│ ├── pyproject.toml # pytest config (markers, addopts)
│ └── templates/
│ ├── base.html # Logout via POST-Form (Django 5)
│ ├── registration/login.html
│ └── mailmerge/
│ └── job_form.html # Form + Preview-Button + iframe
├── nginx/
│ ├── nginx.conf
│ └── conf.d/serienbrief.conf
├── postgres/init/ # SQL init scripts
├── secrets/
│ └── postgres_password.txt # 0600, NICHT in Git
├── backups/ # nightly dumps + media tars
├── docker-compose.yml # Produktion
├── docker-compose.override.yml # Dev (automatisch gemerged)
├── .env # NICHT in Git
├── .env.example
├── .devcontainer/ # VS Code Dev Container
├── .vscode/ # launch / tasks / settings / extensions
└── README.md
Datenfluss eines Serienbrief‑Jobs
- Upload Vorlage:
LetterTemplatemit DOCX‑Datei wird angelegt (Admin oder UI) - Job‑Erstellung: User wählt Vorlage + lädt CSV hoch im
job_form.html - Optional: Preview — Button »Vorschau (erste Zeile)« löst synchronen POST an
/jobs/preview/aus, Ergebnis‑PDF erscheint inline im iframe (siehe nächster Abschnitt) - Submit: Klick auf »Job starten« →
MailMergeJob(status=queued)angelegt - Enqueue: View löst
process_mailmerge_job.delay(job.pk)aus - Worker holt Task:
- Liest CSV (UTF‑8, validiert Header gegen Platzhalter)
- Iteriert Zeilen → docxtpl rendert pro Zeile eine temporäre DOCX in
/tmp - LibreOffice (
soffice --headless --convert-to pdf …) erzeugt pro DOCX ein PDF - pypdf merged alle PDFs in eine Ausgabedatei unter
media_files JobLogEntrys pro Schritt; Statusrunning → donebzw.failed
- UI‑Polling: HTMX fragt
/jobs/<id>/status/alle ~2 s ab → Fortschrittsbalken - Download: Nutzer klickt Download → View prüft Auth → Antwort enthält
X‑Accel‑Redirect: /protected/…→ Nginx streamt das PDF
Single‑Letter‑Preview
Vor dem Komplett‑Render kann eine Vorschau auf Basis der ersten CSV‑Zeile erstellt werden. Spart Zeit bei der Template‑Entwicklung und verhindert, dass fehlerhafte Templates 200 PDFs ungenutzt produzieren.
Design‑Entscheidungen
| Aspekt | Wahl | Begründung |
|---|---|---|
| Ausführung | synchron im web‑Container |
Renderzeit < 5 s, kein Celery‑Roundtrip nötig, kein State |
| Persistenz | keine — kein MailMergeJob, keine Datei auf media_files |
Preview ist ephemer, hinterlässt keine DSGVO‑relevanten Artefakte |
| Render‑Pfad | tmpfs /tmp (size‑limitiert, kein Disk‑I/O) |
LibreOffice‑User‑Profil und Zwischen‑DOCX werden mit Request‑Ende verworfen |
| Validierung | strikt: fehlende Spalten → PreviewError |
Im Komplett‑Job aktuell warn‑only — Preview‑Pfad gibt klare Fehlermeldung ins UI |
| Auth | @login_required, POST‑only |
gleiche Schutzklasse wie Komplett‑Job |
| Response | Content‑Type: application/pdf, Content‑Disposition: inline |
iframe im Form zeigt das PDF direkt |
Response‑Header
Die Preview‑View setzt Diagnose‑Header, die das Front‑End auswertet:
X-Preview-Placeholders— Komma‑Liste der im Template gefundenen PlatzhalterX-Preview-Extra-Columns— Komma‑Liste der CSV‑Spalten, die das Template nicht verwendet (UI zeigt Hinweis)
Fehlerfälle
| Auslöser | Verhalten |
|---|---|
| Form ungültig (fehlende Datei, falsche Extension) | Formular wird mit Django‑Form‑Errors neu gerendert |
PreviewError (z.B. Spalte fehlt) |
Formular mit preview_error‑Message neu gerendert |
| LibreOffice‑Crash / Timeout | Formular mit generischer Fehlermeldung; Stacktrace im Server‑Log |
Datenmodell
LetterTemplate
├── id, name (unique), description
├── docx_file (FileField → media/templates/)
├── created_by (FK User), created_at, updated_at
└── is_active
MailMergeJob
├── id, template (FK LetterTemplate)
├── csv_file (FileField → media/jobs/<id>/input.csv)
├── output_pdf (FileField → media/jobs/<id>/output.pdf)
├── status: queued | running | done | failed
├── total_rows, processed_rows
├── created_by, created_at, started_at, finished_at
└── error_message
JobLogEntry
├── id, job (FK MailMergeJob, related_name="log_entries")
├── level: info | warning | error
├── message, created_at
Sicherheitskonzept
Im Sinne der Space‑Vorgabe »Security‑by‑Default«:
Container‑Hardening
no-new-privileges: truebei allen Servicesread_only: truefürweb,worker,beat(writable nur via tmpfs/tmp)- Non‑root‑User
app(UID/GID 1000, anpassbar per Build‑Args) - Minimales Image (
python:3.12-slim-bookworm), Multi‑Stage‑Build trennt Build‑ und Laufzeit‑Dependencies - Ressourcen‑Limits (memory + cpus) auf allen Services
tmpfs‑Größen explizit limitiert (size=256M, etc.)
Netzwerk
- Zwei isolierte Bridges (
frontend,backend) - Nur
nginxbindet nach außen, default auf Loopback - DB und Redis ausschließlich auf
backend - Externer Proxy übernimmt TLS, HSTS, ggf. IP‑Allowlist
Secrets
- Postgres‑Passwort via Docker‑Secret (
./secrets/postgres_password.txt, Mode 0600) - Redis‑Passwort + Django‑Secret‑Key in
.env(nicht in Git) .envundsecrets/sind in.gitignoreausgenommen
Anwendungs‑Sicherheit
- django‑axes: Lockout nach n Fehlversuchen
- CSRF aktiv, Trusted Origins explizit gepflegt
SECURE_PROXY_SSL_HEADERgesetzt, damit Django HTTPS hinter dem externen Proxy korrekt erkenntUSE_X_FORWARDED_HOST=True- X‑Accel‑Redirect statt direktem File‑Download → Nginx liefert nur, was Django freigibt
- Session‑Cookies
HttpOnly,SameSite=Lax,Secure(in Prod)
Defense‑in‑Depth (Empfehlungen)
- AppArmor‑Profile pro Container (z.B. via
--security-opt apparmor=...) – aktuell nicht aktiv - seccomp‑Profile (Default reicht, custom optional)
- Image‑Scanning (
trivy,grype) in CI integrieren - Renovate/Dependabot für Base‑Image und Python‑Deps
Netzwerk & Ports
| Port (Host) | Service | Sichtbarkeit | Zweck |
|---|---|---|---|
| 8080 | nginx | ${APP_BIND_IP} |
HTTP für externen Proxy |
| 8000 | web | nur Dev | Django runserver |
| 5678 | web (Dev) | nur Dev | debugpy |
| 5432 | db | nur Dev | Postgres (Tools wie pgAdmin) |
| 6379 | redis | nur Dev | Redis‑CLI |
In Produktion bleiben nur Port 8080 (intern an externen Proxy) erreichbar.
Volumes & Persistenz
| Volume | Inhalt | Backup? |
|---|---|---|
postgres_data |
Postgres‑Daten | pg_dump nightly |
redis_data |
AOF + RDB | nein (Broker‑State, regenerierbar) |
static_files |
Generierte Static‑Files | nein (regenerierbar via collectstatic) |
media_files |
Hochgeladene Templates, CSVs, generierte PDFs | tar nightly |
nginx_cache |
Nginx Cache | nein |
nginx_run |
Nginx PID/Socket | nein |
Bind‑Mount im Dev: ./app:/app für Live‑Reload.
Konfiguration
.env (aus .env.example abgeleitet) — Auszug:
# App
APP_VERSION=1.0.0
APP_BIND_IP=127.0.0.1
APP_BIND_PORT=8080
APP_UID=1000
APP_GID=1000
# Django
DJANGO_SECRET_KEY=<openssl rand -base64 64>
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=serienbrief.lan,127.0.0.1,localhost
CSRF_TRUSTED_ORIGINS=https://serienbrief.lan
SECURE_PROXY_SSL_HEADER=HTTP_X_FORWARDED_PROTO,https
USE_X_FORWARDED_HOST=True
# Postgres
POSTGRES_DB=serienbrief
POSTGRES_USER=serienbrief
DATABASE_URL=postgres://serienbrief:<HEX-PWD>@db:5432/serienbrief
# Redis
REDIS_PASSWORD=<openssl rand -hex 32>
CELERY_BROKER_URL=redis://:<REDIS_PASSWORD>@redis:6379/0
CELERY_RESULT_BACKEND=redis://:<REDIS_PASSWORD>@redis:6379/1
# Business
JOB_RETENTION_DAYS=30
Wichtig:
secrets/postgres_password.txtmuss exakt denselben Wert enthalten wie inDATABASE_URL- Datei darf kein abschließendes Newline enthalten — Generierung:
openssl rand -hex 32 | tr -d '\n' > secrets/postgres_password.txt && chmod 600 secrets/postgres_password.txt - Hex statt Base64, weil Base64‑
=URL‑Parsing inDATABASE_URLbricht
Inbetriebnahme
Voraussetzungen
- Ubuntu 22.04/24.04 LTS, aktueller Patch‑Stand
- Docker Engine 27.x+ und Compose v2 (apt‑Pakete
docker-ce+docker-compose-plugin, nicht Snap) - Host‑User in
docker‑Gruppe (sudo usermod -aG docker $USER, dann Re‑Login)
Erststart
# 1. Repo holen / entpacken in z.B. ~/projekte/serienbrief
cd ~/projekte/serienbrief
# 2. Konfiguration aus Template ableiten
cp .env.example .env
$EDITOR .env # Werte ausfüllen, siehe oben
# 3. Postgres‑Passwort als Datei
openssl rand -hex 32 | tr -d '\n' > secrets/postgres_password.txt
chmod 600 secrets/postgres_password.txt
# → diesen Wert auch in DATABASE_URL eintragen
# 4. Image bauen
docker compose build
# 5. Hochfahren
docker compose up -d
# 6. Status & Logs
docker compose ps -a
docker compose logs -f web
# 7. Superuser anlegen
docker compose exec web python manage.py createsuperuser
Externen Reverse-Proxy konfigurieren
Der Stack liefert intern HTTP auf ${APP_BIND_IP}:${APP_BIND_PORT} aus (Default 127.0.0.1:8080). Der externe Proxy übernimmt TLS-Terminierung und die nach außen sichtbare Domain.
Voraussetzungen für externen Zugriff
Wenn der Proxy auf einem anderen Host läuft (typisch bei Nginx Proxy Manager als eigener Container/VM), muss APP_BIND_IP von 127.0.0.1 auf eine LAN-erreichbare Adresse umgestellt werden:
# LAN-IP des Docker-Hosts ermitteln
ip -4 -br a | grep -v lo
# In .env eintragen (Beispiel)
sed -i 's/^APP_BIND_IP=.*/APP_BIND_IP=192.168.10.42/' .env
docker compose up -d
Alternativ 0.0.0.0 für alle Interfaces — die explizite IP ist sicherheitstechnisch sauberer.
Firewall absichern, damit nur der Proxy-Host auf 8080 zugreifen darf:
sudo ufw allow from <proxy-host-ip> to any port 8080 proto tcp comment 'reverse proxy'
sudo ufw deny 8080
Django-Seite anpassen
Die externe Domain muss in .env eingetragen sein, sonst lehnt Django die Requests ab (400 Bad Request bzw. 403 CSRF):
DJANGO_ALLOWED_HOSTS=serienbrief.example.lan,localhost,127.0.0.1
CSRF_TRUSTED_ORIGINS=https://serienbrief.example.lan,http://localhost:8080,http://127.0.0.1:8080
Nach Änderung docker compose restart web.
Variante A: Nginx Proxy Manager (NPM)
Im NPM-Webinterface unter Proxy Hosts → Add Proxy Host:
| Feld | Wert |
|---|---|
| Domain Names | serienbrief.example.lan |
| Scheme | http |
| Forward Hostname / IP | LAN-IP des Docker-Hosts (z.B. 192.168.10.42) |
| Forward Port | 8080 |
| Cache Assets | aus |
| Block Common Exploits | an |
| Websockets Support | an (HTMX nutzt nur AJAX, schadet aber nicht) |
Im Tab Custom Locations oder Advanced zusätzlich:
client_max_body_size 50M;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
NPM setzt Host, X-Forwarded-For und Standard-Header bereits selbst. Pflicht ist nur X-Forwarded-Proto $scheme, weil Djangos SECURE_PROXY_SSL_HEADER darauf basiert.
TLS-Zertifikat (Let's Encrypt für interne Domains via DNS-Challenge, oder eigenes CA-Cert) im Tab SSL zuweisen.
Variante B: Generisches Nginx / OpenResty
server {
listen 443 ssl http2;
server_name serienbrief.example.lan;
ssl_certificate /etc/ssl/lan/serienbrief.crt;
ssl_certificate_key /etc/ssl/lan/serienbrief.key;
client_max_body_size 50M; # für CSV/DOCX-Uploads
location / {
proxy_pass http://<docker-host-ip>:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_redirect off;
}
}
Diagnose bei 502 Bad Gateway
Vom Proxy-Host aus testen:
# TCP-Connect — "succeeded" erwartet
nc -zv <docker-host-ip> 8080
# HTTP-Request — 200/302 erwartet
curl -v -H "Host: serienbrief.example.lan" http://<docker-host-ip>:8080/
Typische Ursachen:
Connection refused→APP_BIND_IPsteht noch auf127.0.0.1Timeout→ Firewall blockt- 502 ohne Connect-Fehler → falsche Upstream-IP im Proxy-Eintrag
Entwicklung in VS Code
.devcontainer/devcontainer.jsonreferenziert den Compose‑Stack mit Dev‑Override.vscode/launch.jsonenthält Configs:- Django: runserver (attach) → debugpy auf 5678
- Django: pytest → führt Tests im Container aus
.vscode/tasks.jsonfürmakemigrations,migrate,shell,collectstatic.vscode/extensions.jsonempfiehlt: Python, Pylance, Django, Docker, Even Better TOML, Ruff
Workflow
# Stack hochfahren (Override greift automatisch)
docker compose up -d
# Code‑Änderungen werden via Bind‑Mount sofort sichtbar
# runserver reloaded automatisch
# Debugging: F5 in VS Code → attach an debugpy:5678
Hinweis zum Compose‑Aufruf: Niemals docker compose -f docker-compose.yml up -d verwenden — das ignoriert die Override‑Datei und führt zu Netzwerk‑Race‑Conditions sowie zur falschen UID im Container. Compose merged docker-compose.yml + docker-compose.override.yml automatisch, wenn keine -f‑Flags gesetzt sind.
Wichtige Unterschiede Dev ↔ Prod
| Aspekt | Dev | Prod |
|---|---|---|
| WSGI | runserver |
Gunicorn |
| Code | Bind‑Mount ./app:/app |
im Image |
| Healthcheck | deaktiviert | aktiv (/healthz) |
read_only |
aus | an |
DJANGO_DEBUG |
True | False |
collectstatic |
auto im Entrypoint | im Entrypoint |
target |
dev (mit debugpy, ipython, pytest) |
runtime |
Tests
Die Test‑Suite liegt unter app/mailmerge/tests/ und ist in drei Schichten organisiert:
| Layer | Datei | Was wird geprüft | LibreOffice |
|---|---|---|---|
| Service‑Unit | test_preview_service.py |
CSV‑Parsing, Header‑Validierung, Result‑Aufbau, Fehlerpfade | gemockt |
| View | test_preview_view.py |
HTTP‑Layer: Auth, Method‑Restriction, Form‑Validation, Response‑Header, Fehler‑Rendering | gemockt |
| Integration | test_preview_integration.py |
End‑to‑End mit echtem soffice --headless |
real (Marker integration) |
Pytest‑Konfiguration (app/pyproject.toml):
--reuse-dbfür schnelle Re‑Runs- Default‑Selektor
-m 'not integration'— Integration‑Tests laufen nur auf Anforderung python_files = ["test_*.py", "*_test.py", "tests.py"]
Schnellstart:
# Erster Lauf (DB anlegen)
docker compose exec web pytest mailmerge/tests/ -v --create-db
# Normale Re‑Runs
docker compose exec web pytest mailmerge/tests/ -v
# Inkl. Integration (langsamer, benötigt LibreOffice im Image)
docker compose exec web pytest mailmerge/tests/ -m integration -v
Detaillierte Anleitung inkl. Coverage, Fixtures, Debugging und CI‑Integration: siehe TEST.md.
Betrieb & Wartung
Häufige Operationen
# Logs eines einzelnen Services
docker compose logs -f worker
# In den Container schauen
docker compose exec web bash
docker compose exec db psql -U serienbrief
# Migration nach Modelländerung
docker compose exec web python manage.py makemigrations
docker compose exec web python manage.py migrate
# Statics neu generieren
docker compose exec web python manage.py collectstatic --noinput
# Celery‑Queue Status
docker compose exec worker celery -A config inspect active
docker compose exec worker celery -A config inspect reserved
Updates
# Code aktualisieren
git pull
# Image neu bauen
docker compose build --pull
# Mit Zero‑Downtime nicht möglich (single‑host) → kurzer Restart
docker compose up -d
# DB‑Migrationen
docker compose exec web python manage.py migrate
Backup & Restore
# Manuelles Backup erzwingen
docker compose restart backup
docker compose logs --tail=50 backup
# Restore (Beispiel)
docker compose exec -T db pg_restore -U serienbrief -d serienbrief --clean < backups/db_20260521_030000.dump
Monitoring (Empfehlung, nicht enthalten)
docker statsfür ad‑hoc Container‑Ressourcen- Healthcheck‑Status:
docker compose ps - Container‑Logs via zentralem syslog/journald (Docker
json-filerotiert 5×10 MB) - Optional: Prometheus + cAdvisor + node_exporter, Loki für Logs
Datenschutz & Compliance
- Rechtsgrundlage: Verarbeitung personenbezogener Adress‑/Empfängerdaten je nach Anwendungsfall Art. 6 Abs. 1 lit. b/c/e DSGVO; bei besonderen Datenkategorien (Art. 9 DSGVO) gesonderte Rechtsgrundlage erforderlich
- Speicherort: Innerbetrieblicher Server, kein Cloud‑Transfer
- Zugriffssteuerung: Nur authentifizierte User; Trennung Templates/Jobs aktuell flach — Mandanten‑/Rollenmodell ggf. ergänzen (siehe Roadmap)
- Aufbewahrung: Jobs + CSVs + Output‑PDFs werden nach
JOB_RETENTION_DAYS(default 30) automatisch gelöscht. Der Retention‑Cleanup läuft als Celery‑Beat‑Periodic‑Task einmal täglich. Manuelle Ausführung/Dry‑Run:docker compose exec web python manage.py cleanup_jobs --dry-run - Audit: Jeder Job hat ein
JobLogEntry‑Protokoll (wer, wann, was, Fehler) - Backup‑Verschlüsselung: Bind‑Mount
./backupsliegt aktuell unverschlüsselt — für produktiven Einsatz LUKS/Filesystem‑Encryption am Host dringend empfohlen - DSFA‑Relevanz: Bei Verarbeitung besonderer Datenkategorien oder umfangreicher Profilbildung ist eine Datenschutz‑Folgenabschätzung gemäß Art. 35 DSGVO durchzuführen, bevor produktiver Echtbetrieb startet
Bekannte Einschränkungen & Roadmap
Tech‑Debt
- Postgres‑Passwort doppelt gehalten (
DATABASE_URL+ Secret‑Datei) → refactor zu Single Source via Entrypoint, derDATABASE_URLaus*_FILE‑Env zusammenbaut - Keine LDAP/AD‑Anbindung — aktuell lokale Django‑User
- CSV‑Header‑Validierung im Haupt‑Job‑Flow warn‑only (im Preview‑Pfad bereits strikt)
- Image enthält LibreOffice (~400 MB) — könnte in separates Worker‑Image ausgelagert werden
- Tests decken Preview + Retention ab — Haupt‑Job‑Flow (Celery‑Task
run_mailmerge, Status‑Polling, X‑Accel‑Redirect) noch ohne automatisierte Tests
Erledigt
- Single‑Letter‑Preview im Job‑Erstellungs‑Flow
- Test‑Suite mit pytest‑django (3 Layer)
- Logout via POST‑Form (Django 5)
- Retention‑Cleanup als periodic task (
mailmerge.cleanup_expired_jobs, täglich)
Roadmap (kurzfristig)
- CSV‑Validierung strict mit Pflichtfeldliste pro Template (auch im Haupt‑Flow)
- Tests für Celery‑Task
run_mailmerge(mitCELERY_TASK_ALWAYS_EAGER) - Mandanten‑/Berechtigungsmodell (Abteilung X sieht nur eigene Templates)
- LDAP‑Auth über
django‑auth‑ldap - Postgres‑Passwort als Single Source of Truth (
*_FILE‑Env)
Roadmap (mittelfristig)
- AppArmor‑Profile pro Container
- Image‑Scanning in CI (Trivy)
- Separate Worker‑Image ohne Web‑Dependencies
- AES‑Verschlüsselung der hochgeladenen CSVs at rest
- Optional: Konvertierung über Gotenberg statt LibreOffice‑im‑Image (cleaneres Separation of Concerns)
Lizenz & Verantwortlichkeit
Internes Projekt. Verantwortlich für Konzept & Betrieb: Datenschutzkoordination / IT‑Systemadministration.
Keine externe Lizenz festgelegt — Verteilung ausschließlich innerbetrieblich.