# 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 1. [Funktionsumfang](#funktionsumfang) 2. [Architekturübersicht](#architekturübersicht) 3. [Tech‑Stack](#techstack) 4. [Komponenten im Detail](#komponenten-im-detail) 5. [Verzeichnisstruktur](#verzeichnisstruktur) 6. [Datenfluss eines Serienbrief‑Jobs](#datenfluss-eines-serienbriefjobs) 7. [Single‑Letter‑Preview](#singleletterpreview) 8. [Datenmodell](#datenmodell) 9. [Sicherheitskonzept](#sicherheitskonzept) 10. [Netzwerk & Ports](#netzwerk--ports) 11. [Volumes & Persistenz](#volumes--persistenz) 12. [Konfiguration](#konfiguration) 13. [Inbetriebnahme](#inbetriebnahme) 14. [Entwicklung in VS Code](#entwicklung-in-vs-code) 15. [Tests](#tests) 16. [Betrieb & Wartung](#betrieb--wartung) 17. [Datenschutz & Compliance](#datenschutz--compliance) 18. [Bekannte Einschränkungen & Roadmap](#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](TEST.md) - **Production‑Anleitung** mit Go‑Live‑Checkliste — siehe [DEPLOYMENT.md](DEPLOYMENT.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‑Redirect` fü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` (per `ROLE`‑Env unterschieden, gleiches Image wie worker/beat) - Stateless, read‑only Root‑FS, `/tmp` als tmpfs - Settings‑Split: `base.py`, `dev.py`, `production.py` - Entrypoint wartet auf DB‑Readiness, führt `migrate` und (in Prod) `collectstatic` aus - Healthcheck via `/healthz` - **Synchron**er Preview‑Endpoint (`/jobs/preview/`) rendert über LibreOffice direkt im `web`‑Container — kein Celery‑Roundtrip ### `worker` – Celery - Verarbeitet `process_mailmerge_job`‑Tasks - LibreOffice‑User‑Profil im tmpfs (`/tmp/lo_profile_`) → keine Leaks zwischen Jobs - `--concurrency=2`, `--max-tasks-per-child=50` (RAM‑Leak‑Schutz bei LibreOffice) ### `beat` – Celery Scheduler - Nutzt `DatabaseScheduler` von 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`) ### `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: 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 `allkeys‑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 `./backups` auf 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 1. **Upload Vorlage:** `LetterTemplate` mit DOCX‑Datei wird angelegt (Admin oder UI) 2. **Job‑Erstellung:** 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, Ergebnis‑PDF 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 (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` - `JobLogEntry`s pro Schritt; Status `running → done` bzw. `failed` 7. **UI‑Polling:** HTMX fragt `/jobs//status/` alle ~2 s ab → Fortschrittsbalken 8. **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 Platzhalter - `X-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 ```text 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//input.csv) ├── output_pdf (FileField → media/jobs//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: true` bei allen Services - `read_only: true` für `web`, `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 `nginx` bindet 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) - `.env` und `secrets/` sind in `.gitignore` ausgenommen ### Anwendungs‑Sicherheit - django‑axes: 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` - 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: ```env # 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= 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:@db:5432/serienbrief # Redis REDIS_PASSWORD= CELERY_BROKER_URL=redis://:@redis:6379/0 CELERY_RESULT_BACKEND=redis://:@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‑`=` URL‑Parsing in `DATABASE_URL` bricht --- ## 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 ```bash # 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: ```bash # 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: ```bash sudo ufw allow from 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): ```env 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: ```nginx 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 ```nginx 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://: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: ```bash # TCP-Connect — "succeeded" erwartet nc -zv 8080 # HTTP-Request — 200/302 erwartet curl -v -H "Host: serienbrief.example.lan" http://:8080/ ``` Typische Ursachen: - `Connection refused` → `APP_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 Compose‑Stack mit Dev‑Override - `.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 ```bash # 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-db` für schnelle Re‑Runs - Default‑Selektor `-m 'not integration'` — Integration‑Tests laufen nur auf Anforderung - `python_files = ["test_*.py", "*_test.py", "tests.py"]` **Schnellstart:** ```bash # 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](TEST.md)**. --- ## Betrieb & Wartung ### Häufige Operationen ```bash # 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 ```bash # 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 ```bash # 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 ad‑hoc Container‑Ressourcen - Healthcheck‑Status: `docker compose ps` - Container‑Logs 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 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 `./backups` liegt 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, der `DATABASE_URL` aus `*_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 - [x] Single‑Letter‑Preview im Job‑Erstellungs‑Flow - [x] Test‑Suite mit pytest‑django (3 Layer) - [x] Logout via POST‑Form (Django 5) - [x] 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` (mit `CELERY_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.