Files
serienbrief_django/README.md
T
2026-05-22 09:14:31 +02:00

726 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](#funktionsumfang)
2. [Architekturübersicht](#architekturübersicht)
3. [TechStack](#techstack)
4. [Komponenten im Detail](#komponenten-im-detail)
5. [Verzeichnisstruktur](#verzeichnisstruktur)
6. [Datenfluss eines SerienbriefJobs](#datenfluss-eines-serienbriefjobs)
7. [SingleLetterPreview](#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
- 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](TEST.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 `nginx``web`. 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 `ROLE`Env 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`
- **Synchron**er PreviewEndpoint (`/jobs/preview/`) rendert über LibreOffice direkt im `web`Container — kein CeleryRoundtrip
### `worker` Celery
- Verarbeitet `process_mailmerge_job`Tasks
- 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_restore`kompatibel
- MediaTar aus `media_files`Volume (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`
- `JobLogEntry`s 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 `web`Container** | 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_error`Message neu gerendert |
| LibreOfficeCrash / Timeout | Formular mit generischer Fehlermeldung; Stacktrace im ServerLog |
---
## 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/<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
- `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. 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:
```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=<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 `docker`Gruppe (`sudo usermod -aG docker $USER`, dann ReLogin)
### 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. 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:
```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 <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):
```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://<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:
```bash
# 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_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
```bash
# 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 `-f`Flags 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:**
```bash
# 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](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
# CeleryQueue 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 ZeroDowntime nicht möglich (singlehost) → kurzer Restart
docker compose up -d
# DBMigrationen
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 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 `JobLogEntry`Protokoll (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 `*_FILE`Env 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
- [x] SingleLetterPreview im JobErstellungsFlow
- [x] TestSuite mit pytestdjango (3 Layer)
- [x] Logout via POSTForm (Django 5)
- [x] 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 (`*_FILE`Env)
### 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.