From 6a103adac4a5b7709df4bd44b957ca1593de2c43 Mon Sep 17 00:00:00 2001 From: Hans-Christian Payer Date: Thu, 21 May 2026 10:36:16 +0200 Subject: [PATCH] =?UTF-8?q?Erste=20lauff=C3=A4hige=20Version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 547 ++++++++++++++++++ app/Dockerfile | 86 +++ app/config/__init__.py | 3 + app/config/asgi.py | 6 + app/config/celery.py | 9 + app/config/settings/__init__.py | 0 app/config/settings/base.py | 144 +++++ app/config/settings/dev.py | 16 + app/config/settings/production.py | 32 + app/config/urls.py | 27 + app/config/wsgi.py | 6 + app/entrypoint.sh | 19 + app/mailmerge/__init__.py | 0 app/mailmerge/admin.py | 25 + app/mailmerge/apps.py | 7 + app/mailmerge/forms.py | 34 ++ app/mailmerge/management/__init__.py | 0 app/mailmerge/management/commands/__init__.py | 0 .../management/commands/wait_for_db.py | 25 + app/mailmerge/migrations/0001_initial.py | 68 +++ app/mailmerge/migrations/__init__.py | 0 app/mailmerge/models.py | 86 +++ app/mailmerge/services/__init__.py | 0 app/mailmerge/services/docx_renderer.py | 74 +++ app/mailmerge/services/pdf_merge.py | 14 + app/mailmerge/tasks.py | 87 +++ app/mailmerge/urls.py | 12 + app/mailmerge/views.py | 94 +++ app/manage.py | 19 + app/pyproject.toml | 17 + app/requirements-dev.txt | 8 + app/requirements.txt | 15 + app/templates/base.html | 41 ++ app/templates/mailmerge/_job_status.html | 25 + app/templates/mailmerge/dashboard.html | 40 ++ app/templates/mailmerge/job_detail.html | 12 + app/templates/mailmerge/job_form.html | 10 + app/templates/mailmerge/template_detail.html | 11 + app/templates/mailmerge/template_form.html | 10 + app/templates/registration/login.html | 10 + docker-compose.override.yml | 65 +++ docker-compose.yml | 309 ++++++++++ nginx/conf.d/proxy_params.inc | 9 + nginx/conf.d/serienbrief.conf | 55 ++ nginx/nginx.conf | 58 ++ serienbrief/.devcontainer/devcontainer.json | 57 ++ serienbrief/.env.example | 46 ++ serienbrief/.gitignore | 39 ++ serienbrief/.vscode/extensions.json | 14 + serienbrief/.vscode/launch.json | 53 ++ serienbrief/.vscode/settings.json | 38 ++ serienbrief/.vscode/tasks.json | 72 +++ serienbrief/README.md | 125 ++++ serienbrief/app/Dockerfile | 86 +++ serienbrief/app/config/__init__.py | 3 + serienbrief/app/config/asgi.py | 6 + serienbrief/app/config/celery.py | 9 + serienbrief/app/config/settings/__init__.py | 0 serienbrief/app/config/settings/base.py | 144 +++++ serienbrief/app/config/settings/dev.py | 16 + serienbrief/app/config/settings/production.py | 32 + serienbrief/app/config/urls.py | 27 + serienbrief/app/config/wsgi.py | 6 + serienbrief/app/entrypoint.sh | 19 + serienbrief/app/mailmerge/__init__.py | 0 serienbrief/app/mailmerge/admin.py | 25 + serienbrief/app/mailmerge/apps.py | 7 + serienbrief/app/mailmerge/forms.py | 34 ++ .../app/mailmerge/management/__init__.py | 0 .../mailmerge/management/commands/__init__.py | 0 .../management/commands/wait_for_db.py | 25 + .../app/mailmerge/migrations/__init__.py | 0 serienbrief/app/mailmerge/models.py | 86 +++ .../app/mailmerge/services/__init__.py | 0 .../app/mailmerge/services/docx_renderer.py | 74 +++ .../app/mailmerge/services/pdf_merge.py | 14 + serienbrief/app/mailmerge/tasks.py | 87 +++ serienbrief/app/mailmerge/urls.py | 12 + serienbrief/app/mailmerge/views.py | 94 +++ serienbrief/app/manage.py | 19 + serienbrief/app/pyproject.toml | 17 + serienbrief/app/requirements-dev.txt | 8 + serienbrief/app/requirements.txt | 15 + serienbrief/app/templates/base.html | 41 ++ .../app/templates/mailmerge/_job_status.html | 25 + .../app/templates/mailmerge/dashboard.html | 40 ++ .../app/templates/mailmerge/job_detail.html | 12 + .../app/templates/mailmerge/job_form.html | 10 + .../templates/mailmerge/template_detail.html | 11 + .../templates/mailmerge/template_form.html | 10 + .../app/templates/registration/login.html | 10 + serienbrief/docker-compose.override.yml | 62 ++ serienbrief/docker-compose.yml | 309 ++++++++++ serienbrief/nginx/conf.d/proxy_params.inc | 9 + serienbrief/nginx/conf.d/serienbrief.conf | 55 ++ serienbrief/nginx/nginx.conf | 58 ++ serienbrief_testdata/empfaenger.csv | 11 + .../vorlage_dsgvo_mitarbeiterinfo.docx | Bin 0 -> 37413 bytes 98 files changed, 4107 insertions(+) create mode 100644 README.md create mode 100644 app/Dockerfile create mode 100644 app/config/__init__.py create mode 100644 app/config/asgi.py create mode 100644 app/config/celery.py create mode 100644 app/config/settings/__init__.py create mode 100644 app/config/settings/base.py create mode 100644 app/config/settings/dev.py create mode 100644 app/config/settings/production.py create mode 100644 app/config/urls.py create mode 100644 app/config/wsgi.py create mode 100755 app/entrypoint.sh create mode 100644 app/mailmerge/__init__.py create mode 100644 app/mailmerge/admin.py create mode 100644 app/mailmerge/apps.py create mode 100644 app/mailmerge/forms.py create mode 100644 app/mailmerge/management/__init__.py create mode 100644 app/mailmerge/management/commands/__init__.py create mode 100644 app/mailmerge/management/commands/wait_for_db.py create mode 100644 app/mailmerge/migrations/0001_initial.py create mode 100644 app/mailmerge/migrations/__init__.py create mode 100644 app/mailmerge/models.py create mode 100644 app/mailmerge/services/__init__.py create mode 100644 app/mailmerge/services/docx_renderer.py create mode 100644 app/mailmerge/services/pdf_merge.py create mode 100644 app/mailmerge/tasks.py create mode 100644 app/mailmerge/urls.py create mode 100644 app/mailmerge/views.py create mode 100644 app/manage.py create mode 100644 app/pyproject.toml create mode 100644 app/requirements-dev.txt create mode 100644 app/requirements.txt create mode 100644 app/templates/base.html create mode 100644 app/templates/mailmerge/_job_status.html create mode 100644 app/templates/mailmerge/dashboard.html create mode 100644 app/templates/mailmerge/job_detail.html create mode 100644 app/templates/mailmerge/job_form.html create mode 100644 app/templates/mailmerge/template_detail.html create mode 100644 app/templates/mailmerge/template_form.html create mode 100644 app/templates/registration/login.html create mode 100644 docker-compose.override.yml create mode 100644 docker-compose.yml create mode 100644 nginx/conf.d/proxy_params.inc create mode 100644 nginx/conf.d/serienbrief.conf create mode 100644 nginx/nginx.conf create mode 100644 serienbrief/.devcontainer/devcontainer.json create mode 100644 serienbrief/.env.example create mode 100644 serienbrief/.gitignore create mode 100644 serienbrief/.vscode/extensions.json create mode 100644 serienbrief/.vscode/launch.json create mode 100644 serienbrief/.vscode/settings.json create mode 100644 serienbrief/.vscode/tasks.json create mode 100644 serienbrief/README.md create mode 100644 serienbrief/app/Dockerfile create mode 100644 serienbrief/app/config/__init__.py create mode 100644 serienbrief/app/config/asgi.py create mode 100644 serienbrief/app/config/celery.py create mode 100644 serienbrief/app/config/settings/__init__.py create mode 100644 serienbrief/app/config/settings/base.py create mode 100644 serienbrief/app/config/settings/dev.py create mode 100644 serienbrief/app/config/settings/production.py create mode 100644 serienbrief/app/config/urls.py create mode 100644 serienbrief/app/config/wsgi.py create mode 100755 serienbrief/app/entrypoint.sh create mode 100644 serienbrief/app/mailmerge/__init__.py create mode 100644 serienbrief/app/mailmerge/admin.py create mode 100644 serienbrief/app/mailmerge/apps.py create mode 100644 serienbrief/app/mailmerge/forms.py create mode 100644 serienbrief/app/mailmerge/management/__init__.py create mode 100644 serienbrief/app/mailmerge/management/commands/__init__.py create mode 100644 serienbrief/app/mailmerge/management/commands/wait_for_db.py create mode 100644 serienbrief/app/mailmerge/migrations/__init__.py create mode 100644 serienbrief/app/mailmerge/models.py create mode 100644 serienbrief/app/mailmerge/services/__init__.py create mode 100644 serienbrief/app/mailmerge/services/docx_renderer.py create mode 100644 serienbrief/app/mailmerge/services/pdf_merge.py create mode 100644 serienbrief/app/mailmerge/tasks.py create mode 100644 serienbrief/app/mailmerge/urls.py create mode 100644 serienbrief/app/mailmerge/views.py create mode 100644 serienbrief/app/manage.py create mode 100644 serienbrief/app/pyproject.toml create mode 100644 serienbrief/app/requirements-dev.txt create mode 100644 serienbrief/app/requirements.txt create mode 100644 serienbrief/app/templates/base.html create mode 100644 serienbrief/app/templates/mailmerge/_job_status.html create mode 100644 serienbrief/app/templates/mailmerge/dashboard.html create mode 100644 serienbrief/app/templates/mailmerge/job_detail.html create mode 100644 serienbrief/app/templates/mailmerge/job_form.html create mode 100644 serienbrief/app/templates/mailmerge/template_detail.html create mode 100644 serienbrief/app/templates/mailmerge/template_form.html create mode 100644 serienbrief/app/templates/registration/login.html create mode 100644 serienbrief/docker-compose.override.yml create mode 100644 serienbrief/docker-compose.yml create mode 100644 serienbrief/nginx/conf.d/proxy_params.inc create mode 100644 serienbrief/nginx/conf.d/serienbrief.conf create mode 100644 serienbrief/nginx/nginx.conf create mode 100644 serienbrief_testdata/empfaenger.csv create mode 100644 serienbrief_testdata/vorlage_dsgvo_mitarbeiterinfo.docx diff --git a/README.md b/README.md new file mode 100644 index 0000000..17bb9e1 --- /dev/null +++ b/README.md @@ -0,0 +1,547 @@ +# 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. [Datenmodell](#datenmodell) +8. [Sicherheitskonzept](#sicherheitskonzept) +9. [Netzwerk & Ports](#netzwerk--ports) +10. [Volumes & Persistenz](#volumes--persistenz) +11. [Konfiguration](#konfiguration) +12. [Inbetriebnahme](#inbetriebnahme) +13. [Entwicklung in VS Code](#entwicklung-in-vs-code) +14. [Betrieb & Wartung](#betrieb--wartung) +15. [Datenschutz & Compliance](#datenschutz--compliance) +16. [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) +- Asynchrone Verarbeitung ü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** +- 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) + +--- + +## 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) | + +--- + +## 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` + +### `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 +- Geplant für: Retention‑Cleanup (alte Jobs/PDFs), Audit‑Log‑Rotation +- *Hinweis: Konkrete Periodic Tasks aktuell noch nicht registriert (siehe Roadmap)* + +### `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 +│ │ ├── admin.py +│ │ ├── forms.py +│ │ ├── urls.py +│ │ ├── tasks.py # Celery tasks +│ │ ├── services/ +│ │ │ ├── docx_renderer.py # docxtpl + LibreOffice +│ │ │ └── pdf_merge.py # pypdf +│ │ └── management/commands/ +│ │ └── wait_for_db.py +│ └── templates/ +│ ├── base.html +│ ├── registration/login.html +│ └── mailmerge/… +├── 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 → `MailMergeJob(status=queued)` +3. **Enqueue:** View löst `process_mailmerge_job.delay(job.pk)` aus +4. **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` +5. **UI‑Polling:** HTMX fragt `/jobs//status/` alle ~2 s ab → Fortschrittsbalken +6. **Download:** Nutzer klickt Download → View prüft Auth → Antwort enthält `X‑Accel‑Redirect: /protected/…` → Nginx streamt das PDF + +--- + +## 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 Proxy konfigurieren (Beispiel) + +```nginx +server { + listen 443 ssl http2; + server_name serienbrief.lan; + + ssl_certificate /etc/ssl/lan/serienbrief.crt; + ssl_certificate_key /etc/ssl/lan/serienbrief.key; + + location / { + proxy_pass http://:8080; + 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_redirect off; + client_max_body_size 50M; # für CSV/DOCX‑Uploads + } +} +``` + +--- + +## 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 +``` + +### 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` | + +--- + +## 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 werden nach `JOB_RETENTION_DAYS` (default 30) gelöscht — Cleanup‑Task noch zu implementieren (siehe Roadmap) +- **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 +- Keine Periodic Tasks im Beat registriert (Retention‑Cleanup, Log‑Rotation) +- CSV‑Header‑Validierung ist warn‑only, kein Abbruch bei fehlenden Pflichtfeldern +- Keine Vorschau (Single‑Letter‑Preview) vor dem Komplett‑Render +- Image enthält LibreOffice (~400 MB) — könnte in separates Worker‑Image ausgelagert werden + +### Roadmap (kurzfristig) +- [ ] Single‑Letter‑Preview im Job‑Erstellungs‑Flow +- [ ] Retention‑Cleanup als periodic task +- [ ] CSV‑Validierung strict mit Pflichtfeldliste pro Template +- [ ] Mandanten‑/Berechtigungsmodell (Abteilung X sieht nur eigene Templates) +- [ ] LDAP‑Auth über `django‑auth‑ldap` (AD‑Integration GB) + +### 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. diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..e07eb0a --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,86 @@ +# ============================================================================= +# Multi-Stage: builder → dev → runtime +# Dev-Image enthält zusätzlich debugpy, ipython, django-debug-toolbar. +# ============================================================================= + +# ---------- Stage 1: Build ---------------------------------------------------- +FROM python:3.12-slim-bookworm AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential libpq-dev gcc \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY requirements.txt requirements-dev.txt ./ +RUN pip wheel --wheel-dir=/wheels -r requirements.txt -r requirements-dev.txt + + +# ---------- Common Runtime Base ---------------------------------------------- +FROM python:3.12-slim-bookworm AS runtime-base + +ARG APP_UID=1000 +ARG APP_GID=1000 + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + libreoffice libreoffice-writer \ + fonts-liberation fonts-dejavu \ + tini curl \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* + +RUN groupadd -g ${APP_GID} app && \ + useradd -u ${APP_UID} -g ${APP_GID} -m -s /bin/bash app + +WORKDIR /app +COPY --from=builder /wheels /wheels + + +# ---------- Stage 2a: Runtime (Production) ------------------------------------ +FROM runtime-base AS runtime + +COPY requirements.txt . +RUN pip install --no-index --find-links=/wheels -r requirements.txt && \ + rm -rf /wheels + +COPY --chown=app:app . /app/ +RUN mkdir -p /app/staticfiles /app/media && chown -R app:app /app +USER app +EXPOSE 8000 +ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"] +CMD ["gunicorn", "config.wsgi:application", \ + "--bind", "0.0.0.0:8000", \ + "--workers", "3", "--threads", "2", \ + "--worker-class", "gthread", "--worker-tmp-dir", "/tmp", \ + "--access-logfile", "-", "--error-logfile", "-", \ + "--timeout", "120"] + + +# ---------- Stage 2b: Dev ----------------------------------------------------- +FROM runtime-base AS dev + +# Dev-Tools für Container & VS Code +RUN apt-get update && apt-get install -y --no-install-recommends \ + git bash-completion vim less procps iputils-ping \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt requirements-dev.txt ./ +RUN pip install --no-index --find-links=/wheels -r requirements.txt -r requirements-dev.txt && \ + rm -rf /wheels + +# Code wird im Dev via Volume gemountet; nichts kopieren. +RUN mkdir -p /app/staticfiles /app/media && chown -R app:app /app +USER app +EXPOSE 8000 5678 +ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"] +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/app/config/__init__.py b/app/config/__init__.py new file mode 100644 index 0000000..370372a --- /dev/null +++ b/app/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ["celery_app"] diff --git a/app/config/asgi.py b/app/config/asgi.py new file mode 100644 index 0000000..5096b23 --- /dev/null +++ b/app/config/asgi.py @@ -0,0 +1,6 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") +application = get_asgi_application() diff --git a/app/config/celery.py b/app/config/celery.py new file mode 100644 index 0000000..020f2a5 --- /dev/null +++ b/app/config/celery.py @@ -0,0 +1,9 @@ +import os + +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") + +app = Celery("serienbrief") +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/app/config/settings/__init__.py b/app/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config/settings/base.py b/app/config/settings/base.py new file mode 100644 index 0000000..f10dce3 --- /dev/null +++ b/app/config/settings/base.py @@ -0,0 +1,144 @@ +""" +Basis-Settings. Werden von dev.py und production.py erweitert. +""" +from pathlib import Path + +import environ + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +env = environ.Env( + DJANGO_DEBUG=(bool, False), + USE_X_FORWARDED_HOST=(bool, True), + JOB_RETENTION_DAYS=(int, 30), +) + +# --- Core -------------------------------------------------------------------- +SECRET_KEY = env("DJANGO_SECRET_KEY", default="dev-insecure-change-me") +DEBUG = env("DJANGO_DEBUG") +ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"]) +CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[]) + +# Hinter dem äußeren Reverse-Proxy +USE_X_FORWARDED_HOST = env("USE_X_FORWARDED_HOST") +_proxy_header = env("SECURE_PROXY_SSL_HEADER", default="") +if _proxy_header: + name, value = _proxy_header.split(",", 1) + SECURE_PROXY_SSL_HEADER = (name.strip(), value.strip()) + +# --- Apps -------------------------------------------------------------------- +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # 3rd party + "django_celery_beat", + "django_celery_results", + "django_htmx", + "axes", + # local + "mailmerge", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django_htmx.middleware.HtmxMiddleware", + "axes.middleware.AxesMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +# --- Database ---------------------------------------------------------------- +DATABASES = { + "default": env.db_url( + "DATABASE_URL", default="sqlite:///" + str(BASE_DIR / "db.sqlite3") + ), +} + +# --- Auth -------------------------------------------------------------------- +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + "OPTIONS": {"min_length": 12}}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.Argon2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2PasswordHasher", +] + +AUTHENTICATION_BACKENDS = [ + "axes.backends.AxesStandaloneBackend", + "django.contrib.auth.backends.ModelBackend", +] + +LOGIN_URL = "/accounts/login/" +LOGIN_REDIRECT_URL = "/" +LOGOUT_REDIRECT_URL = "/accounts/login/" + +# django-axes – Brute-Force-Schutz +AXES_FAILURE_LIMIT = 5 +AXES_COOLOFF_TIME = 1 # Stunde +AXES_LOCKOUT_PARAMETERS = ["username", "ip_address"] + +# --- I18N / TZ --------------------------------------------------------------- +LANGUAGE_CODE = "de-at" +TIME_ZONE = "Europe/Vienna" +USE_I18N = True +USE_TZ = True + +# --- Static / Media ---------------------------------------------------------- +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + +# --- Celery ------------------------------------------------------------------ +CELERY_BROKER_URL = env("CELERY_BROKER_URL", default="redis://redis:6379/0") +CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", default="django-db") +CELERY_TASK_TRACK_STARTED = True +CELERY_TASK_TIME_LIMIT = 600 # 10 Minuten Hard-Timeout +CELERY_TASK_SOFT_TIME_LIMIT = 540 +CELERY_WORKER_PREFETCH_MULTIPLIER = 1 +CELERY_TIMEZONE = TIME_ZONE + +# --- App --------------------------------------------------------------------- +JOB_RETENTION_DAYS = env("JOB_RETENTION_DAYS") +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# --- Security Defaults ------------------------------------------------------- +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_HTTPONLY = False # Bleibt false, damit JS/HTMX-Forms funktionieren +CSRF_COOKIE_SAMESITE = "Lax" +X_FRAME_OPTIONS = "DENY" +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin" diff --git a/app/config/settings/dev.py b/app/config/settings/dev.py new file mode 100644 index 0000000..b42785a --- /dev/null +++ b/app/config/settings/dev.py @@ -0,0 +1,16 @@ +from .base import * # noqa: F401,F403 +from .base import INSTALLED_APPS, MIDDLEWARE + +DEBUG = True +ALLOWED_HOSTS = ["*"] + +# Debug-Toolbar nur lokal +INSTALLED_APPS = INSTALLED_APPS + ["debug_toolbar"] +MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE] +INTERNAL_IPS = ["127.0.0.1"] + +# Im Dev keine Auto-Lockouts beim Testen +AXES_ENABLED = False + +# E-Mails nur in die Konsole +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" diff --git a/app/config/settings/production.py b/app/config/settings/production.py new file mode 100644 index 0000000..6fa51d1 --- /dev/null +++ b/app/config/settings/production.py @@ -0,0 +1,32 @@ +from .base import * # noqa: F401,F403 + +DEBUG = False + +# Strikte Security-Defaults – TLS macht der äußere Proxy. +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SECURE_HSTS_SECONDS = 0 # HSTS setzt der äußere Proxy +SECURE_HSTS_INCLUDE_SUBDOMAINS = False +SECURE_HSTS_PRELOAD = False + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{asctime} {levelname} {name} {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "root": {"handlers": ["console"], "level": "INFO"}, + "loggers": { + "django.security": {"handlers": ["console"], "level": "WARNING", "propagate": False}, + "mailmerge": {"handlers": ["console"], "level": "INFO", "propagate": False}, + }, +} diff --git a/app/config/urls.py b/app/config/urls.py new file mode 100644 index 0000000..f44ac97 --- /dev/null +++ b/app/config/urls.py @@ -0,0 +1,27 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.contrib.auth import views as auth_views +from django.http import HttpResponse +from django.urls import include, path + + +def healthz(_request): + return HttpResponse("ok", content_type="text/plain") + + +urlpatterns = [ + path("healthz", healthz), + path("admin/", admin.site.urls), + path("accounts/login/", auth_views.LoginView.as_view(), name="login"), + path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"), + path("", include("mailmerge.urls")), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + try: + import debug_toolbar + urlpatterns = [path("__debug__/", include(debug_toolbar.urls)), *urlpatterns] + except ImportError: + pass diff --git a/app/config/wsgi.py b/app/config/wsgi.py new file mode 100644 index 0000000..949d08d --- /dev/null +++ b/app/config/wsgi.py @@ -0,0 +1,6 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") +application = get_wsgi_application() diff --git a/app/entrypoint.sh b/app/entrypoint.sh new file mode 100755 index 0000000..2c30784 --- /dev/null +++ b/app/entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail +ROLE="${ROLE:-web}" + +if [[ "$ROLE" == "web" ]]; then + echo "[entrypoint] waiting for database..." + python manage.py wait_for_db --timeout 60 || true + + echo "[entrypoint] running migrations..." + python manage.py migrate --noinput + + if [[ "${DJANGO_DEBUG:-False}" != "True" ]]; then + echo "[entrypoint] collecting static files..." + python manage.py collectstatic --noinput --clear + fi +fi + +echo "[entrypoint] starting role=$ROLE: $*" +exec "$@" diff --git a/app/mailmerge/__init__.py b/app/mailmerge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/mailmerge/admin.py b/app/mailmerge/admin.py new file mode 100644 index 0000000..5587a21 --- /dev/null +++ b/app/mailmerge/admin.py @@ -0,0 +1,25 @@ +from django.contrib import admin + +from .models import JobLogEntry, LetterTemplate, MailMergeJob + + +@admin.register(LetterTemplate) +class LetterTemplateAdmin(admin.ModelAdmin): + list_display = ("name", "created_by", "created_at") + search_fields = ("name",) + readonly_fields = ("id", "placeholders", "created_at", "updated_at") + + +@admin.register(MailMergeJob) +class MailMergeJobAdmin(admin.ModelAdmin): + list_display = ("id", "template", "status", "processed_rows", "total_rows", + "created_by", "created_at") + list_filter = ("status",) + readonly_fields = ("id", "created_at", "started_at", "finished_at", + "processed_rows", "total_rows", "error_message") + + +@admin.register(JobLogEntry) +class JobLogEntryAdmin(admin.ModelAdmin): + list_display = ("job", "level", "timestamp") + list_filter = ("level",) diff --git a/app/mailmerge/apps.py b/app/mailmerge/apps.py new file mode 100644 index 0000000..5e5874e --- /dev/null +++ b/app/mailmerge/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class MailmergeConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "mailmerge" + verbose_name = "Serienbrief" diff --git a/app/mailmerge/forms.py b/app/mailmerge/forms.py new file mode 100644 index 0000000..c966b51 --- /dev/null +++ b/app/mailmerge/forms.py @@ -0,0 +1,34 @@ +from django import forms + +from .models import LetterTemplate, MailMergeJob + + +class LetterTemplateForm(forms.ModelForm): + class Meta: + model = LetterTemplate + fields = ["name", "description", "file"] + widgets = { + "description": forms.Textarea(attrs={"rows": 3}), + } + + def clean_file(self): + f = self.cleaned_data["file"] + if not f.name.lower().endswith(".docx"): + raise forms.ValidationError("Nur .docx-Dateien sind erlaubt.") + if f.size > 10 * 1024 * 1024: + raise forms.ValidationError("Datei zu groß (max. 10 MB).") + return f + + +class MailMergeJobForm(forms.ModelForm): + class Meta: + model = MailMergeJob + fields = ["template", "recipients_csv"] + + def clean_recipients_csv(self): + f = self.cleaned_data["recipients_csv"] + if not f.name.lower().endswith(".csv"): + raise forms.ValidationError("Nur .csv-Dateien sind erlaubt.") + if f.size > 20 * 1024 * 1024: + raise forms.ValidationError("Datei zu groß (max. 20 MB).") + return f diff --git a/app/mailmerge/management/__init__.py b/app/mailmerge/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/mailmerge/management/commands/__init__.py b/app/mailmerge/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/mailmerge/management/commands/wait_for_db.py b/app/mailmerge/management/commands/wait_for_db.py new file mode 100644 index 0000000..e6bddd1 --- /dev/null +++ b/app/mailmerge/management/commands/wait_for_db.py @@ -0,0 +1,25 @@ +"""Wartet, bis die DB Connections annimmt.""" +import time + +from django.core.management.base import BaseCommand +from django.db import OperationalError, connections + + +class Command(BaseCommand): + help = "Wartet, bis die Standard-DB verfügbar ist." + + def add_arguments(self, parser): + parser.add_argument("--timeout", type=int, default=60) + + def handle(self, *args, **opts): + deadline = time.time() + opts["timeout"] + while time.time() < deadline: + try: + connections["default"].ensure_connection() + self.stdout.write(self.style.SUCCESS("DB ist bereit.")) + return + except OperationalError: + self.stdout.write("warte auf DB...") + time.sleep(2) + self.stderr.write(self.style.ERROR("DB nach Timeout nicht erreichbar.")) + raise SystemExit(1) diff --git a/app/mailmerge/migrations/0001_initial.py b/app/mailmerge/migrations/0001_initial.py new file mode 100644 index 0000000..ca1a252 --- /dev/null +++ b/app/mailmerge/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 5.1.4 on 2026-05-21 07:24 + +import django.db.models.deletion +import mailmerge.models +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='LetterTemplate', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('file', models.FileField(upload_to=mailmerge.models.template_upload_path)), + ('placeholders', models.JSONField(blank=True, default=list, help_text='Aus dem DOCX extrahierte Variablen.')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='templates', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='MailMergeJob', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('recipients_csv', models.FileField(upload_to=mailmerge.models.csv_upload_path)), + ('status', models.CharField(choices=[('pending', 'Wartet'), ('running', 'Läuft'), ('done', 'Fertig'), ('failed', 'Fehlgeschlagen')], default='pending', max_length=20)), + ('result_pdf', models.FileField(blank=True, null=True, upload_to=mailmerge.models.result_upload_path)), + ('total_rows', models.PositiveIntegerField(default=0)), + ('processed_rows', models.PositiveIntegerField(default=0)), + ('error_message', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('finished_at', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='jobs', to=settings.AUTH_USER_MODEL)), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='jobs', to='mailmerge.lettertemplate')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='JobLogEntry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('level', models.CharField(choices=[('info', 'Info'), ('warning', 'Warnung'), ('error', 'Fehler')], default='info', max_length=10)), + ('message', models.TextField()), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='mailmerge.mailmergejob')), + ], + options={ + 'ordering': ['timestamp'], + }, + ), + ] diff --git a/app/mailmerge/migrations/__init__.py b/app/mailmerge/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/mailmerge/models.py b/app/mailmerge/models.py new file mode 100644 index 0000000..33d4160 --- /dev/null +++ b/app/mailmerge/models.py @@ -0,0 +1,86 @@ +""" +Datenmodell – Templates, Jobs, Log-Einträge. +""" +import uuid +from pathlib import Path + +from django.conf import settings +from django.db import models + + +def template_upload_path(instance, filename: str) -> str: + return f"templates/{instance.id}/{Path(filename).name}" + + +def csv_upload_path(instance, filename: str) -> str: + return f"jobs/{instance.id}/recipients/{Path(filename).name}" + + +def result_upload_path(instance, filename: str) -> str: + return f"jobs/{instance.id}/result/{Path(filename).name}" + + +class LetterTemplate(models.Model): + """DOCX-Vorlage mit Jinja-Platzhaltern.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + file = models.FileField(upload_to=template_upload_path) + placeholders = models.JSONField(default=list, blank=True, + help_text="Aus dem DOCX extrahierte Variablen.") + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, + related_name="templates") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return self.name + + +class MailMergeJob(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "Wartet" + RUNNING = "running", "Läuft" + DONE = "done", "Fertig" + FAILED = "failed", "Fehlgeschlagen" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + template = models.ForeignKey(LetterTemplate, on_delete=models.PROTECT, + related_name="jobs") + recipients_csv = models.FileField(upload_to=csv_upload_path) + status = models.CharField(max_length=20, choices=Status.choices, + default=Status.PENDING) + result_pdf = models.FileField(upload_to=result_upload_path, null=True, blank=True) + total_rows = models.PositiveIntegerField(default=0) + processed_rows = models.PositiveIntegerField(default=0) + error_message = models.TextField(blank=True) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, + related_name="jobs") + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"Job {self.id} ({self.template.name})" + + +class JobLogEntry(models.Model): + class Level(models.TextChoices): + INFO = "info", "Info" + WARNING = "warning", "Warnung" + ERROR = "error", "Fehler" + + job = models.ForeignKey(MailMergeJob, on_delete=models.CASCADE, related_name="logs") + level = models.CharField(max_length=10, choices=Level.choices, default=Level.INFO) + message = models.TextField() + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["timestamp"] diff --git a/app/mailmerge/services/__init__.py b/app/mailmerge/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/mailmerge/services/docx_renderer.py b/app/mailmerge/services/docx_renderer.py new file mode 100644 index 0000000..8e5a034 --- /dev/null +++ b/app/mailmerge/services/docx_renderer.py @@ -0,0 +1,74 @@ +""" +DOCX-Rendering und PDF-Konvertierung. +""" +from __future__ import annotations + +import logging +import re +import subprocess +import tempfile +from pathlib import Path + +from docx import Document +from docxtpl import DocxTemplate + +logger = logging.getLogger(__name__) + +PLACEHOLDER_RE = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)") + + +def extract_placeholders(docx_path: Path) -> list[str]: + """Liest die Jinja-Platzhalter aus einem DOCX und gibt sie sortiert zurück.""" + doc = Document(str(docx_path)) + found: set[str] = set() + for para in doc.paragraphs: + for m in PLACEHOLDER_RE.finditer(para.text): + found.add(m.group(1)) + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + for para in cell.paragraphs: + for m in PLACEHOLDER_RE.finditer(para.text): + found.add(m.group(1)) + return sorted(found) + + +def render_docx(template_path: Path, context: dict, out_path: Path) -> Path: + """Füllt das DOCX-Template mit Kontext und schreibt das Ergebnis.""" + tpl = DocxTemplate(str(template_path)) + tpl.render(context) + tpl.save(str(out_path)) + return out_path + + +def docx_to_pdf(docx_path: Path, out_dir: Path) -> Path: + """Konvertiert DOCX nach PDF mit LibreOffice headless. + + LibreOffice braucht ein eigenes Profilverzeichnis, sonst kollidieren + parallele Worker. + """ + out_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="lo-profile-") as profile_dir: + cmd = [ + "soffice", + "--headless", + "--nologo", + "--norestore", + "--nolockcheck", + f"-env:UserInstallation=file://{profile_dir}", + "--convert-to", "pdf", + "--outdir", str(out_dir), + str(docx_path), + ] + logger.info("LibreOffice convert: %s", " ".join(cmd)) + result = subprocess.run( # noqa: S603 + cmd, capture_output=True, text=True, timeout=120, check=False + ) + if result.returncode != 0: + raise RuntimeError( + f"LibreOffice-Konvertierung fehlgeschlagen: {result.stderr}" + ) + pdf_path = out_dir / (docx_path.stem + ".pdf") + if not pdf_path.exists(): + raise FileNotFoundError(f"PDF nicht gefunden: {pdf_path}") + return pdf_path diff --git a/app/mailmerge/services/pdf_merge.py b/app/mailmerge/services/pdf_merge.py new file mode 100644 index 0000000..89d9294 --- /dev/null +++ b/app/mailmerge/services/pdf_merge.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from pypdf import PdfWriter + + +def merge_pdfs(pdfs: list[Path], out_path: Path) -> Path: + writer = PdfWriter() + for pdf in pdfs: + writer.append(str(pdf)) + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("wb") as f: + writer.write(f) + writer.close() + return out_path diff --git a/app/mailmerge/tasks.py b/app/mailmerge/tasks.py new file mode 100644 index 0000000..32ce487 --- /dev/null +++ b/app/mailmerge/tasks.py @@ -0,0 +1,87 @@ +""" +Celery-Tasks für die PDF-Erzeugung. +""" +from __future__ import annotations + +import csv +import io +import logging +import tempfile +from pathlib import Path + +from celery import shared_task +from django.core.files import File +from django.utils import timezone + +from .models import JobLogEntry, MailMergeJob +from .services.docx_renderer import docx_to_pdf, render_docx +from .services.pdf_merge import merge_pdfs + +logger = logging.getLogger(__name__) + + +def _log(job: MailMergeJob, level: str, msg: str) -> None: + JobLogEntry.objects.create(job=job, level=level, message=msg) + logger.log(getattr(logging, level.upper(), logging.INFO), "[job %s] %s", job.id, msg) + + +@shared_task(bind=True) +def run_mailmerge(self, job_id: str) -> str: + job = MailMergeJob.objects.select_related("template").get(pk=job_id) + job.status = MailMergeJob.Status.RUNNING + job.started_at = timezone.now() + job.save(update_fields=["status", "started_at"]) + _log(job, "info", f"Job gestartet (task={self.request.id}).") + + try: + # CSV einlesen + raw = job.recipients_csv.read().decode("utf-8-sig") + reader = csv.DictReader(io.StringIO(raw)) + rows = list(reader) + job.total_rows = len(rows) + job.save(update_fields=["total_rows"]) + _log(job, "info", f"{len(rows)} Empfänger gefunden.") + + if not rows: + raise ValueError("CSV enthält keine Datenzeilen.") + + # CSV-Felder vs. Template-Platzhalter prüfen + csv_fields = set(reader.fieldnames or []) + placeholders = set(job.template.placeholders or []) + missing = placeholders - csv_fields + if missing: + _log(job, "warning", + f"CSV fehlen Spalten: {', '.join(sorted(missing))}") + + # Jeden Brief rendern, alle zu einem PDF zusammenführen + with tempfile.TemporaryDirectory(prefix="mailmerge-") as tmpdir: + tmp = Path(tmpdir) + pdfs: list[Path] = [] + template_path = Path(job.template.file.path) + + for idx, row in enumerate(rows, start=1): + docx_out = tmp / f"letter_{idx:05d}.docx" + render_docx(template_path, row, docx_out) + pdf = docx_to_pdf(docx_out, tmp / "pdf") + pdfs.append(pdf) + job.processed_rows = idx + job.save(update_fields=["processed_rows"]) + + merged = tmp / f"serienbrief_{job.id}.pdf" + merge_pdfs(pdfs, merged) + with merged.open("rb") as f: + job.result_pdf.save(merged.name, File(f), save=False) + + job.status = MailMergeJob.Status.DONE + job.finished_at = timezone.now() + job.save() + _log(job, "info", "Job erfolgreich abgeschlossen.") + return str(job.id) + + except Exception as exc: # noqa: BLE001 + job.status = MailMergeJob.Status.FAILED + job.error_message = str(exc) + job.finished_at = timezone.now() + job.save() + _log(job, "error", f"Job fehlgeschlagen: {exc}") + raise diff --git a/app/mailmerge/urls.py b/app/mailmerge/urls.py new file mode 100644 index 0000000..72a9acf --- /dev/null +++ b/app/mailmerge/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("", views.dashboard, name="dashboard"), + path("templates/new/", views.template_upload, name="template-upload"), + path("templates//", views.template_detail, name="template-detail"), + path("jobs/new/", views.job_create, name="job-create"), + path("jobs//", views.job_detail, name="job-detail"), + path("jobs//download/", views.job_download, name="job-download"), +] diff --git a/app/mailmerge/views.py b/app/mailmerge/views.py new file mode 100644 index 0000000..e6c548c --- /dev/null +++ b/app/mailmerge/views.py @@ -0,0 +1,94 @@ +from pathlib import Path + +from django.contrib.auth.decorators import login_required +from django.http import FileResponse, Http404, HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.views.decorators.http import require_http_methods + +from .forms import LetterTemplateForm, MailMergeJobForm +from .models import LetterTemplate, MailMergeJob +from .services.docx_renderer import extract_placeholders +from .tasks import run_mailmerge + + +@login_required +def dashboard(request): + templates = LetterTemplate.objects.all()[:20] + jobs = MailMergeJob.objects.select_related("template")[:20] + return render(request, "mailmerge/dashboard.html", + {"templates": templates, "jobs": jobs}) + + +@login_required +@require_http_methods(["GET", "POST"]) +def template_upload(request): + form = LetterTemplateForm(request.POST or None, request.FILES or None) + if request.method == "POST" and form.is_valid(): + tpl = form.save(commit=False) + tpl.created_by = request.user + tpl.save() + # Platzhalter extrahieren – Datei liegt jetzt auf Disk + tpl.placeholders = extract_placeholders(Path(tpl.file.path)) + tpl.save(update_fields=["placeholders"]) + return redirect(reverse("template-detail", args=[tpl.id])) + return render(request, "mailmerge/template_form.html", {"form": form}) + + +@login_required +def template_detail(request, pk): + tpl = get_object_or_404(LetterTemplate, pk=pk) + return render(request, "mailmerge/template_detail.html", {"template": tpl}) + + +@login_required +@require_http_methods(["GET", "POST"]) +def job_create(request): + form = MailMergeJobForm(request.POST or None, request.FILES or None) + if request.method == "POST" and form.is_valid(): + job = form.save(commit=False) + job.created_by = request.user + job.save() + run_mailmerge.delay(str(job.id)) + return redirect(reverse("job-detail", args=[job.id])) + return render(request, "mailmerge/job_form.html", {"form": form}) + + +@login_required +def job_detail(request, pk): + job = get_object_or_404( + MailMergeJob.objects.select_related("template"), pk=pk + ) + logs = job.logs.all() + # HTMX partials: nur das Status-Fragment ausliefern + if request.headers.get("HX-Request"): + return render(request, "mailmerge/_job_status.html", + {"job": job, "logs": logs}) + return render(request, "mailmerge/job_detail.html", + {"job": job, "logs": logs}) + + +@login_required +def job_download(request, pk): + """PDF-Download – via X-Accel-Redirect in Production, direkter Stream im Dev.""" + job = get_object_or_404(MailMergeJob, pk=pk) + if not job.result_pdf: + raise Http404("Kein Ergebnis-PDF vorhanden.") + + # In Dev (settings.DEBUG) direkt streamen + from django.conf import settings + if settings.DEBUG: + return FileResponse(job.result_pdf.open("rb"), + as_attachment=True, + filename=Path(job.result_pdf.name).name) + + # In Production: Nginx serviert die Datei via internem Mount. + response = HttpResponse() + response["Content-Type"] = "application/pdf" + response["Content-Disposition"] = ( + f'attachment; filename="{Path(job.result_pdf.name).name}"' + ) + # Pfad relativ zu MEDIA_ROOT, gemappt auf /protected-media/ + relative = job.result_pdf.name + response["X-Accel-Redirect"] = f"/protected-media/{relative}" + return response diff --git a/app/manage.py b/app/manage.py new file mode 100644 index 0000000..fef1f23 --- /dev/null +++ b/app/manage.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +"""Django management entry point.""" +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Django konnte nicht importiert werden. Ist die venv aktiv?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/app/pyproject.toml b/app/pyproject.toml new file mode 100644 index 0000000..87a87b3 --- /dev/null +++ b/app/pyproject.toml @@ -0,0 +1,17 @@ +[tool.ruff] +line-length = 100 +target-version = "py312" +extend-exclude = ["migrations", "staticfiles", "media"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "DJ", "S"] +ignore = ["E501", "S101"] + +[tool.ruff.lint.per-file-ignores] +"**/tests/**" = ["S"] +"**/settings/**" = ["S105", "S106"] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings.dev" +python_files = ["test_*.py", "*_test.py", "tests.py"] +addopts = "--reuse-db -ra" diff --git a/app/requirements-dev.txt b/app/requirements-dev.txt new file mode 100644 index 0000000..014fada --- /dev/null +++ b/app/requirements-dev.txt @@ -0,0 +1,8 @@ +# Nur im dev-Build installieren +debugpy==1.8.7 +django-debug-toolbar==4.4.6 +ipython==8.29.0 +ruff==0.7.4 +pytest==8.3.3 +pytest-django==4.9.0 +factory-boy==3.3.1 diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..25827b4 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,15 @@ +Django==5.1.4 +django-environ==0.11.2 +django-celery-beat==2.7.0 +django-celery-results==2.5.1 +psycopg[binary]==3.2.3 +gunicorn==23.0.0 +celery[redis]==5.4.0 +redis==5.2.0 +docxtpl==0.19.0 +python-docx==1.1.2 +pypdf==5.1.0 +argon2-cffi==23.1.0 +django-axes==7.0.1 +django-htmx==1.21.0 +python-dateutil==2.9.0.post0 diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..55a413c --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,41 @@ + + + + + + {% block title %}Serienbrief{% endblock %} + + + + + {% if messages %}
    {% for m in messages %}
  • {{ m }}
  • {% endfor %}
{% endif %} + {% block content %}{% endblock %} + + diff --git a/app/templates/mailmerge/_job_status.html b/app/templates/mailmerge/_job_status.html new file mode 100644 index 0000000..73ab620 --- /dev/null +++ b/app/templates/mailmerge/_job_status.html @@ -0,0 +1,25 @@ +
+

Vorlage: {{ job.template.name }}

+

Status: + {{ job.get_status_display }} +

+

Fortschritt: {{ job.processed_rows }} / {{ job.total_rows }}

+ + {% if job.status == "done" %} +

PDF herunterladen

+ {% endif %} + {% if job.error_message %} +

Fehler: {{ job.error_message }}

+ {% endif %} + +

Log

+
+ {% for entry in logs %} +
+ [{{ entry.timestamp|date:"H:i:s" }}] {{ entry.level|upper }} – {{ entry.message }} +
+ {% empty %} + Keine Einträge. + {% endfor %} +
+
diff --git a/app/templates/mailmerge/dashboard.html b/app/templates/mailmerge/dashboard.html new file mode 100644 index 0000000..de6c7d5 --- /dev/null +++ b/app/templates/mailmerge/dashboard.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block content %} +

Übersicht

+ +

Vorlagen

+Neue Vorlage hochladen + + + + {% for t in templates %} + + + + + + {% empty %} + + {% endfor %} + +
NamePlatzhalterErstellt
{{ t.name }}{{ t.placeholders|join:", " }}{{ t.created_at|date:"d.m.Y H:i" }}
Noch keine Vorlagen vorhanden.
+ +

Aufträge

+Neuen Serienbrief erstellen + + + + {% for j in jobs %} + + + + + + + + {% empty %} + + {% endfor %} + +
IDVorlageStatusFortschrittErstellt
{{ j.id|stringformat:"s"|slice:":8" }}…{{ j.template.name }}{{ j.get_status_display }}{{ j.processed_rows }} / {{ j.total_rows }}{{ j.created_at|date:"d.m.Y H:i" }}
Noch keine Aufträge.
+{% endblock %} diff --git a/app/templates/mailmerge/job_detail.html b/app/templates/mailmerge/job_detail.html new file mode 100644 index 0000000..4248044 --- /dev/null +++ b/app/templates/mailmerge/job_detail.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block content %} +

Auftrag {{ job.id|stringformat:"s"|slice:":8" }}…

+ +
+ {% include "mailmerge/_job_status.html" %} +
+ + +{% endblock %} diff --git a/app/templates/mailmerge/job_form.html b/app/templates/mailmerge/job_form.html new file mode 100644 index 0000000..ab7fe9b --- /dev/null +++ b/app/templates/mailmerge/job_form.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block content %} +

Neuer Serienbrief

+

Vorlage und Empfänger-CSV auswählen. Die Spaltennamen der CSV müssen mit den Platzhaltern der Vorlage übereinstimmen (erste Zeile = Spaltennamen).

+
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} diff --git a/app/templates/mailmerge/template_detail.html b/app/templates/mailmerge/template_detail.html new file mode 100644 index 0000000..b960965 --- /dev/null +++ b/app/templates/mailmerge/template_detail.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block content %} +

{{ template.name }}

+

{{ template.description }}

+

Datei: {{ template.file.name }}

+

Erkannte Platzhalter:

+
    + {% for p in template.placeholders %}
  • {{ p }}
  • {% empty %}
  • Keine gefunden.
  • {% endfor %} +
+Serienbrief mit dieser Vorlage erstellen +{% endblock %} diff --git a/app/templates/mailmerge/template_form.html b/app/templates/mailmerge/template_form.html new file mode 100644 index 0000000..8f13700 --- /dev/null +++ b/app/templates/mailmerge/template_form.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block content %} +

Neue Vorlage hochladen

+

DOCX-Datei mit Platzhaltern wie {{ vorname }}, {{ nachname }}, … Die Spaltennamen der späteren CSV müssen mit den Platzhaltern übereinstimmen.

+
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} diff --git a/app/templates/registration/login.html b/app/templates/registration/login.html new file mode 100644 index 0000000..31a85e8 --- /dev/null +++ b/app/templates/registration/login.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}Anmelden{% endblock %} +{% block content %} +

Anmelden

+
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..60668ef --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,65 @@ +# ============================================================================= +# Dev-Overrides – wird von "docker compose" automatisch mit gemerged. +# Für reine Produktion: `docker compose -f docker-compose.yml up -d` +# ============================================================================= +# Wichtig: Nur "web" hat einen build-Block. worker/beat teilen das gleiche +# Image und dürfen daher KEINEN build-Override haben – sonst versucht Bake, +# sie eigenständig zu bauen und scheitert mangels Dockerfile-Kontext. +# ============================================================================= + +services: + web: + build: + target: dev + args: + APP_UID: "${APP_UID:-1000}" + APP_GID: "${APP_GID:-1000}" + command: ["python", "manage.py", "runserver", "0.0.0.0:8000"] + environment: + DJANGO_SETTINGS_MODULE: config.settings.dev + ROLE: web + ports: + - "127.0.0.1:8000:8000" + - "127.0.0.1:5678:5678" + volumes: + - ./app:/app:cached + - static_files:/app/staticfiles + - media_files:/app/media + read_only: false + healthcheck: + disable: true + + worker: + command: ["celery", "-A", "config", "worker", "--loglevel=debug", "--concurrency=1"] + environment: + DJANGO_SETTINGS_MODULE: config.settings.dev + ROLE: worker + volumes: + - ./app:/app:cached + - media_files:/app/media + read_only: false + healthcheck: + disable: true + + beat: + environment: + DJANGO_SETTINGS_MODULE: config.settings.dev + volumes: + - ./app:/app:cached + read_only: false + + nginx: + depends_on: + web: + condition: service_started + + db: + ports: + - "127.0.0.1:5432:5432" + + redis: + ports: + - "127.0.0.1:6379:6379" + + backup: + profiles: ["never"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..781ac80 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,309 @@ +# ============================================================================= +# Serienbrief – Compose (Production) +# Ubuntu Server 22.04/24.04 LTS · Docker Engine 27.x · Compose v2 +# ============================================================================= +# Die App spricht intern nur HTTP. TLS terminiert ein vorgelagerter Nginx- +# Reverse-Proxy (außerhalb dieses Compose-Stacks, z.B. zentraler LAN-Proxy). +# Der hier enthaltene "nginx"-Service ist NUR ein App-interner Proxy für: +# - Ausspielen von static/media über X-Accel-Redirect +# - Connection-Pooling und einfache Rate-Limits +# ============================================================================= + +name: serienbrief + +x-logging: &default-logging + driver: "json-file" + options: + max-size: "10m" + max-file: "5" + tag: "{{.Name}}" + +x-restart: &default-restart + restart: unless-stopped + +x-security-opts: &default-security-opts + security_opt: + - no-new-privileges:true + +services: + # --------------------------------------------------------------------------- + # App-interner Nginx – nimmt Traffic vom äußeren Proxy entgegen (HTTP). + # Hört nur auf Loopback bzw. dem konfigurierten LAN-Bind. + # --------------------------------------------------------------------------- + nginx: + image: nginx:1.27-alpine + <<: [*default-restart, *default-security-opts] + depends_on: + web: + condition: service_healthy + ports: + # Standardmäßig nur lokal – der externe Proxy spricht über das Docker- + # Host-Interface. Für direkten LAN-Zugriff LAN_BIND_IP in .env setzen. + - "${APP_BIND_IP:-127.0.0.1}:${APP_BIND_PORT:-8080}:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - static_files:/var/www/static:ro + - media_files:/var/www/media:ro + - nginx_cache:/var/cache/nginx + - nginx_run:/var/run + networks: + - frontend + logging: *default-logging + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/healthz"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + memory: 128M + cpus: "0.5" + + # --------------------------------------------------------------------------- + # Django – Gunicorn WSGI + # --------------------------------------------------------------------------- + web: + build: + context: ./app + dockerfile: Dockerfile + target: runtime + args: + APP_UID: ${APP_UID:-10001} + APP_GID: ${APP_GID:-10001} + image: serienbrief/web:${APP_VERSION:-latest} + <<: [*default-restart, *default-security-opts] + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + env_file: .env + environment: + DJANGO_SETTINGS_MODULE: config.settings.production + ROLE: web + volumes: + - static_files:/app/staticfiles + - media_files:/app/media + networks: + - frontend + - backend + logging: *default-logging + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3).status==200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + read_only: true + tmpfs: + - /tmp:size=256M,mode=1777 + deploy: + resources: + limits: + memory: 1G + cpus: "1.5" + + # --------------------------------------------------------------------------- + # Celery Worker – DOCX→PDF-Generierung + # --------------------------------------------------------------------------- + worker: + image: serienbrief/web:${APP_VERSION:-latest} + <<: [*default-restart, *default-security-opts] + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + web: + condition: service_started + env_file: .env + environment: + DJANGO_SETTINGS_MODULE: config.settings.production + ROLE: worker + command: ["celery", "-A", "config", "worker", "--loglevel=info", "--concurrency=2", "--max-tasks-per-child=50"] + volumes: + - media_files:/app/media + networks: + - backend + logging: *default-logging + healthcheck: + test: ["CMD-SHELL", "celery -A config inspect ping -d celery@$$HOSTNAME || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + read_only: true + tmpfs: + - /tmp:size=512M,mode=1777 + deploy: + resources: + limits: + memory: 2G + cpus: "2.0" + + # --------------------------------------------------------------------------- + # Celery Beat – Scheduler (Retention, Cleanup) + # --------------------------------------------------------------------------- + beat: + image: serienbrief/web:${APP_VERSION:-latest} + <<: [*default-restart, *default-security-opts] + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + env_file: .env + environment: + DJANGO_SETTINGS_MODULE: config.settings.production + ROLE: beat + command: ["celery", "-A", "config", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"] + networks: + - backend + logging: *default-logging + read_only: true + tmpfs: + - /tmp:size=64M,mode=1777 + deploy: + resources: + limits: + memory: 256M + cpus: "0.3" + + # --------------------------------------------------------------------------- + # PostgreSQL + # --------------------------------------------------------------------------- + db: + image: postgres:16-alpine + <<: [*default-restart, *default-security-opts] + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + PGDATA: /var/lib/postgresql/data/pgdata + secrets: + - postgres_password + volumes: + - postgres_data:/var/lib/postgresql/data + - ./postgres/init:/docker-entrypoint-initdb.d:ro + networks: + - backend + logging: *default-logging + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + shm_size: 256mb + deploy: + resources: + limits: + memory: 1G + cpus: "1.0" + + # --------------------------------------------------------------------------- + # Redis – Celery Broker + # --------------------------------------------------------------------------- + redis: + image: redis:7-alpine + <<: [*default-restart, *default-security-opts] + command: + - "redis-server" + - "--requirepass" + - "${REDIS_PASSWORD}" + - "--maxmemory" + - "256mb" + - "--maxmemory-policy" + - "allkeys-lru" + - "--save" + - "900 1" + - "--appendonly" + - "yes" + volumes: + - redis_data:/data + networks: + - backend + logging: *default-logging + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "--no-auth-warning", "ping"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + memory: 384M + cpus: "0.5" + + # --------------------------------------------------------------------------- + # Backup – pg_dump + Media-Tar, nightly, 14 Tage Retention + # --------------------------------------------------------------------------- + backup: + image: postgres:16-alpine + <<: [*default-restart, *default-security-opts] + depends_on: + db: + condition: service_healthy + environment: + PGHOST: db + PGUSER: ${POSTGRES_USER} + PGDATABASE: ${POSTGRES_DB} + PGPASSWORD_FILE: /run/secrets/postgres_password + BACKUP_RETENTION_DAYS: "14" + secrets: + - postgres_password + volumes: + - ./backups:/backups + - media_files:/media:ro + networks: + - backend + entrypoint: ["/bin/sh", "-c"] + command: + - | + apk add --no-cache tar gzip findutils >/dev/null && \ + while true; do + TS=$$(date +%Y%m%d_%H%M%S) + export PGPASSWORD=$$(cat $$PGPASSWORD_FILE) + echo "[$$(date -Iseconds)] starting backup $$TS" + pg_dump -Fc -f /backups/db_$$TS.dump && \ + tar czf /backups/media_$$TS.tar.gz -C /media . && \ + find /backups -name "db_*.dump" -mtime +$$BACKUP_RETENTION_DAYS -delete && \ + find /backups -name "media_*.tar.gz" -mtime +$$BACKUP_RETENTION_DAYS -delete && \ + echo "[$$(date -Iseconds)] backup done" + sleep 86400 + done + logging: *default-logging + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + +# ============================================================================= +networks: + frontend: + driver: bridge + driver_opts: + com.docker.network.bridge.name: br-sb-front + backend: + driver: bridge + driver_opts: + com.docker.network.bridge.name: br-sb-back + +# ============================================================================= +volumes: + postgres_data: + redis_data: + static_files: + media_files: + nginx_cache: + nginx_run: + +# ============================================================================= +secrets: + postgres_password: + file: ./secrets/postgres_password.txt diff --git a/nginx/conf.d/proxy_params.inc b/nginx/conf.d/proxy_params.inc new file mode 100644 index 0000000..906d055 --- /dev/null +++ b/nginx/conf.d/proxy_params.inc @@ -0,0 +1,9 @@ +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 $http_x_forwarded_proto; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header Connection ""; +proxy_redirect off; +proxy_buffering on; diff --git a/nginx/conf.d/serienbrief.conf b/nginx/conf.d/serienbrief.conf new file mode 100644 index 0000000..647e5a1 --- /dev/null +++ b/nginx/conf.d/serienbrief.conf @@ -0,0 +1,55 @@ +# ============================================================================= +# vHost – HTTP only. Security-Header & TLS sind Aufgabe des äußeren Proxys. +# ============================================================================= + +# Vom äußeren Proxy weitergereichte Header vertrauen – aber NUR aus dem +# Docker-Netz oder von der bekannten Proxy-IP. Bei Bedarf set_real_ip_from +# auf das CIDR des Proxys einschränken. +set_real_ip_from 0.0.0.0/0; +real_ip_header X-Forwarded-For; +real_ip_recursive on; + +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + # Healthcheck (für äußeren Proxy & Compose-Healthcheck) + location = /healthz { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } + + limit_conn conn_per_ip 20; + + # Login strikter limitieren + location ~ ^/(accounts/login|admin/login) { + limit_req zone=login burst=3 nodelay; + proxy_pass http://django_app; + include /etc/nginx/conf.d/proxy_params.inc; + } + + # Statische Dateien + location /static/ { + alias /var/www/static/; + access_log off; + expires 7d; + add_header Cache-Control "public, immutable"; + } + + # Geschützte Media (PDFs) – nur per X-Accel-Redirect aus Django ausspielen + location /protected-media/ { + internal; + alias /var/www/media/; + } + + # App + location / { + limit_req zone=app burst=50 nodelay; + proxy_pass http://django_app; + include /etc/nginx/conf.d/proxy_params.inc; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..bdf3d22 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,58 @@ +# ============================================================================= +# App-interner Nginx – HTTP only. +# TLS macht der äußere Proxy. +# ============================================================================= + +user nginx; +worker_processes auto; +worker_rlimit_nofile 8192; +pid /var/run/nginx.pid; +error_log /var/log/nginx/error.log warn; + +events { + worker_connections 2048; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main_ext '$remote_addr - $remote_user [$time_iso8601] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" ' + 'xff="$http_x_forwarded_for" ' + 'rt=$request_time urt="$upstream_response_time"'; + access_log /var/log/nginx/access.log main_ext; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + server_tokens off; + + # CSVs etc. – am äußeren Proxy spiegeln! + client_max_body_size 25M; + client_body_buffer_size 128k; + client_body_timeout 60s; + client_header_timeout 30s; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css application/json application/javascript + text/xml application/xml application/xml+rss text/javascript + image/svg+xml; + + limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; + limit_req_zone $binary_remote_addr zone=app:10m rate=30r/s; + limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m; + + upstream django_app { + server web:8000 max_fails=3 fail_timeout=30s; + keepalive 32; + } + + include /etc/nginx/conf.d/*.conf; +} diff --git a/serienbrief/.devcontainer/devcontainer.json b/serienbrief/.devcontainer/devcontainer.json new file mode 100644 index 0000000..1d03a51 --- /dev/null +++ b/serienbrief/.devcontainer/devcontainer.json @@ -0,0 +1,57 @@ +{ + "name": "Serienbrief Dev", + "dockerComposeFile": [ + "../docker-compose.yml", + "../docker-compose.override.yml" + ], + "service": "web", + "workspaceFolder": "/app", + "remoteUser": "app", + "shutdownAction": "stopCompose", + + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.debugpy", + "charliermarsh.ruff", + "batisteo.vscode-django", + "ms-azuretools.vscode-docker", + "redhat.vscode-yaml", + "tamasfe.even-better-toml", + "eamodio.gitlens", + "njpwerner.autodocstring" + ], + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.terminal.activateEnvironment": false, + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": ["app"], + "[python]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.fixAll": "explicit" + } + }, + "files.exclude": { + "**/__pycache__": true, + "**/*.pyc": true + } + } + } + }, + + "forwardPorts": [8000, 5678, 5432, 6379, 8080], + "portsAttributes": { + "8000": { "label": "Django runserver" }, + "5678": { "label": "debugpy" }, + "8080": { "label": "App-Nginx" }, + "5432": { "label": "PostgreSQL" }, + "6379": { "label": "Redis" } + }, + + "postCreateCommand": "pip install --user -e . 2>/dev/null || true && python manage.py migrate --noinput || true" +} diff --git a/serienbrief/.env.example b/serienbrief/.env.example new file mode 100644 index 0000000..c6e34ed --- /dev/null +++ b/serienbrief/.env.example @@ -0,0 +1,46 @@ +# ============================================================================= +# Serienbrief – Environment +# Kopieren als .env. NICHT ins Git committen. +# ============================================================================= + +# --- Deployment ------------------------------------------------------------- +APP_VERSION=1.0.0 +APP_UID=10001 +APP_GID=10001 + +# Bind des internen Nginx-Sockets. Wird vom äußeren Reverse-Proxy angesprochen. +# - 127.0.0.1: nur lokal (z.B. wenn der äußere Proxy auf demselben Host läuft) +# - LAN-IP: wenn der äußere Proxy auf einem anderen Host steht +APP_BIND_IP=127.0.0.1 +APP_BIND_PORT=8080 + +# --- Django ----------------------------------------------------------------- +# openssl rand -base64 64 +DJANGO_SECRET_KEY=CHANGE_ME_LONG_RANDOM_STRING +DJANGO_DEBUG=False + +# Hosts, unter denen die App erreichbar ist (Domain des äußeren Proxys). +DJANGO_ALLOWED_HOSTS=serienbrief.lan,127.0.0.1,localhost + +# Trusted Origins für CSRF – HTTPS-URL des äußeren Proxys. +CSRF_TRUSTED_ORIGINS=https://serienbrief.lan + +# Wichtig hinter HTTPS-terminierendem Proxy: erlaubt Django, das Schema aus +# dem X-Forwarded-Proto-Header zu übernehmen. +USE_X_FORWARDED_HOST=True +SECURE_PROXY_SSL_HEADER=HTTP_X_FORWARDED_PROTO,https + +# --- PostgreSQL ------------------------------------------------------------- +POSTGRES_DB=serienbrief +POSTGRES_USER=serienbrief +# Passwort liegt in ./secrets/postgres_password.txt +DATABASE_URL=postgres://serienbrief:__FROM_SECRET__@db:5432/serienbrief + +# --- Redis ------------------------------------------------------------------ +# openssl rand -base64 32 +REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD +CELERY_BROKER_URL=redis://:${REDIS_PASSWORD}@redis:6379/0 +CELERY_RESULT_BACKEND=redis://:${REDIS_PASSWORD}@redis:6379/1 + +# --- Retention -------------------------------------------------------------- +JOB_RETENTION_DAYS=30 diff --git a/serienbrief/.gitignore b/serienbrief/.gitignore new file mode 100644 index 0000000..4fd059b --- /dev/null +++ b/serienbrief/.gitignore @@ -0,0 +1,39 @@ +# --- Secrets / Env --- +.env +.env.* +!.env.example +secrets/ +*.key +*.pem + +# --- Python --- +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +htmlcov/ +.tox/ + +# --- Django --- +*.sqlite3 +staticfiles/ +media/ + +# --- Backups & Data --- +backups/ +postgres_data/ +redis_data/ + +# --- IDE --- +.idea/ +*.swp +*.swo + +# --- OS --- +.DS_Store +Thumbs.db diff --git a/serienbrief/.vscode/extensions.json b/serienbrief/.vscode/extensions.json new file mode 100644 index 0000000..8fb2c50 --- /dev/null +++ b/serienbrief/.vscode/extensions.json @@ -0,0 +1,14 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.debugpy", + "charliermarsh.ruff", + "batisteo.vscode-django", + "ms-azuretools.vscode-docker", + "ms-vscode-remote.remote-containers", + "redhat.vscode-yaml", + "tamasfe.even-better-toml", + "eamodio.gitlens" + ] +} diff --git a/serienbrief/.vscode/launch.json b/serienbrief/.vscode/launch.json new file mode 100644 index 0000000..de50b70 --- /dev/null +++ b/serienbrief/.vscode/launch.json @@ -0,0 +1,53 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Django: attach (debugpy in Container)", + "type": "debugpy", + "request": "attach", + "connect": { "host": "127.0.0.1", "port": 5678 }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}/app", + "remoteRoot": "/app" + } + ], + "justMyCode": false, + "django": true + }, + { + "name": "Django: runserver (lokal, ohne Docker)", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/app/manage.py", + "args": ["runserver", "127.0.0.1:8000"], + "django": true, + "justMyCode": false, + "env": { + "DJANGO_SETTINGS_MODULE": "config.settings.dev" + } + }, + { + "name": "Celery Worker (lokal, ohne Docker)", + "type": "debugpy", + "request": "launch", + "module": "celery", + "args": ["-A", "config", "worker", "--loglevel=debug", "--concurrency=1"], + "cwd": "${workspaceFolder}/app", + "justMyCode": false, + "env": { + "DJANGO_SETTINGS_MODULE": "config.settings.dev" + } + }, + { + "name": "Pytest: aktuelle Datei", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": ["${file}", "-v"], + "cwd": "${workspaceFolder}/app", + "console": "integratedTerminal", + "justMyCode": false + } + ] +} diff --git a/serienbrief/.vscode/settings.json b/serienbrief/.vscode/settings.json new file mode 100644 index 0000000..ef08bb5 --- /dev/null +++ b/serienbrief/.vscode/settings.json @@ -0,0 +1,38 @@ +{ + "python.analysis.extraPaths": ["app"], + "python.analysis.typeCheckingMode": "basic", + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": ["app"], + "python.testing.cwd": "${workspaceFolder}/app", + + "[python]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.fixAll": "explicit" + } + }, + + "ruff.lineLength": 100, + + "files.associations": { + "**/templates/**/*.html": "django-html", + "**/requirements*.txt": "pip-requirements" + }, + "emmet.includeLanguages": { + "django-html": "html" + }, + + "files.exclude": { + "**/__pycache__": true, + "**/*.pyc": true, + "**/.pytest_cache": true, + "**/.ruff_cache": true + }, + "search.exclude": { + "**/staticfiles": true, + "**/media": true, + "**/backups": true + } +} diff --git a/serienbrief/.vscode/tasks.json b/serienbrief/.vscode/tasks.json new file mode 100644 index 0000000..74b4cda --- /dev/null +++ b/serienbrief/.vscode/tasks.json @@ -0,0 +1,72 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "compose: up", + "type": "shell", + "command": "docker compose up -d", + "problemMatcher": [] + }, + { + "label": "compose: down", + "type": "shell", + "command": "docker compose down", + "problemMatcher": [] + }, + { + "label": "compose: logs web", + "type": "shell", + "command": "docker compose logs -f web", + "problemMatcher": [] + }, + { + "label": "compose: rebuild web", + "type": "shell", + "command": "docker compose build web && docker compose up -d web", + "problemMatcher": [] + }, + { + "label": "django: makemigrations", + "type": "shell", + "command": "docker compose exec web python manage.py makemigrations", + "problemMatcher": [] + }, + { + "label": "django: migrate", + "type": "shell", + "command": "docker compose exec web python manage.py migrate", + "problemMatcher": [] + }, + { + "label": "django: shell", + "type": "shell", + "command": "docker compose exec web python manage.py shell", + "problemMatcher": [] + }, + { + "label": "django: createsuperuser", + "type": "shell", + "command": "docker compose exec web python manage.py createsuperuser", + "problemMatcher": [] + }, + { + "label": "django: start debugpy", + "type": "shell", + "command": "docker compose exec web python -m debugpy --listen 0.0.0.0:5678 manage.py runserver 0.0.0.0:8000 --noreload", + "problemMatcher": [] + }, + { + "label": "tests: pytest", + "type": "shell", + "command": "docker compose exec web pytest -v", + "group": "test", + "problemMatcher": [] + }, + { + "label": "lint: ruff", + "type": "shell", + "command": "docker compose exec web ruff check .", + "problemMatcher": [] + } + ] +} diff --git a/serienbrief/README.md b/serienbrief/README.md new file mode 100644 index 0000000..db26d1a --- /dev/null +++ b/serienbrief/README.md @@ -0,0 +1,125 @@ +# Serienbrief + +Django-Webanwendung zur Erzeugung von Serienbriefen aus DOCX-Vorlagen und CSV-Empfängerlisten. +Läuft als Compose-Stack, HTTP-only — TLS terminiert ein **vorgelagerter Nginx-Reverse-Proxy** außerhalb dieses Stacks. + +## Architektur + +``` +LAN-Client ──HTTPS──▶ Externer Nginx-Proxy ──HTTP──▶ App-Stack (dieses Repo) + (TLS-Terminierung) │ + ├─ nginx (intern, :8080) + ├─ web (Gunicorn / Django) + ├─ worker (Celery) + ├─ beat (Celery Scheduler) + ├─ db (PostgreSQL 16) + ├─ redis (Celery Broker) + └─ backup (nightly pg_dump) +``` + +Der externe Proxy muss diese Header setzen: +- `X-Forwarded-Proto: https` +- `X-Forwarded-For: ` +- `Host: ` + +## Verzeichnisstruktur + +``` +serienbrief/ +├── docker-compose.yml # Prod-Stack +├── docker-compose.override.yml # Dev-Overrides (Auto-Merge) +├── .env.example # Konfig-Template +├── .devcontainer/ # VS Code Dev-Container +├── .vscode/ # Launch, Tasks, Settings +├── nginx/ # App-interner Nginx (HTTP) +├── app/ # Django-Projekt +│ ├── Dockerfile # Multi-Stage: builder/dev/runtime +│ ├── requirements.txt +│ ├── requirements-dev.txt +│ ├── pyproject.toml # Ruff & Pytest +│ ├── manage.py +│ ├── config/ # Settings, URLs, WSGI +│ └── mailmerge/ # Django-App: Models, Views, Tasks +├── postgres/init/ # SQL-Init-Scripts (optional) +├── secrets/ # Passwort-Files (chmod 600) +└── backups/ # DB- & Media-Dumps +``` + +## Erstinbetriebnahme (Prod) + +```bash +cp .env.example .env +nano .env # Werte setzen + +mkdir -p secrets +openssl rand -base64 32 > secrets/postgres_password.txt +chmod 600 secrets/postgres_password.txt + +docker compose -f docker-compose.yml build +docker compose -f docker-compose.yml up -d +docker compose exec web python manage.py createsuperuser +``` + +Der externe Proxy zeigt dann z.B. so auf den Stack: +```nginx +location / { + proxy_pass http://app-host:8080; + 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; +} +``` + +## Entwicklung in VS Code + +### Variante A — Dev-Container (empfohlen) + +1. VS Code öffnet das Projekt +2. Extension **Dev Containers** installiert +3. Command Palette → `Dev Containers: Reopen in Container` +4. VS Code startet Compose mit Override (Build-Target `dev`, Code-Mount, Hot-Reload) +5. Terminal im Container: `python manage.py migrate` +6. Browser: `http://localhost:8000` + +### Variante B — Lokal mit Compose im Hintergrund + +```bash +docker compose up -d # Override wird automatisch geladen +docker compose logs -f web +``` + +Code-Änderungen sind sofort wirksam (Bind-Mount + `runserver` mit Auto-Reload). + +### Debugging + +Im Container läuft `debugpy` auf Port **5678**. In VS Code: +- Run & Debug → **Django: attach (debugpy in Container)** +- Breakpoints überall im Code setzen + +Alternativ Task **django: start debugpy** ausführen und attachen. + +### Häufige VS-Code-Tasks + +`Strg+Shift+P` → `Tasks: Run Task`: + +| Task | Wirkung | +|---|---| +| compose: up | Stack starten | +| compose: logs web | Live-Logs | +| django: makemigrations | Migrations erzeugen | +| django: migrate | Migrations anwenden | +| django: shell | Django-Shell | +| django: createsuperuser | Admin-User anlegen | +| tests: pytest | Tests laufen lassen | +| lint: ruff | Linting | + +## Härtungs-Hinweise + +- Externe Proxy-Konfiguration (TLS, HSTS, CSP, Rate-Limit) **muss** vorhanden sein +- `APP_BIND_IP=127.0.0.1` in `.env` außer der externe Proxy läuft auf anderem Host +- `DJANGO_ALLOWED_HOSTS` und `CSRF_TRUSTED_ORIGINS` strikt setzen +- `SECURE_PROXY_SSL_HEADER` ist gesetzt — damit erkennt Django korrekt, dass der Client über HTTPS kam +- Postgres und Redis sind **nicht** nach außen exponiert (nur im backend-Netz) +- Container laufen non-root, `read_only`, mit `no-new-privileges` +- Backups regelmäßig auf zweites System spiegeln (Borg/Restic) diff --git a/serienbrief/app/Dockerfile b/serienbrief/app/Dockerfile new file mode 100644 index 0000000..78f3d64 --- /dev/null +++ b/serienbrief/app/Dockerfile @@ -0,0 +1,86 @@ +# ============================================================================= +# Multi-Stage: builder → dev → runtime +# Dev-Image enthält zusätzlich debugpy, ipython, django-debug-toolbar. +# ============================================================================= + +# ---------- Stage 1: Build ---------------------------------------------------- +FROM python:3.12-slim-bookworm AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential libpq-dev gcc \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY requirements.txt requirements-dev.txt ./ +RUN pip wheel --wheel-dir=/wheels -r requirements.txt -r requirements-dev.txt + + +# ---------- Common Runtime Base ---------------------------------------------- +FROM python:3.12-slim-bookworm AS runtime-base + +ARG APP_UID=10001 +ARG APP_GID=10001 + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + libreoffice libreoffice-writer \ + fonts-liberation fonts-dejavu \ + tini curl \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* + +RUN groupadd -g ${APP_GID} app && \ + useradd -u ${APP_UID} -g ${APP_GID} -m -s /bin/bash app + +WORKDIR /app +COPY --from=builder /wheels /wheels + + +# ---------- Stage 2a: Runtime (Production) ------------------------------------ +FROM runtime-base AS runtime + +COPY requirements.txt . +RUN pip install --no-index --find-links=/wheels -r requirements.txt && \ + rm -rf /wheels + +COPY --chown=app:app . /app/ +RUN mkdir -p /app/staticfiles /app/media && chown -R app:app /app +USER app +EXPOSE 8000 +ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"] +CMD ["gunicorn", "config.wsgi:application", \ + "--bind", "0.0.0.0:8000", \ + "--workers", "3", "--threads", "2", \ + "--worker-class", "gthread", "--worker-tmp-dir", "/tmp", \ + "--access-logfile", "-", "--error-logfile", "-", \ + "--timeout", "120"] + + +# ---------- Stage 2b: Dev ----------------------------------------------------- +FROM runtime-base AS dev + +# Dev-Tools für Container & VS Code +RUN apt-get update && apt-get install -y --no-install-recommends \ + git bash-completion vim less procps iputils-ping \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt requirements-dev.txt ./ +RUN pip install --no-index --find-links=/wheels -r requirements.txt -r requirements-dev.txt && \ + rm -rf /wheels + +# Code wird im Dev via Volume gemountet; nichts kopieren. +RUN mkdir -p /app/staticfiles /app/media && chown -R app:app /app +USER app +EXPOSE 8000 5678 +ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"] +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/serienbrief/app/config/__init__.py b/serienbrief/app/config/__init__.py new file mode 100644 index 0000000..370372a --- /dev/null +++ b/serienbrief/app/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ["celery_app"] diff --git a/serienbrief/app/config/asgi.py b/serienbrief/app/config/asgi.py new file mode 100644 index 0000000..5096b23 --- /dev/null +++ b/serienbrief/app/config/asgi.py @@ -0,0 +1,6 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") +application = get_asgi_application() diff --git a/serienbrief/app/config/celery.py b/serienbrief/app/config/celery.py new file mode 100644 index 0000000..020f2a5 --- /dev/null +++ b/serienbrief/app/config/celery.py @@ -0,0 +1,9 @@ +import os + +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") + +app = Celery("serienbrief") +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/serienbrief/app/config/settings/__init__.py b/serienbrief/app/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/serienbrief/app/config/settings/base.py b/serienbrief/app/config/settings/base.py new file mode 100644 index 0000000..f10dce3 --- /dev/null +++ b/serienbrief/app/config/settings/base.py @@ -0,0 +1,144 @@ +""" +Basis-Settings. Werden von dev.py und production.py erweitert. +""" +from pathlib import Path + +import environ + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +env = environ.Env( + DJANGO_DEBUG=(bool, False), + USE_X_FORWARDED_HOST=(bool, True), + JOB_RETENTION_DAYS=(int, 30), +) + +# --- Core -------------------------------------------------------------------- +SECRET_KEY = env("DJANGO_SECRET_KEY", default="dev-insecure-change-me") +DEBUG = env("DJANGO_DEBUG") +ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"]) +CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[]) + +# Hinter dem äußeren Reverse-Proxy +USE_X_FORWARDED_HOST = env("USE_X_FORWARDED_HOST") +_proxy_header = env("SECURE_PROXY_SSL_HEADER", default="") +if _proxy_header: + name, value = _proxy_header.split(",", 1) + SECURE_PROXY_SSL_HEADER = (name.strip(), value.strip()) + +# --- Apps -------------------------------------------------------------------- +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # 3rd party + "django_celery_beat", + "django_celery_results", + "django_htmx", + "axes", + # local + "mailmerge", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django_htmx.middleware.HtmxMiddleware", + "axes.middleware.AxesMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +# --- Database ---------------------------------------------------------------- +DATABASES = { + "default": env.db_url( + "DATABASE_URL", default="sqlite:///" + str(BASE_DIR / "db.sqlite3") + ), +} + +# --- Auth -------------------------------------------------------------------- +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + "OPTIONS": {"min_length": 12}}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.Argon2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2PasswordHasher", +] + +AUTHENTICATION_BACKENDS = [ + "axes.backends.AxesStandaloneBackend", + "django.contrib.auth.backends.ModelBackend", +] + +LOGIN_URL = "/accounts/login/" +LOGIN_REDIRECT_URL = "/" +LOGOUT_REDIRECT_URL = "/accounts/login/" + +# django-axes – Brute-Force-Schutz +AXES_FAILURE_LIMIT = 5 +AXES_COOLOFF_TIME = 1 # Stunde +AXES_LOCKOUT_PARAMETERS = ["username", "ip_address"] + +# --- I18N / TZ --------------------------------------------------------------- +LANGUAGE_CODE = "de-at" +TIME_ZONE = "Europe/Vienna" +USE_I18N = True +USE_TZ = True + +# --- Static / Media ---------------------------------------------------------- +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + +# --- Celery ------------------------------------------------------------------ +CELERY_BROKER_URL = env("CELERY_BROKER_URL", default="redis://redis:6379/0") +CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", default="django-db") +CELERY_TASK_TRACK_STARTED = True +CELERY_TASK_TIME_LIMIT = 600 # 10 Minuten Hard-Timeout +CELERY_TASK_SOFT_TIME_LIMIT = 540 +CELERY_WORKER_PREFETCH_MULTIPLIER = 1 +CELERY_TIMEZONE = TIME_ZONE + +# --- App --------------------------------------------------------------------- +JOB_RETENTION_DAYS = env("JOB_RETENTION_DAYS") +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# --- Security Defaults ------------------------------------------------------- +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_HTTPONLY = False # Bleibt false, damit JS/HTMX-Forms funktionieren +CSRF_COOKIE_SAMESITE = "Lax" +X_FRAME_OPTIONS = "DENY" +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin" diff --git a/serienbrief/app/config/settings/dev.py b/serienbrief/app/config/settings/dev.py new file mode 100644 index 0000000..b42785a --- /dev/null +++ b/serienbrief/app/config/settings/dev.py @@ -0,0 +1,16 @@ +from .base import * # noqa: F401,F403 +from .base import INSTALLED_APPS, MIDDLEWARE + +DEBUG = True +ALLOWED_HOSTS = ["*"] + +# Debug-Toolbar nur lokal +INSTALLED_APPS = INSTALLED_APPS + ["debug_toolbar"] +MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE] +INTERNAL_IPS = ["127.0.0.1"] + +# Im Dev keine Auto-Lockouts beim Testen +AXES_ENABLED = False + +# E-Mails nur in die Konsole +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" diff --git a/serienbrief/app/config/settings/production.py b/serienbrief/app/config/settings/production.py new file mode 100644 index 0000000..6fa51d1 --- /dev/null +++ b/serienbrief/app/config/settings/production.py @@ -0,0 +1,32 @@ +from .base import * # noqa: F401,F403 + +DEBUG = False + +# Strikte Security-Defaults – TLS macht der äußere Proxy. +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SECURE_HSTS_SECONDS = 0 # HSTS setzt der äußere Proxy +SECURE_HSTS_INCLUDE_SUBDOMAINS = False +SECURE_HSTS_PRELOAD = False + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{asctime} {levelname} {name} {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "root": {"handlers": ["console"], "level": "INFO"}, + "loggers": { + "django.security": {"handlers": ["console"], "level": "WARNING", "propagate": False}, + "mailmerge": {"handlers": ["console"], "level": "INFO", "propagate": False}, + }, +} diff --git a/serienbrief/app/config/urls.py b/serienbrief/app/config/urls.py new file mode 100644 index 0000000..f44ac97 --- /dev/null +++ b/serienbrief/app/config/urls.py @@ -0,0 +1,27 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.contrib.auth import views as auth_views +from django.http import HttpResponse +from django.urls import include, path + + +def healthz(_request): + return HttpResponse("ok", content_type="text/plain") + + +urlpatterns = [ + path("healthz", healthz), + path("admin/", admin.site.urls), + path("accounts/login/", auth_views.LoginView.as_view(), name="login"), + path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"), + path("", include("mailmerge.urls")), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + try: + import debug_toolbar + urlpatterns = [path("__debug__/", include(debug_toolbar.urls)), *urlpatterns] + except ImportError: + pass diff --git a/serienbrief/app/config/wsgi.py b/serienbrief/app/config/wsgi.py new file mode 100644 index 0000000..949d08d --- /dev/null +++ b/serienbrief/app/config/wsgi.py @@ -0,0 +1,6 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") +application = get_wsgi_application() diff --git a/serienbrief/app/entrypoint.sh b/serienbrief/app/entrypoint.sh new file mode 100755 index 0000000..2c30784 --- /dev/null +++ b/serienbrief/app/entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail +ROLE="${ROLE:-web}" + +if [[ "$ROLE" == "web" ]]; then + echo "[entrypoint] waiting for database..." + python manage.py wait_for_db --timeout 60 || true + + echo "[entrypoint] running migrations..." + python manage.py migrate --noinput + + if [[ "${DJANGO_DEBUG:-False}" != "True" ]]; then + echo "[entrypoint] collecting static files..." + python manage.py collectstatic --noinput --clear + fi +fi + +echo "[entrypoint] starting role=$ROLE: $*" +exec "$@" diff --git a/serienbrief/app/mailmerge/__init__.py b/serienbrief/app/mailmerge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/serienbrief/app/mailmerge/admin.py b/serienbrief/app/mailmerge/admin.py new file mode 100644 index 0000000..5587a21 --- /dev/null +++ b/serienbrief/app/mailmerge/admin.py @@ -0,0 +1,25 @@ +from django.contrib import admin + +from .models import JobLogEntry, LetterTemplate, MailMergeJob + + +@admin.register(LetterTemplate) +class LetterTemplateAdmin(admin.ModelAdmin): + list_display = ("name", "created_by", "created_at") + search_fields = ("name",) + readonly_fields = ("id", "placeholders", "created_at", "updated_at") + + +@admin.register(MailMergeJob) +class MailMergeJobAdmin(admin.ModelAdmin): + list_display = ("id", "template", "status", "processed_rows", "total_rows", + "created_by", "created_at") + list_filter = ("status",) + readonly_fields = ("id", "created_at", "started_at", "finished_at", + "processed_rows", "total_rows", "error_message") + + +@admin.register(JobLogEntry) +class JobLogEntryAdmin(admin.ModelAdmin): + list_display = ("job", "level", "timestamp") + list_filter = ("level",) diff --git a/serienbrief/app/mailmerge/apps.py b/serienbrief/app/mailmerge/apps.py new file mode 100644 index 0000000..5e5874e --- /dev/null +++ b/serienbrief/app/mailmerge/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class MailmergeConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "mailmerge" + verbose_name = "Serienbrief" diff --git a/serienbrief/app/mailmerge/forms.py b/serienbrief/app/mailmerge/forms.py new file mode 100644 index 0000000..c966b51 --- /dev/null +++ b/serienbrief/app/mailmerge/forms.py @@ -0,0 +1,34 @@ +from django import forms + +from .models import LetterTemplate, MailMergeJob + + +class LetterTemplateForm(forms.ModelForm): + class Meta: + model = LetterTemplate + fields = ["name", "description", "file"] + widgets = { + "description": forms.Textarea(attrs={"rows": 3}), + } + + def clean_file(self): + f = self.cleaned_data["file"] + if not f.name.lower().endswith(".docx"): + raise forms.ValidationError("Nur .docx-Dateien sind erlaubt.") + if f.size > 10 * 1024 * 1024: + raise forms.ValidationError("Datei zu groß (max. 10 MB).") + return f + + +class MailMergeJobForm(forms.ModelForm): + class Meta: + model = MailMergeJob + fields = ["template", "recipients_csv"] + + def clean_recipients_csv(self): + f = self.cleaned_data["recipients_csv"] + if not f.name.lower().endswith(".csv"): + raise forms.ValidationError("Nur .csv-Dateien sind erlaubt.") + if f.size > 20 * 1024 * 1024: + raise forms.ValidationError("Datei zu groß (max. 20 MB).") + return f diff --git a/serienbrief/app/mailmerge/management/__init__.py b/serienbrief/app/mailmerge/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/serienbrief/app/mailmerge/management/commands/__init__.py b/serienbrief/app/mailmerge/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/serienbrief/app/mailmerge/management/commands/wait_for_db.py b/serienbrief/app/mailmerge/management/commands/wait_for_db.py new file mode 100644 index 0000000..e6bddd1 --- /dev/null +++ b/serienbrief/app/mailmerge/management/commands/wait_for_db.py @@ -0,0 +1,25 @@ +"""Wartet, bis die DB Connections annimmt.""" +import time + +from django.core.management.base import BaseCommand +from django.db import OperationalError, connections + + +class Command(BaseCommand): + help = "Wartet, bis die Standard-DB verfügbar ist." + + def add_arguments(self, parser): + parser.add_argument("--timeout", type=int, default=60) + + def handle(self, *args, **opts): + deadline = time.time() + opts["timeout"] + while time.time() < deadline: + try: + connections["default"].ensure_connection() + self.stdout.write(self.style.SUCCESS("DB ist bereit.")) + return + except OperationalError: + self.stdout.write("warte auf DB...") + time.sleep(2) + self.stderr.write(self.style.ERROR("DB nach Timeout nicht erreichbar.")) + raise SystemExit(1) diff --git a/serienbrief/app/mailmerge/migrations/__init__.py b/serienbrief/app/mailmerge/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/serienbrief/app/mailmerge/models.py b/serienbrief/app/mailmerge/models.py new file mode 100644 index 0000000..33d4160 --- /dev/null +++ b/serienbrief/app/mailmerge/models.py @@ -0,0 +1,86 @@ +""" +Datenmodell – Templates, Jobs, Log-Einträge. +""" +import uuid +from pathlib import Path + +from django.conf import settings +from django.db import models + + +def template_upload_path(instance, filename: str) -> str: + return f"templates/{instance.id}/{Path(filename).name}" + + +def csv_upload_path(instance, filename: str) -> str: + return f"jobs/{instance.id}/recipients/{Path(filename).name}" + + +def result_upload_path(instance, filename: str) -> str: + return f"jobs/{instance.id}/result/{Path(filename).name}" + + +class LetterTemplate(models.Model): + """DOCX-Vorlage mit Jinja-Platzhaltern.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + file = models.FileField(upload_to=template_upload_path) + placeholders = models.JSONField(default=list, blank=True, + help_text="Aus dem DOCX extrahierte Variablen.") + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, + related_name="templates") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return self.name + + +class MailMergeJob(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "Wartet" + RUNNING = "running", "Läuft" + DONE = "done", "Fertig" + FAILED = "failed", "Fehlgeschlagen" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + template = models.ForeignKey(LetterTemplate, on_delete=models.PROTECT, + related_name="jobs") + recipients_csv = models.FileField(upload_to=csv_upload_path) + status = models.CharField(max_length=20, choices=Status.choices, + default=Status.PENDING) + result_pdf = models.FileField(upload_to=result_upload_path, null=True, blank=True) + total_rows = models.PositiveIntegerField(default=0) + processed_rows = models.PositiveIntegerField(default=0) + error_message = models.TextField(blank=True) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, + related_name="jobs") + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"Job {self.id} ({self.template.name})" + + +class JobLogEntry(models.Model): + class Level(models.TextChoices): + INFO = "info", "Info" + WARNING = "warning", "Warnung" + ERROR = "error", "Fehler" + + job = models.ForeignKey(MailMergeJob, on_delete=models.CASCADE, related_name="logs") + level = models.CharField(max_length=10, choices=Level.choices, default=Level.INFO) + message = models.TextField() + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["timestamp"] diff --git a/serienbrief/app/mailmerge/services/__init__.py b/serienbrief/app/mailmerge/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/serienbrief/app/mailmerge/services/docx_renderer.py b/serienbrief/app/mailmerge/services/docx_renderer.py new file mode 100644 index 0000000..8e5a034 --- /dev/null +++ b/serienbrief/app/mailmerge/services/docx_renderer.py @@ -0,0 +1,74 @@ +""" +DOCX-Rendering und PDF-Konvertierung. +""" +from __future__ import annotations + +import logging +import re +import subprocess +import tempfile +from pathlib import Path + +from docx import Document +from docxtpl import DocxTemplate + +logger = logging.getLogger(__name__) + +PLACEHOLDER_RE = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)") + + +def extract_placeholders(docx_path: Path) -> list[str]: + """Liest die Jinja-Platzhalter aus einem DOCX und gibt sie sortiert zurück.""" + doc = Document(str(docx_path)) + found: set[str] = set() + for para in doc.paragraphs: + for m in PLACEHOLDER_RE.finditer(para.text): + found.add(m.group(1)) + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + for para in cell.paragraphs: + for m in PLACEHOLDER_RE.finditer(para.text): + found.add(m.group(1)) + return sorted(found) + + +def render_docx(template_path: Path, context: dict, out_path: Path) -> Path: + """Füllt das DOCX-Template mit Kontext und schreibt das Ergebnis.""" + tpl = DocxTemplate(str(template_path)) + tpl.render(context) + tpl.save(str(out_path)) + return out_path + + +def docx_to_pdf(docx_path: Path, out_dir: Path) -> Path: + """Konvertiert DOCX nach PDF mit LibreOffice headless. + + LibreOffice braucht ein eigenes Profilverzeichnis, sonst kollidieren + parallele Worker. + """ + out_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="lo-profile-") as profile_dir: + cmd = [ + "soffice", + "--headless", + "--nologo", + "--norestore", + "--nolockcheck", + f"-env:UserInstallation=file://{profile_dir}", + "--convert-to", "pdf", + "--outdir", str(out_dir), + str(docx_path), + ] + logger.info("LibreOffice convert: %s", " ".join(cmd)) + result = subprocess.run( # noqa: S603 + cmd, capture_output=True, text=True, timeout=120, check=False + ) + if result.returncode != 0: + raise RuntimeError( + f"LibreOffice-Konvertierung fehlgeschlagen: {result.stderr}" + ) + pdf_path = out_dir / (docx_path.stem + ".pdf") + if not pdf_path.exists(): + raise FileNotFoundError(f"PDF nicht gefunden: {pdf_path}") + return pdf_path diff --git a/serienbrief/app/mailmerge/services/pdf_merge.py b/serienbrief/app/mailmerge/services/pdf_merge.py new file mode 100644 index 0000000..89d9294 --- /dev/null +++ b/serienbrief/app/mailmerge/services/pdf_merge.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from pypdf import PdfWriter + + +def merge_pdfs(pdfs: list[Path], out_path: Path) -> Path: + writer = PdfWriter() + for pdf in pdfs: + writer.append(str(pdf)) + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("wb") as f: + writer.write(f) + writer.close() + return out_path diff --git a/serienbrief/app/mailmerge/tasks.py b/serienbrief/app/mailmerge/tasks.py new file mode 100644 index 0000000..32ce487 --- /dev/null +++ b/serienbrief/app/mailmerge/tasks.py @@ -0,0 +1,87 @@ +""" +Celery-Tasks für die PDF-Erzeugung. +""" +from __future__ import annotations + +import csv +import io +import logging +import tempfile +from pathlib import Path + +from celery import shared_task +from django.core.files import File +from django.utils import timezone + +from .models import JobLogEntry, MailMergeJob +from .services.docx_renderer import docx_to_pdf, render_docx +from .services.pdf_merge import merge_pdfs + +logger = logging.getLogger(__name__) + + +def _log(job: MailMergeJob, level: str, msg: str) -> None: + JobLogEntry.objects.create(job=job, level=level, message=msg) + logger.log(getattr(logging, level.upper(), logging.INFO), "[job %s] %s", job.id, msg) + + +@shared_task(bind=True) +def run_mailmerge(self, job_id: str) -> str: + job = MailMergeJob.objects.select_related("template").get(pk=job_id) + job.status = MailMergeJob.Status.RUNNING + job.started_at = timezone.now() + job.save(update_fields=["status", "started_at"]) + _log(job, "info", f"Job gestartet (task={self.request.id}).") + + try: + # CSV einlesen + raw = job.recipients_csv.read().decode("utf-8-sig") + reader = csv.DictReader(io.StringIO(raw)) + rows = list(reader) + job.total_rows = len(rows) + job.save(update_fields=["total_rows"]) + _log(job, "info", f"{len(rows)} Empfänger gefunden.") + + if not rows: + raise ValueError("CSV enthält keine Datenzeilen.") + + # CSV-Felder vs. Template-Platzhalter prüfen + csv_fields = set(reader.fieldnames or []) + placeholders = set(job.template.placeholders or []) + missing = placeholders - csv_fields + if missing: + _log(job, "warning", + f"CSV fehlen Spalten: {', '.join(sorted(missing))}") + + # Jeden Brief rendern, alle zu einem PDF zusammenführen + with tempfile.TemporaryDirectory(prefix="mailmerge-") as tmpdir: + tmp = Path(tmpdir) + pdfs: list[Path] = [] + template_path = Path(job.template.file.path) + + for idx, row in enumerate(rows, start=1): + docx_out = tmp / f"letter_{idx:05d}.docx" + render_docx(template_path, row, docx_out) + pdf = docx_to_pdf(docx_out, tmp / "pdf") + pdfs.append(pdf) + job.processed_rows = idx + job.save(update_fields=["processed_rows"]) + + merged = tmp / f"serienbrief_{job.id}.pdf" + merge_pdfs(pdfs, merged) + with merged.open("rb") as f: + job.result_pdf.save(merged.name, File(f), save=False) + + job.status = MailMergeJob.Status.DONE + job.finished_at = timezone.now() + job.save() + _log(job, "info", "Job erfolgreich abgeschlossen.") + return str(job.id) + + except Exception as exc: # noqa: BLE001 + job.status = MailMergeJob.Status.FAILED + job.error_message = str(exc) + job.finished_at = timezone.now() + job.save() + _log(job, "error", f"Job fehlgeschlagen: {exc}") + raise diff --git a/serienbrief/app/mailmerge/urls.py b/serienbrief/app/mailmerge/urls.py new file mode 100644 index 0000000..72a9acf --- /dev/null +++ b/serienbrief/app/mailmerge/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("", views.dashboard, name="dashboard"), + path("templates/new/", views.template_upload, name="template-upload"), + path("templates//", views.template_detail, name="template-detail"), + path("jobs/new/", views.job_create, name="job-create"), + path("jobs//", views.job_detail, name="job-detail"), + path("jobs//download/", views.job_download, name="job-download"), +] diff --git a/serienbrief/app/mailmerge/views.py b/serienbrief/app/mailmerge/views.py new file mode 100644 index 0000000..e6c548c --- /dev/null +++ b/serienbrief/app/mailmerge/views.py @@ -0,0 +1,94 @@ +from pathlib import Path + +from django.contrib.auth.decorators import login_required +from django.http import FileResponse, Http404, HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.views.decorators.http import require_http_methods + +from .forms import LetterTemplateForm, MailMergeJobForm +from .models import LetterTemplate, MailMergeJob +from .services.docx_renderer import extract_placeholders +from .tasks import run_mailmerge + + +@login_required +def dashboard(request): + templates = LetterTemplate.objects.all()[:20] + jobs = MailMergeJob.objects.select_related("template")[:20] + return render(request, "mailmerge/dashboard.html", + {"templates": templates, "jobs": jobs}) + + +@login_required +@require_http_methods(["GET", "POST"]) +def template_upload(request): + form = LetterTemplateForm(request.POST or None, request.FILES or None) + if request.method == "POST" and form.is_valid(): + tpl = form.save(commit=False) + tpl.created_by = request.user + tpl.save() + # Platzhalter extrahieren – Datei liegt jetzt auf Disk + tpl.placeholders = extract_placeholders(Path(tpl.file.path)) + tpl.save(update_fields=["placeholders"]) + return redirect(reverse("template-detail", args=[tpl.id])) + return render(request, "mailmerge/template_form.html", {"form": form}) + + +@login_required +def template_detail(request, pk): + tpl = get_object_or_404(LetterTemplate, pk=pk) + return render(request, "mailmerge/template_detail.html", {"template": tpl}) + + +@login_required +@require_http_methods(["GET", "POST"]) +def job_create(request): + form = MailMergeJobForm(request.POST or None, request.FILES or None) + if request.method == "POST" and form.is_valid(): + job = form.save(commit=False) + job.created_by = request.user + job.save() + run_mailmerge.delay(str(job.id)) + return redirect(reverse("job-detail", args=[job.id])) + return render(request, "mailmerge/job_form.html", {"form": form}) + + +@login_required +def job_detail(request, pk): + job = get_object_or_404( + MailMergeJob.objects.select_related("template"), pk=pk + ) + logs = job.logs.all() + # HTMX partials: nur das Status-Fragment ausliefern + if request.headers.get("HX-Request"): + return render(request, "mailmerge/_job_status.html", + {"job": job, "logs": logs}) + return render(request, "mailmerge/job_detail.html", + {"job": job, "logs": logs}) + + +@login_required +def job_download(request, pk): + """PDF-Download – via X-Accel-Redirect in Production, direkter Stream im Dev.""" + job = get_object_or_404(MailMergeJob, pk=pk) + if not job.result_pdf: + raise Http404("Kein Ergebnis-PDF vorhanden.") + + # In Dev (settings.DEBUG) direkt streamen + from django.conf import settings + if settings.DEBUG: + return FileResponse(job.result_pdf.open("rb"), + as_attachment=True, + filename=Path(job.result_pdf.name).name) + + # In Production: Nginx serviert die Datei via internem Mount. + response = HttpResponse() + response["Content-Type"] = "application/pdf" + response["Content-Disposition"] = ( + f'attachment; filename="{Path(job.result_pdf.name).name}"' + ) + # Pfad relativ zu MEDIA_ROOT, gemappt auf /protected-media/ + relative = job.result_pdf.name + response["X-Accel-Redirect"] = f"/protected-media/{relative}" + return response diff --git a/serienbrief/app/manage.py b/serienbrief/app/manage.py new file mode 100644 index 0000000..fef1f23 --- /dev/null +++ b/serienbrief/app/manage.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +"""Django management entry point.""" +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Django konnte nicht importiert werden. Ist die venv aktiv?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/serienbrief/app/pyproject.toml b/serienbrief/app/pyproject.toml new file mode 100644 index 0000000..87a87b3 --- /dev/null +++ b/serienbrief/app/pyproject.toml @@ -0,0 +1,17 @@ +[tool.ruff] +line-length = 100 +target-version = "py312" +extend-exclude = ["migrations", "staticfiles", "media"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "DJ", "S"] +ignore = ["E501", "S101"] + +[tool.ruff.lint.per-file-ignores] +"**/tests/**" = ["S"] +"**/settings/**" = ["S105", "S106"] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings.dev" +python_files = ["test_*.py", "*_test.py", "tests.py"] +addopts = "--reuse-db -ra" diff --git a/serienbrief/app/requirements-dev.txt b/serienbrief/app/requirements-dev.txt new file mode 100644 index 0000000..014fada --- /dev/null +++ b/serienbrief/app/requirements-dev.txt @@ -0,0 +1,8 @@ +# Nur im dev-Build installieren +debugpy==1.8.7 +django-debug-toolbar==4.4.6 +ipython==8.29.0 +ruff==0.7.4 +pytest==8.3.3 +pytest-django==4.9.0 +factory-boy==3.3.1 diff --git a/serienbrief/app/requirements.txt b/serienbrief/app/requirements.txt new file mode 100644 index 0000000..25827b4 --- /dev/null +++ b/serienbrief/app/requirements.txt @@ -0,0 +1,15 @@ +Django==5.1.4 +django-environ==0.11.2 +django-celery-beat==2.7.0 +django-celery-results==2.5.1 +psycopg[binary]==3.2.3 +gunicorn==23.0.0 +celery[redis]==5.4.0 +redis==5.2.0 +docxtpl==0.19.0 +python-docx==1.1.2 +pypdf==5.1.0 +argon2-cffi==23.1.0 +django-axes==7.0.1 +django-htmx==1.21.0 +python-dateutil==2.9.0.post0 diff --git a/serienbrief/app/templates/base.html b/serienbrief/app/templates/base.html new file mode 100644 index 0000000..55a413c --- /dev/null +++ b/serienbrief/app/templates/base.html @@ -0,0 +1,41 @@ + + + + + + {% block title %}Serienbrief{% endblock %} + + + + + {% if messages %}
    {% for m in messages %}
  • {{ m }}
  • {% endfor %}
{% endif %} + {% block content %}{% endblock %} + + diff --git a/serienbrief/app/templates/mailmerge/_job_status.html b/serienbrief/app/templates/mailmerge/_job_status.html new file mode 100644 index 0000000..73ab620 --- /dev/null +++ b/serienbrief/app/templates/mailmerge/_job_status.html @@ -0,0 +1,25 @@ +
+

Vorlage: {{ job.template.name }}

+

Status: + {{ job.get_status_display }} +

+

Fortschritt: {{ job.processed_rows }} / {{ job.total_rows }}

+ + {% if job.status == "done" %} +

PDF herunterladen

+ {% endif %} + {% if job.error_message %} +

Fehler: {{ job.error_message }}

+ {% endif %} + +

Log

+
+ {% for entry in logs %} +
+ [{{ entry.timestamp|date:"H:i:s" }}] {{ entry.level|upper }} – {{ entry.message }} +
+ {% empty %} + Keine Einträge. + {% endfor %} +
+
diff --git a/serienbrief/app/templates/mailmerge/dashboard.html b/serienbrief/app/templates/mailmerge/dashboard.html new file mode 100644 index 0000000..de6c7d5 --- /dev/null +++ b/serienbrief/app/templates/mailmerge/dashboard.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block content %} +

Übersicht

+ +

Vorlagen

+Neue Vorlage hochladen + + + + {% for t in templates %} + + + + + + {% empty %} + + {% endfor %} + +
NamePlatzhalterErstellt
{{ t.name }}{{ t.placeholders|join:", " }}{{ t.created_at|date:"d.m.Y H:i" }}
Noch keine Vorlagen vorhanden.
+ +

Aufträge

+Neuen Serienbrief erstellen + + + + {% for j in jobs %} + + + + + + + + {% empty %} + + {% endfor %} + +
IDVorlageStatusFortschrittErstellt
{{ j.id|stringformat:"s"|slice:":8" }}…{{ j.template.name }}{{ j.get_status_display }}{{ j.processed_rows }} / {{ j.total_rows }}{{ j.created_at|date:"d.m.Y H:i" }}
Noch keine Aufträge.
+{% endblock %} diff --git a/serienbrief/app/templates/mailmerge/job_detail.html b/serienbrief/app/templates/mailmerge/job_detail.html new file mode 100644 index 0000000..4248044 --- /dev/null +++ b/serienbrief/app/templates/mailmerge/job_detail.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block content %} +

Auftrag {{ job.id|stringformat:"s"|slice:":8" }}…

+ +
+ {% include "mailmerge/_job_status.html" %} +
+ + +{% endblock %} diff --git a/serienbrief/app/templates/mailmerge/job_form.html b/serienbrief/app/templates/mailmerge/job_form.html new file mode 100644 index 0000000..ab7fe9b --- /dev/null +++ b/serienbrief/app/templates/mailmerge/job_form.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block content %} +

Neuer Serienbrief

+

Vorlage und Empfänger-CSV auswählen. Die Spaltennamen der CSV müssen mit den Platzhaltern der Vorlage übereinstimmen (erste Zeile = Spaltennamen).

+
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} diff --git a/serienbrief/app/templates/mailmerge/template_detail.html b/serienbrief/app/templates/mailmerge/template_detail.html new file mode 100644 index 0000000..b960965 --- /dev/null +++ b/serienbrief/app/templates/mailmerge/template_detail.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block content %} +

{{ template.name }}

+

{{ template.description }}

+

Datei: {{ template.file.name }}

+

Erkannte Platzhalter:

+
    + {% for p in template.placeholders %}
  • {{ p }}
  • {% empty %}
  • Keine gefunden.
  • {% endfor %} +
+Serienbrief mit dieser Vorlage erstellen +{% endblock %} diff --git a/serienbrief/app/templates/mailmerge/template_form.html b/serienbrief/app/templates/mailmerge/template_form.html new file mode 100644 index 0000000..8f13700 --- /dev/null +++ b/serienbrief/app/templates/mailmerge/template_form.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block content %} +

Neue Vorlage hochladen

+

DOCX-Datei mit Platzhaltern wie {{ vorname }}, {{ nachname }}, … Die Spaltennamen der späteren CSV müssen mit den Platzhaltern übereinstimmen.

+
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} diff --git a/serienbrief/app/templates/registration/login.html b/serienbrief/app/templates/registration/login.html new file mode 100644 index 0000000..31a85e8 --- /dev/null +++ b/serienbrief/app/templates/registration/login.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}Anmelden{% endblock %} +{% block content %} +

Anmelden

+
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} diff --git a/serienbrief/docker-compose.override.yml b/serienbrief/docker-compose.override.yml new file mode 100644 index 0000000..f0b7a76 --- /dev/null +++ b/serienbrief/docker-compose.override.yml @@ -0,0 +1,62 @@ +# ============================================================================= +# Dev-Overrides – wird von "docker compose" automatisch mit gemerged. +# Für reine Produktion: `docker compose -f docker-compose.yml up -d` +# ============================================================================= + +services: + web: + build: + target: dev + command: ["python", "manage.py", "runserver", "0.0.0.0:8000"] + environment: + DJANGO_SETTINGS_MODULE: config.settings.dev + ROLE: web + ports: + # Direkter Zugriff von Host für VS Code & Browser + - "127.0.0.1:8000:8000" + # debugpy für VS Code Remote-Debug + - "127.0.0.1:5678:5678" + volumes: + # Live-Reload: lokaler Code wird in den Container gemountet + - ./app:/app:cached + - static_files:/app/staticfiles + - media_files:/app/media + read_only: false + healthcheck: + disable: true + + worker: + build: + target: dev + command: ["celery", "-A", "config", "worker", "--loglevel=debug", "--concurrency=1"] + environment: + DJANGO_SETTINGS_MODULE: config.settings.dev + ROLE: worker + volumes: + - ./app:/app:cached + - media_files:/app/media + read_only: false + healthcheck: + disable: true + + beat: + build: + target: dev + environment: + DJANGO_SETTINGS_MODULE: config.settings.dev + volumes: + - ./app:/app:cached + read_only: false + + db: + ports: + # Lokaler DB-Zugriff via DBeaver/psql – nur Loopback + - "127.0.0.1:5432:5432" + + redis: + ports: + - "127.0.0.1:6379:6379" + + # Backup im Dev nicht nötig + backup: + profiles: ["never"] diff --git a/serienbrief/docker-compose.yml b/serienbrief/docker-compose.yml new file mode 100644 index 0000000..781ac80 --- /dev/null +++ b/serienbrief/docker-compose.yml @@ -0,0 +1,309 @@ +# ============================================================================= +# Serienbrief – Compose (Production) +# Ubuntu Server 22.04/24.04 LTS · Docker Engine 27.x · Compose v2 +# ============================================================================= +# Die App spricht intern nur HTTP. TLS terminiert ein vorgelagerter Nginx- +# Reverse-Proxy (außerhalb dieses Compose-Stacks, z.B. zentraler LAN-Proxy). +# Der hier enthaltene "nginx"-Service ist NUR ein App-interner Proxy für: +# - Ausspielen von static/media über X-Accel-Redirect +# - Connection-Pooling und einfache Rate-Limits +# ============================================================================= + +name: serienbrief + +x-logging: &default-logging + driver: "json-file" + options: + max-size: "10m" + max-file: "5" + tag: "{{.Name}}" + +x-restart: &default-restart + restart: unless-stopped + +x-security-opts: &default-security-opts + security_opt: + - no-new-privileges:true + +services: + # --------------------------------------------------------------------------- + # App-interner Nginx – nimmt Traffic vom äußeren Proxy entgegen (HTTP). + # Hört nur auf Loopback bzw. dem konfigurierten LAN-Bind. + # --------------------------------------------------------------------------- + nginx: + image: nginx:1.27-alpine + <<: [*default-restart, *default-security-opts] + depends_on: + web: + condition: service_healthy + ports: + # Standardmäßig nur lokal – der externe Proxy spricht über das Docker- + # Host-Interface. Für direkten LAN-Zugriff LAN_BIND_IP in .env setzen. + - "${APP_BIND_IP:-127.0.0.1}:${APP_BIND_PORT:-8080}:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - static_files:/var/www/static:ro + - media_files:/var/www/media:ro + - nginx_cache:/var/cache/nginx + - nginx_run:/var/run + networks: + - frontend + logging: *default-logging + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/healthz"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + memory: 128M + cpus: "0.5" + + # --------------------------------------------------------------------------- + # Django – Gunicorn WSGI + # --------------------------------------------------------------------------- + web: + build: + context: ./app + dockerfile: Dockerfile + target: runtime + args: + APP_UID: ${APP_UID:-10001} + APP_GID: ${APP_GID:-10001} + image: serienbrief/web:${APP_VERSION:-latest} + <<: [*default-restart, *default-security-opts] + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + env_file: .env + environment: + DJANGO_SETTINGS_MODULE: config.settings.production + ROLE: web + volumes: + - static_files:/app/staticfiles + - media_files:/app/media + networks: + - frontend + - backend + logging: *default-logging + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3).status==200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + read_only: true + tmpfs: + - /tmp:size=256M,mode=1777 + deploy: + resources: + limits: + memory: 1G + cpus: "1.5" + + # --------------------------------------------------------------------------- + # Celery Worker – DOCX→PDF-Generierung + # --------------------------------------------------------------------------- + worker: + image: serienbrief/web:${APP_VERSION:-latest} + <<: [*default-restart, *default-security-opts] + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + web: + condition: service_started + env_file: .env + environment: + DJANGO_SETTINGS_MODULE: config.settings.production + ROLE: worker + command: ["celery", "-A", "config", "worker", "--loglevel=info", "--concurrency=2", "--max-tasks-per-child=50"] + volumes: + - media_files:/app/media + networks: + - backend + logging: *default-logging + healthcheck: + test: ["CMD-SHELL", "celery -A config inspect ping -d celery@$$HOSTNAME || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + read_only: true + tmpfs: + - /tmp:size=512M,mode=1777 + deploy: + resources: + limits: + memory: 2G + cpus: "2.0" + + # --------------------------------------------------------------------------- + # Celery Beat – Scheduler (Retention, Cleanup) + # --------------------------------------------------------------------------- + beat: + image: serienbrief/web:${APP_VERSION:-latest} + <<: [*default-restart, *default-security-opts] + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + env_file: .env + environment: + DJANGO_SETTINGS_MODULE: config.settings.production + ROLE: beat + command: ["celery", "-A", "config", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"] + networks: + - backend + logging: *default-logging + read_only: true + tmpfs: + - /tmp:size=64M,mode=1777 + deploy: + resources: + limits: + memory: 256M + cpus: "0.3" + + # --------------------------------------------------------------------------- + # PostgreSQL + # --------------------------------------------------------------------------- + db: + image: postgres:16-alpine + <<: [*default-restart, *default-security-opts] + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + PGDATA: /var/lib/postgresql/data/pgdata + secrets: + - postgres_password + volumes: + - postgres_data:/var/lib/postgresql/data + - ./postgres/init:/docker-entrypoint-initdb.d:ro + networks: + - backend + logging: *default-logging + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + shm_size: 256mb + deploy: + resources: + limits: + memory: 1G + cpus: "1.0" + + # --------------------------------------------------------------------------- + # Redis – Celery Broker + # --------------------------------------------------------------------------- + redis: + image: redis:7-alpine + <<: [*default-restart, *default-security-opts] + command: + - "redis-server" + - "--requirepass" + - "${REDIS_PASSWORD}" + - "--maxmemory" + - "256mb" + - "--maxmemory-policy" + - "allkeys-lru" + - "--save" + - "900 1" + - "--appendonly" + - "yes" + volumes: + - redis_data:/data + networks: + - backend + logging: *default-logging + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "--no-auth-warning", "ping"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + memory: 384M + cpus: "0.5" + + # --------------------------------------------------------------------------- + # Backup – pg_dump + Media-Tar, nightly, 14 Tage Retention + # --------------------------------------------------------------------------- + backup: + image: postgres:16-alpine + <<: [*default-restart, *default-security-opts] + depends_on: + db: + condition: service_healthy + environment: + PGHOST: db + PGUSER: ${POSTGRES_USER} + PGDATABASE: ${POSTGRES_DB} + PGPASSWORD_FILE: /run/secrets/postgres_password + BACKUP_RETENTION_DAYS: "14" + secrets: + - postgres_password + volumes: + - ./backups:/backups + - media_files:/media:ro + networks: + - backend + entrypoint: ["/bin/sh", "-c"] + command: + - | + apk add --no-cache tar gzip findutils >/dev/null && \ + while true; do + TS=$$(date +%Y%m%d_%H%M%S) + export PGPASSWORD=$$(cat $$PGPASSWORD_FILE) + echo "[$$(date -Iseconds)] starting backup $$TS" + pg_dump -Fc -f /backups/db_$$TS.dump && \ + tar czf /backups/media_$$TS.tar.gz -C /media . && \ + find /backups -name "db_*.dump" -mtime +$$BACKUP_RETENTION_DAYS -delete && \ + find /backups -name "media_*.tar.gz" -mtime +$$BACKUP_RETENTION_DAYS -delete && \ + echo "[$$(date -Iseconds)] backup done" + sleep 86400 + done + logging: *default-logging + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + +# ============================================================================= +networks: + frontend: + driver: bridge + driver_opts: + com.docker.network.bridge.name: br-sb-front + backend: + driver: bridge + driver_opts: + com.docker.network.bridge.name: br-sb-back + +# ============================================================================= +volumes: + postgres_data: + redis_data: + static_files: + media_files: + nginx_cache: + nginx_run: + +# ============================================================================= +secrets: + postgres_password: + file: ./secrets/postgres_password.txt diff --git a/serienbrief/nginx/conf.d/proxy_params.inc b/serienbrief/nginx/conf.d/proxy_params.inc new file mode 100644 index 0000000..906d055 --- /dev/null +++ b/serienbrief/nginx/conf.d/proxy_params.inc @@ -0,0 +1,9 @@ +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 $http_x_forwarded_proto; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header Connection ""; +proxy_redirect off; +proxy_buffering on; diff --git a/serienbrief/nginx/conf.d/serienbrief.conf b/serienbrief/nginx/conf.d/serienbrief.conf new file mode 100644 index 0000000..647e5a1 --- /dev/null +++ b/serienbrief/nginx/conf.d/serienbrief.conf @@ -0,0 +1,55 @@ +# ============================================================================= +# vHost – HTTP only. Security-Header & TLS sind Aufgabe des äußeren Proxys. +# ============================================================================= + +# Vom äußeren Proxy weitergereichte Header vertrauen – aber NUR aus dem +# Docker-Netz oder von der bekannten Proxy-IP. Bei Bedarf set_real_ip_from +# auf das CIDR des Proxys einschränken. +set_real_ip_from 0.0.0.0/0; +real_ip_header X-Forwarded-For; +real_ip_recursive on; + +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + # Healthcheck (für äußeren Proxy & Compose-Healthcheck) + location = /healthz { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } + + limit_conn conn_per_ip 20; + + # Login strikter limitieren + location ~ ^/(accounts/login|admin/login) { + limit_req zone=login burst=3 nodelay; + proxy_pass http://django_app; + include /etc/nginx/conf.d/proxy_params.inc; + } + + # Statische Dateien + location /static/ { + alias /var/www/static/; + access_log off; + expires 7d; + add_header Cache-Control "public, immutable"; + } + + # Geschützte Media (PDFs) – nur per X-Accel-Redirect aus Django ausspielen + location /protected-media/ { + internal; + alias /var/www/media/; + } + + # App + location / { + limit_req zone=app burst=50 nodelay; + proxy_pass http://django_app; + include /etc/nginx/conf.d/proxy_params.inc; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } +} diff --git a/serienbrief/nginx/nginx.conf b/serienbrief/nginx/nginx.conf new file mode 100644 index 0000000..bdf3d22 --- /dev/null +++ b/serienbrief/nginx/nginx.conf @@ -0,0 +1,58 @@ +# ============================================================================= +# App-interner Nginx – HTTP only. +# TLS macht der äußere Proxy. +# ============================================================================= + +user nginx; +worker_processes auto; +worker_rlimit_nofile 8192; +pid /var/run/nginx.pid; +error_log /var/log/nginx/error.log warn; + +events { + worker_connections 2048; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main_ext '$remote_addr - $remote_user [$time_iso8601] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" ' + 'xff="$http_x_forwarded_for" ' + 'rt=$request_time urt="$upstream_response_time"'; + access_log /var/log/nginx/access.log main_ext; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + server_tokens off; + + # CSVs etc. – am äußeren Proxy spiegeln! + client_max_body_size 25M; + client_body_buffer_size 128k; + client_body_timeout 60s; + client_header_timeout 30s; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css application/json application/javascript + text/xml application/xml application/xml+rss text/javascript + image/svg+xml; + + limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; + limit_req_zone $binary_remote_addr zone=app:10m rate=30r/s; + limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m; + + upstream django_app { + server web:8000 max_fails=3 fail_timeout=30s; + keepalive 32; + } + + include /etc/nginx/conf.d/*.conf; +} diff --git a/serienbrief_testdata/empfaenger.csv b/serienbrief_testdata/empfaenger.csv new file mode 100644 index 0000000..b27863d --- /dev/null +++ b/serienbrief_testdata/empfaenger.csv @@ -0,0 +1,11 @@ +anrede,anrede_brief,vorname,nachname,strasse,plz,ort,datum,gz,personalnr,funktion,abteilung,durchwahl +Frau,geehrte Frau,Andrea,Huber,Hauptstraße 12,7000,Eisenstadt,21.05.2026,GB-DS-2026-0142,10342,Diplomierte Gesundheits- und Krankenpflegerin,Innere Medizin,1234 +Herr,geehrter Herr,Bernhard,Müller,Bahngasse 8,7100,Neusiedl am See,21.05.2026,GB-DS-2026-0143,10891,Stationsarzt,Chirurgie,1235 +Frau,geehrte Frau,Claudia,Berger,Schulgasse 3,7350,Oberpullendorf,21.05.2026,GB-DS-2026-0144,11204,Medizinisch-technische Assistentin,Labor,1236 +Herr,geehrter Herr,Daniel,Wagner,Kirchenplatz 5,7400,Oberwart,21.05.2026,GB-DS-2026-0145,11458,Hausarbeiter,Haustechnik,1237 +Frau,geehrte Frau,Eva,Schneider,Lindenweg 17,7540,Güssing,21.05.2026,GB-DS-2026-0146,11876,Verwaltungsangestellte,Patientenadministration,1238 +Herr,geehrter Herr,Florian,Maier,Ringstraße 22,7000,Eisenstadt,21.05.2026,GB-DS-2026-0147,12001,IT-Administrator,IT-Abteilung,1239 +Frau,geehrte Frau,Gabriele,Fischer,Marktplatz 4,7350,Oberpullendorf,21.05.2026,GB-DS-2026-0148,12245,Diätologin,Ernährungsmedizin,1240 +Herr,geehrter Herr,Hannes,Weber,Feldweg 9,7100,Neusiedl am See,21.05.2026,GB-DS-2026-0149,12567,Oberarzt,Anästhesiologie,1241 +Frau,geehrte Frau,Ingrid,Steiner,Bergstraße 14,7400,Oberwart,21.05.2026,GB-DS-2026-0150,12789,Stationsleitung,Geriatrie,1242 +Herr,geehrter Herr,Johann,Bauer,Wienerstraße 31,7540,Güssing,21.05.2026,GB-DS-2026-0151,13012,Reinigungskraft,Hauswirtschaft,1243 diff --git a/serienbrief_testdata/vorlage_dsgvo_mitarbeiterinfo.docx b/serienbrief_testdata/vorlage_dsgvo_mitarbeiterinfo.docx new file mode 100644 index 0000000000000000000000000000000000000000..9bbe037cbe94cb66f50729c18887e6daccfb0f02 GIT binary patch literal 37413 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