727 lines
30 KiB
Markdown
727 lines
30 KiB
Markdown
# 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_<task_id>`) → 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/<id>/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/<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: 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=<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‑`=` 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 <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 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.
|