2026-05-22 09:15:59 +02:00
2026-05-21 17:54:38 +02:00
2026-05-21 17:54:38 +02:00
2026-05-22 09:14:31 +02:00
2026-05-21 10:36:16 +02:00
2026-05-21 10:36:16 +02:00
2026-05-22 07:59:03 +02:00
2026-05-21 17:54:38 +02:00
2026-05-21 17:54:38 +02:00
2026-05-22 09:15:59 +02:00
2026-05-22 07:59:03 +02:00
2026-05-22 09:14:31 +02:00
2026-05-22 09:15:59 +02:00
2026-05-22 08:40:04 +02:00

Serienbrief DOCXMailmerge mit Django, Celery & LibreOffice

WebAnwendung zur Erstellung personalisierter Serienbriefe aus DOCXVorlagen und CSVEmpfängerlisten. Ergebnis ist ein zusammengeführtes PDF mit einem Brief pro Empfänger:in.

Zielumgebung: internes LAN, hinter zentralem ReverseProxy (TLSTerminierung extern). Produktionsnah konzipiert: Hardening, ResourceLimits, Healthchecks, BackupJob.


Inhaltsverzeichnis

  1. Funktionsumfang
  2. Architekturübersicht
  3. TechStack
  4. Komponenten im Detail
  5. Verzeichnisstruktur
  6. Datenfluss eines SerienbriefJobs
  7. SingleLetterPreview
  8. Datenmodell
  9. Sicherheitskonzept
  10. Netzwerk & Ports
  11. Volumes & Persistenz
  12. Konfiguration
  13. Inbetriebnahme
  14. Entwicklung in VS Code
  15. Tests
  16. Betrieb & Wartung
  17. Datenschutz & Compliance
  18. Bekannte Einschränkungen & Roadmap

Funktionsumfang

  • DOCXVorlagen mit Jinjaartigen Platzhaltern ({{ feldname }}) via docxtpl
  • CSVUpload als Empfängerliste (UTF8, KommaTrenner)
  • SingleLetterPreview vor dem KomplettRender: rendert nur die erste CSVZeile synchron als PDF und liefert es inline aus — kein Job, keine Persistenz, kein QueueRoundtrip
  • Asynchrone Verarbeitung des KomplettJobs über Celery — UI bleibt responsiv, StatusPolling per HTMX
  • DOCX → PDF Konversion mit headless LibreOffice
  • Zusammenführung aller EinzelPDFs via pypdf
  • Authentifizierung über Djangos AuthSystem, BruteForceSchutz mit djangoaxes
  • Logout per POSTFormular (Django 5 Pflicht)
  • Geschütztes Ausliefern generierter PDFs via XAccelRedirect (Nginx)
  • AuditLog pro Job (StatusWechsel, Fehler, abgearbeitete Zeilen)
  • Tägliches Backup (pg_dump + MediaTar, 14 Tage Retention)
  • TestSuite mit pytestdjango (ServiceUnit, ViewLayer, Integration mit echtem LibreOffice) — siehe TEST.md
  • ProductionAnleitung mit GoLiveCheckliste — siehe DEPLOYMENT.md

Architekturübersicht

                  ┌──────────────────────────────────────────────┐
                  │   Externer LANProxy (Nginx, TLSTerminierung)│
                  │   https://serienbrief.lan                    │
                  └────────────────────┬─────────────────────────┘
                                       │ HTTP (intern)
                                       ▼
        ┌───────────────────────────────────────────────────────────┐
        │                ComposeStack »serienbrief«                │
        │                                                           │
        │  ┌───────────┐   ┌──────────────┐   ┌──────────────────┐  │
        │  │ nginx     │──▶│ web (Django) │──▶│ db (Postgres 16) │  │
        │  │ AppProxy │   │ Gunicorn     │   │                  │  │
        │  └─────┬─────┘   └──────┬───────┘   └──────────────────┘  │
        │        │ XAccel        │ enqueue                         │
        │        │ static/media   ▼                                 │
        │        │         ┌──────────────┐   ┌──────────────────┐  │
        │        └────────▶│ redis (Broker)│◀─│ worker (Celery)  │  │
        │                  └──────────────┘   │ LibreOffice+pypdf│  │
        │                                     └──────────────────┘  │
        │                  ┌──────────────┐   ┌──────────────────┐  │
        │                  │ beat (Cron)  │   │ backup (pg_dump) │  │
        │                  └──────────────┘   └──────────────────┘  │
        └───────────────────────────────────────────────────────────┘

Zwei BridgeNetze trennen Verantwortlichkeiten:

  • frontend nur nginxweb. Einziges Netz mit PortBind nach außen.
  • backend web, worker, beat, db, redis, backup. Kein direktes Internet/LANExposing.

TechStack

Schicht Komponente Version
HostOS Ubuntu Server LTS 22.04 / 24.04
Container Runtime Docker Engine 27.x+
Orchestrierung Docker Compose v2
AppFramework Django 5.1.4
WSGI (Prod) Gunicorn
Task Queue Celery 5.4
Broker / Result Backend Redis 7alpine
Datenbank PostgreSQL 16alpine
DOCXTemplating docxtpl 0.19
DOCX→PDF LibreOffice (headless) bundled in Image
PDFMerge pypdf 5.1
AppProxy Nginx 1.27alpine
AuthHardening djangoaxes aktuell
UIInteraktion djangohtmx aktuell
DBAdapter psycopg 3.2.3 (binary)
Python CPython 3.12 (slimbookworm)
Tests pytest, pytestdjango 8.3 / 4.9

Komponenten im Detail

nginx Appinterner ReverseProxy

  • Terminiert kein TLS (übernimmt der externe Proxy)
  • Liefert Static und MediaDateien direkt aus den Volumes aus
  • Setzt XAccelRedirect für Authgeschü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 (per ROLEEnv unterschieden, gleiches Image wie worker/beat)
  • Stateless, readonly RootFS, /tmp als tmpfs
  • SettingsSplit: base.py, dev.py, production.py
  • Entrypoint wartet auf DBReadiness, führt migrate und (in Prod) collectstatic aus
  • Healthcheck via /healthz
  • Synchroner PreviewEndpoint (/jobs/preview/) rendert über LibreOffice direkt im webContainer — kein CeleryRoundtrip

worker Celery

  • Verarbeitet process_mailmerge_jobTasks
  • LibreOfficeUserProfil im tmpfs (/tmp/lo_profile_<task_id>) → keine Leaks zwischen Jobs
  • --concurrency=2, --max-tasks-per-child=50 (RAMLeakSchutz bei LibreOffice)

beat Celery Scheduler

  • Nutzt DatabaseScheduler von djangocelerybeat
  • Periodic Tasks werden über DjangoMigrationen registriert (reproduzierbar, single source of truth)
  • Aktiv:
    • RetentionCleanup (mailmerge.cleanup_expired_jobs) — täglich 03:15 (CELERY_TIMEZONE)

db PostgreSQL 16

  • Eigenes Volume postgres_data
  • Passwort via DockerSecret (/run/secrets/postgres_password)
  • InitSkripte unter ./postgres/init (z.B. zusätzliche Extensions)
  • shm_size: 256mb für komplexere Queries

redis Broker & Result Backend

  • Passwortgeschützt (REDIS_PASSWORD aus .env)
  • AOF persistence + RDB snapshot (save 900 1)
  • maxmemory 256mb mit allkeyslru

backup nightly pg_dump + media tar

  • CustomLoop mit sleep 86400 (kein systemd nötig)
  • pg_dump im CustomFormat (-Fc) → pg_restorekompatibel
  • MediaTar aus media_filesVolume (readonly Mount)
  • Retention 14 Tage (konfigurierbar)
  • Schreibt nach ./backups auf dem Host

Verzeichnisstruktur

serienbrief/
├── app/                          # DjangoProjekt
│   ├── Dockerfile                # MultiStage: builder → runtimebase → 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 SerienbriefJobs

  1. Upload Vorlage: LetterTemplate mit DOCXDatei wird angelegt (Admin oder UI)
  2. JobErstellung: User wählt Vorlage + lädt CSV hoch im job_form.html
  3. Optional: Preview — Button »Vorschau (erste Zeile)« löst synchronen POST an /jobs/preview/ aus, ErgebnisPDF erscheint inline im iframe (siehe nächster Abschnitt)
  4. Submit: Klick auf »Job starten« → MailMergeJob(status=queued) angelegt
  5. Enqueue: View löst process_mailmerge_job.delay(job.pk) aus
  6. Worker holt Task:
    • Liest CSV (UTF8, 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; Status running → done bzw. failed
  7. UIPolling: HTMX fragt /jobs/<id>/status/ alle ~2 s ab → Fortschrittsbalken
  8. Download: Nutzer klickt Download → View prüft Auth → Antwort enthält XAccelRedirect: /protected/… → Nginx streamt das PDF

SingleLetterPreview

Vor dem KomplettRender kann eine Vorschau auf Basis der ersten CSVZeile erstellt werden. Spart Zeit bei der TemplateEntwicklung und verhindert, dass fehlerhafte Templates 200 PDFs ungenutzt produzieren.

DesignEntscheidungen

Aspekt Wahl Begründung
Ausführung synchron im webContainer Renderzeit < 5 s, kein CeleryRoundtrip nötig, kein State
Persistenz keine — kein MailMergeJob, keine Datei auf media_files Preview ist ephemer, hinterlässt keine DSGVOrelevanten Artefakte
RenderPfad tmpfs /tmp (sizelimitiert, kein DiskI/O) LibreOfficeUserProfil und ZwischenDOCX werden mit RequestEnde verworfen
Validierung strikt: fehlende Spalten → PreviewError Im KomplettJob aktuell warnonly — PreviewPfad gibt klare Fehlermeldung ins UI
Auth @login_required, POSTonly gleiche Schutzklasse wie KomplettJob
Response ContentType: application/pdf, ContentDisposition: inline iframe im Form zeigt das PDF direkt

ResponseHeader

Die PreviewView setzt DiagnoseHeader, die das FrontEnd auswertet:

  • X-Preview-Placeholders — KommaListe der im Template gefundenen Platzhalter
  • X-Preview-Extra-Columns — KommaListe der CSVSpalten, die das Template nicht verwendet (UI zeigt Hinweis)

Fehlerfälle

Auslöser Verhalten
Form ungültig (fehlende Datei, falsche Extension) Formular wird mit DjangoFormErrors neu gerendert
PreviewError (z.B. Spalte fehlt) Formular mit preview_errorMessage neu gerendert
LibreOfficeCrash / Timeout Formular mit generischer Fehlermeldung; Stacktrace im ServerLog

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 SpaceVorgabe »SecuritybyDefault«:

ContainerHardening

  • no-new-privileges: true bei allen Services
  • read_only: true für web, worker, beat (writable nur via tmpfs /tmp)
  • NonrootUser app (UID/GID 1000, anpassbar per BuildArgs)
  • Minimales Image (python:3.12-slim-bookworm), MultiStageBuild trennt Build und LaufzeitDependencies
  • RessourcenLimits (memory + cpus) auf allen Services
  • tmpfsGrößen explizit limitiert (size=256M, etc.)

Netzwerk

  • Zwei isolierte Bridges (frontend, backend)
  • Nur nginx bindet nach außen, default auf Loopback
  • DB und Redis ausschließlich auf backend
  • Externer Proxy übernimmt TLS, HSTS, ggf. IPAllowlist

Secrets

  • PostgresPasswort via DockerSecret (./secrets/postgres_password.txt, Mode 0600)
  • RedisPasswort + DjangoSecretKey in .env (nicht in Git)
  • .env und secrets/ sind in .gitignore ausgenommen

AnwendungsSicherheit

  • djangoaxes: Lockout nach n Fehlversuchen
  • CSRF aktiv, Trusted Origins explizit gepflegt
  • SECURE_PROXY_SSL_HEADER gesetzt, damit Django HTTPS hinter dem externen Proxy korrekt erkennt
  • USE_X_FORWARDED_HOST=True
  • XAccelRedirect statt direktem FileDownload → Nginx liefert nur, was Django freigibt
  • SessionCookies HttpOnly, SameSite=Lax, Secure (in Prod)

DefenseinDepth (Empfehlungen)

  • AppArmorProfile pro Container (z.B. via --security-opt apparmor=...) aktuell nicht aktiv
  • seccompProfile (Default reicht, custom optional)
  • ImageScanning (trivy, grype) in CI integrieren
  • Renovate/Dependabot für BaseImage und PythonDeps

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 RedisCLI

In Produktion bleiben nur Port 8080 (intern an externen Proxy) erreichbar.


Volumes & Persistenz

Volume Inhalt Backup?
postgres_data PostgresDaten pg_dump nightly
redis_data AOF + RDB nein (BrokerState, regenerierbar)
static_files Generierte StaticFiles nein (regenerierbar via collectstatic)
media_files Hochgeladene Templates, CSVs, generierte PDFs tar nightly
nginx_cache Nginx Cache nein
nginx_run Nginx PID/Socket nein

BindMount im Dev: ./app:/app für LiveReload.


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.txt muss exakt denselben Wert enthalten wie in DATABASE_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= URLParsing in DATABASE_URL bricht

Inbetriebnahme

Voraussetzungen

  • Ubuntu 22.04/24.04 LTS, aktueller PatchStand
  • Docker Engine 27.x+ und Compose v2 (aptPakete docker-ce + docker-compose-plugin, nicht Snap)
  • HostUser in dockerGruppe (sudo usermod -aG docker $USER, dann ReLogin)

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. PostgresPasswort 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 refusedAPP_BIND_IP steht noch auf 127.0.0.1
  • Timeout → Firewall blockt
  • 502 ohne Connect-Fehler → falsche Upstream-IP im Proxy-Eintrag

Entwicklung in VS Code

  • .devcontainer/devcontainer.json referenziert den ComposeStack mit DevOverride
  • .vscode/launch.json enthält Configs:
    • Django: runserver (attach) → debugpy auf 5678
    • Django: pytest → führt Tests im Container aus
  • .vscode/tasks.json für makemigrations, migrate, shell, collectstatic
  • .vscode/extensions.json empfiehlt: Python, Pylance, Django, Docker, Even Better TOML, Ruff

Workflow

# Stack hochfahren (Override greift automatisch)
docker compose up -d

# Code‑Änderungen werden via BindMount sofort sichtbar
# runserver reloaded automatisch

# Debugging: F5 in VS Code → attach an debugpy:5678

Hinweis zum ComposeAufruf: Niemals docker compose -f docker-compose.yml up -d verwenden — das ignoriert die OverrideDatei und führt zu NetzwerkRaceConditions sowie zur falschen UID im Container. Compose merged docker-compose.yml + docker-compose.override.yml automatisch, wenn keine -fFlags gesetzt sind.

Wichtige Unterschiede Dev ↔ Prod

Aspekt Dev Prod
WSGI runserver Gunicorn
Code BindMount ./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 TestSuite liegt unter app/mailmerge/tests/ und ist in drei Schichten organisiert:

Layer Datei Was wird geprüft LibreOffice
ServiceUnit test_preview_service.py CSVParsing, HeaderValidierung, ResultAufbau, Fehlerpfade gemockt
View test_preview_view.py HTTPLayer: Auth, MethodRestriction, FormValidation, ResponseHeader, FehlerRendering gemockt
Integration test_preview_integration.py EndtoEnd mit echtem soffice --headless real (Marker integration)

PytestKonfiguration (app/pyproject.toml):

  • --reuse-db für schnelle ReRuns
  • DefaultSelektor -m 'not integration' — IntegrationTests 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 ReRuns
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 CIIntegration: 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

# CeleryQueue 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 ZeroDowntime nicht möglich (singlehost) → kurzer Restart
docker compose up -d

# DBMigrationen
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 stats für adhoc ContainerRessourcen
  • HealthcheckStatus: docker compose ps
  • ContainerLogs via zentralem syslog/journald (Docker json-file rotiert 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 CloudTransfer
  • Zugriffssteuerung: Nur authentifizierte User; Trennung Templates/Jobs aktuell flach — Mandanten/Rollenmodell ggf. ergänzen (siehe Roadmap)
  • Aufbewahrung: Jobs + CSVs + OutputPDFs werden nach JOB_RETENTION_DAYS (default 30) automatisch gelöscht. Der RetentionCleanup läuft als CeleryBeatPeriodicTask einmal täglich. Manuelle Ausführung/DryRun: docker compose exec web python manage.py cleanup_jobs --dry-run
  • Audit: Jeder Job hat ein JobLogEntryProtokoll (wer, wann, was, Fehler)
  • BackupVerschlüsselung: BindMount ./backups liegt aktuell unverschlüsselt — für produktiven Einsatz LUKS/FilesystemEncryption am Host dringend empfohlen
  • DSFARelevanz: Bei Verarbeitung besonderer Datenkategorien oder umfangreicher Profilbildung ist eine DatenschutzFolgenabschätzung gemäß Art. 35 DSGVO durchzuführen, bevor produktiver Echtbetrieb startet

Bekannte Einschränkungen & Roadmap

TechDebt

  • PostgresPasswort doppelt gehalten (DATABASE_URL + SecretDatei) → refactor zu Single Source via Entrypoint, der DATABASE_URL aus *_FILEEnv zusammenbaut
  • Keine LDAP/ADAnbindung — aktuell lokale DjangoUser
  • CSVHeaderValidierung im HauptJobFlow warnonly (im PreviewPfad bereits strikt)
  • Image enthält LibreOffice (~400 MB) — könnte in separates WorkerImage ausgelagert werden
  • Tests decken Preview + Retention ab — HauptJobFlow (CeleryTask run_mailmerge, StatusPolling, XAccelRedirect) noch ohne automatisierte Tests

Erledigt

  • SingleLetterPreview im JobErstellungsFlow
  • TestSuite mit pytestdjango (3 Layer)
  • Logout via POSTForm (Django 5)
  • RetentionCleanup als periodic task (mailmerge.cleanup_expired_jobs, täglich)

Roadmap (kurzfristig)

  • CSVValidierung strict mit Pflichtfeldliste pro Template (auch im HauptFlow)
  • Tests für CeleryTask run_mailmerge (mit CELERY_TASK_ALWAYS_EAGER)
  • Mandanten/Berechtigungsmodell (Abteilung X sieht nur eigene Templates)
  • LDAPAuth über djangoauthldap
  • PostgresPasswort als Single Source of Truth (*_FILEEnv)

Roadmap (mittelfristig)

  • AppArmorProfile pro Container
  • ImageScanning in CI (Trivy)
  • Separate WorkerImage ohne WebDependencies
  • AESVerschlüsselung der hochgeladenen CSVs at rest
  • Optional: Konvertierung über Gotenberg statt LibreOfficeimImage (cleaneres Separation of Concerns)

Lizenz & Verantwortlichkeit

Internes Projekt. Verantwortlich für Konzept & Betrieb: Datenschutzkoordination / ITSystemadministration.

Keine externe Lizenz festgelegt — Verteilung ausschließlich innerbetrieblich.

S
Description
Django-basierte Web-App zur Erstellung personalisierter Serienbriefe aus DOCX-Vorlagen und CSV-Empfängerlisten. Asynchrone Verarbeitung via Celery, DOCX→PDF mit LibreOffice, produktionsnaher Docker-Stack hinter externem Reverse-Proxy.
Readme 387 KiB
Languages
Python 80.2%
HTML 13.5%
Dockerfile 4.9%
Shell 0.8%
Assembly 0.6%