Erste lauffähige Version

This commit is contained in:
2026-05-21 10:36:16 +02:00
commit 6a103adac4
98 changed files with 4107 additions and 0 deletions
+547
View File
@@ -0,0 +1,547 @@
# Serienbrief DOCXMailmerge mit Django, Celery & LibreOffice
WebAnwendung zur Erstellung personalisierter Serienbriefe aus DOCXVorlagen und CSVEmpfängerlisten. Ergebnis ist ein zusammengeführtes PDF mit einem Brief pro Empfänger:in.
Zielumgebung: internes LAN, hinter zentralem ReverseProxy (TLSTerminierung extern). Produktionsnah konzipiert: Hardening, ResourceLimits, Healthchecks, BackupJob.
---
## Inhaltsverzeichnis
1. [Funktionsumfang](#funktionsumfang)
2. [Architekturübersicht](#architekturübersicht)
3. [TechStack](#techstack)
4. [Komponenten im Detail](#komponenten-im-detail)
5. [Verzeichnisstruktur](#verzeichnisstruktur)
6. [Datenfluss eines SerienbriefJobs](#datenfluss-eines-serienbriefjobs)
7. [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
- DOCXVorlagen mit Jinjaartigen Platzhaltern (`{{ feldname }}`) via **docxtpl**
- CSVUpload als Empfängerliste (UTF8, KommaTrenner)
- Asynchrone Verarbeitung über Celery — UI bleibt responsiv, StatusPolling per HTMX
- DOCX → PDF Konversion mit headless **LibreOffice**
- Zusammenführung aller EinzelPDFs via **pypdf**
- Authentifizierung über Djangos AuthSystem, BruteForceSchutz mit **djangoaxes**
- Geschütztes Ausliefern generierter PDFs via **XAccelRedirect** (Nginx)
- AuditLog pro Job (StatusWechsel, Fehler, abgearbeitete Zeilen)
- Tägliches Backup (pg_dump + MediaTar, 14 Tage Retention)
---
## Architekturübersicht
```
┌──────────────────────────────────────────────┐
│ Externer LANProxy (Nginx, TLSTerminierung)│
│ https://serienbrief.lan │
└────────────────────┬─────────────────────────┘
│ HTTP (intern)
┌───────────────────────────────────────────────────────────┐
│ ComposeStack »serienbrief« │
│ │
│ ┌───────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ nginx │──▶│ web (Django) │──▶│ db (Postgres 16) │ │
│ │ AppProxy │ │ Gunicorn │ │ │ │
│ └─────┬─────┘ └──────┬───────┘ └──────────────────┘ │
│ │ XAccel │ enqueue │
│ │ static/media ▼ │
│ │ ┌──────────────┐ ┌──────────────────┐ │
│ └────────▶│ redis (Broker)│◀─│ worker (Celery) │ │
│ └──────────────┘ │ LibreOffice+pypdf│ │
│ └──────────────────┘ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ beat (Cron) │ │ backup (pg_dump) │ │
│ └──────────────┘ └──────────────────┘ │
└───────────────────────────────────────────────────────────┘
```
Zwei BridgeNetze trennen Verantwortlichkeiten:
- **frontend** nur `nginx``web`. Einziges Netz mit PortBind nach außen.
- **backend** `web`, `worker`, `beat`, `db`, `redis`, `backup`. Kein direktes Internet/LANExposing.
---
## TechStack
| Schicht | Komponente | Version |
|---|---|---|
| HostOS | Ubuntu Server LTS | 22.04 / 24.04 |
| Container Runtime | Docker Engine | 27.x+ |
| Orchestrierung | Docker Compose v2 | — |
| AppFramework | Django | 5.1.4 |
| WSGI (Prod) | Gunicorn | — |
| Task Queue | Celery | 5.4 |
| Broker / Result Backend | Redis | 7alpine |
| Datenbank | PostgreSQL | 16alpine |
| DOCXTemplating | docxtpl | 0.19 |
| DOCX→PDF | LibreOffice (headless) | bundled in Image |
| PDFMerge | pypdf | 5.1 |
| AppProxy | Nginx | 1.27alpine |
| AuthHardening | djangoaxes | aktuell |
| UIInteraktion | djangohtmx | aktuell |
| DBAdapter | psycopg | 3.2.3 (binary) |
| Python | CPython | 3.12 (slimbookworm) |
---
## Komponenten im Detail
### `nginx` Appinterner ReverseProxy
- Terminiert **kein** TLS (übernimmt der externe Proxy)
- Liefert Static und MediaDateien direkt aus den Volumes aus
- Setzt `XAccelRedirect` für Authgeschützte PDFs um: Django prüft die Berechtigung, Nginx streamt die Datei ohne Python im Pfad
- Hört auf `${APP_BIND_IP:-127.0.0.1}:${APP_BIND_PORT:-8080}` (Default Loopback)
- Healthcheck gegen `/healthz`
### `web` Django + Gunicorn
- Rolle `web` (per `ROLE`Env unterschieden, gleiches Image wie worker/beat)
- Stateless, readonly RootFS, `/tmp` als tmpfs
- SettingsSplit: `base.py`, `dev.py`, `production.py`
- Entrypoint wartet auf DBReadiness, führt `migrate` und (in Prod) `collectstatic` aus
- Healthcheck via `/healthz`
### `worker` Celery
- Verarbeitet `process_mailmerge_job`Tasks
- LibreOfficeUserProfil im tmpfs (`/tmp/lo_profile_<task_id>`) → keine Leaks zwischen Jobs
- `--concurrency=2`, `--max-tasks-per-child=50` (RAMLeakSchutz bei LibreOffice)
### `beat` Celery Scheduler
- Nutzt `DatabaseScheduler` von djangocelerybeat
- Geplant für: RetentionCleanup (alte Jobs/PDFs), AuditLogRotation
- *Hinweis: Konkrete Periodic Tasks aktuell noch nicht registriert (siehe Roadmap)*
### `db` PostgreSQL 16
- Eigenes Volume `postgres_data`
- Passwort via DockerSecret (`/run/secrets/postgres_password`)
- InitSkripte unter `./postgres/init` (z.B. zusätzliche Extensions)
- `shm_size: 256mb` für komplexere Queries
### `redis` Broker & Result Backend
- Passwortgeschützt (`REDIS_PASSWORD` aus `.env`)
- AOF persistence + RDB snapshot (`save 900 1`)
- `maxmemory 256mb` mit `allkeyslru`
### `backup` nightly pg_dump + media tar
- CustomLoop mit `sleep 86400` (kein systemd nötig)
- pg_dump im CustomFormat (`-Fc`) → `pg_restore`kompatibel
- MediaTar aus `media_files`Volume (readonly Mount)
- Retention 14 Tage (konfigurierbar)
- Schreibt nach `./backups` auf dem Host
---
## Verzeichnisstruktur
```
serienbrief/
├── app/ # DjangoProjekt
│ ├── Dockerfile # MultiStage: builder → runtimebase → runtime / dev
│ ├── entrypoint.sh # wait_for_db → migrate → collectstatic → exec CMD
│ ├── manage.py
│ ├── requirements.txt
│ ├── requirements-dev.txt # debugpy, ipython, ruff, pytest, debug-toolbar
│ ├── config/ # Project package
│ │ ├── celery.py
│ │ ├── urls.py
│ │ ├── wsgi.py
│ │ └── settings/
│ │ ├── base.py
│ │ ├── dev.py
│ │ └── production.py
│ ├── mailmerge/ # Hauptapp
│ │ ├── models.py # LetterTemplate, MailMergeJob, JobLogEntry
│ │ ├── views.py
│ │ ├── 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 SerienbriefJobs
1. **Upload Vorlage:** `LetterTemplate` mit DOCXDatei wird angelegt (Admin oder UI)
2. **JobErstellung:** 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 (UTF8, validiert Header gegen Platzhalter)
- Iteriert Zeilen → docxtpl rendert pro Zeile eine temporäre DOCX in `/tmp`
- LibreOffice (`soffice --headless --convert-to pdf …`) erzeugt pro DOCX ein PDF
- pypdf merged alle PDFs in eine Ausgabedatei unter `media_files`
- `JobLogEntry`s pro Schritt; Status `running → done` bzw. `failed`
5. **UIPolling:** HTMX fragt `/jobs/<id>/status/` alle ~2 s ab → Fortschrittsbalken
6. **Download:** Nutzer klickt Download → View prüft Auth → Antwort enthält `XAccelRedirect: /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/<id>/input.csv)
├── output_pdf (FileField → media/jobs/<id>/output.pdf)
├── status: queued | running | done | failed
├── total_rows, processed_rows
├── created_by, created_at, started_at, finished_at
└── error_message
JobLogEntry
├── id, job (FK MailMergeJob, related_name="log_entries")
├── level: info | warning | error
├── message, created_at
```
---
## Sicherheitskonzept
Im Sinne der SpaceVorgabe »SecuritybyDefault«:
### ContainerHardening
- `no-new-privileges: true` bei allen Services
- `read_only: true` für `web`, `worker`, `beat` (writable nur via tmpfs `/tmp`)
- NonrootUser `app` (UID/GID 1000, anpassbar per BuildArgs)
- Minimales Image (`python:3.12-slim-bookworm`), MultiStageBuild trennt Build und LaufzeitDependencies
- RessourcenLimits (memory + cpus) auf allen Services
- `tmpfs`Größen explizit limitiert (`size=256M`, etc.)
### Netzwerk
- Zwei isolierte Bridges (`frontend`, `backend`)
- Nur `nginx` bindet nach außen, default auf **Loopback**
- DB und Redis ausschließlich auf `backend`
- Externer Proxy übernimmt TLS, HSTS, ggf. IPAllowlist
### Secrets
- PostgresPasswort via DockerSecret (`./secrets/postgres_password.txt`, Mode 0600)
- RedisPasswort + DjangoSecretKey in `.env` (nicht in Git)
- `.env` und `secrets/` sind in `.gitignore` ausgenommen
### AnwendungsSicherheit
- djangoaxes: Lockout nach n Fehlversuchen
- CSRF aktiv, Trusted Origins explizit gepflegt
- `SECURE_PROXY_SSL_HEADER` gesetzt, damit Django HTTPS hinter dem externen Proxy korrekt erkennt
- `USE_X_FORWARDED_HOST=True`
- XAccelRedirect statt direktem FileDownload → Nginx liefert nur, was Django freigibt
- SessionCookies `HttpOnly`, `SameSite=Lax`, `Secure` (in Prod)
### DefenseinDepth (Empfehlungen)
- AppArmorProfile pro Container (z.B. via `--security-opt apparmor=...`) aktuell nicht aktiv
- seccompProfile (Default reicht, custom optional)
- ImageScanning (`trivy`, `grype`) in CI integrieren
- Renovate/Dependabot für BaseImage und PythonDeps
---
## Netzwerk & Ports
| Port (Host) | Service | Sichtbarkeit | Zweck |
|---|---|---|---|
| 8080 | nginx | `${APP_BIND_IP}` | HTTP für externen Proxy |
| 8000 | web | nur Dev | Django runserver |
| 5678 | web (Dev) | nur Dev | debugpy |
| 5432 | db | nur Dev | Postgres (Tools wie pgAdmin) |
| 6379 | redis | nur Dev | RedisCLI |
In Produktion bleiben **nur** Port 8080 (intern an externen Proxy) erreichbar.
---
## Volumes & Persistenz
| Volume | Inhalt | Backup? |
|---|---|---|
| `postgres_data` | PostgresDaten | pg_dump nightly |
| `redis_data` | AOF + RDB | nein (BrokerState, regenerierbar) |
| `static_files` | Generierte StaticFiles | nein (regenerierbar via `collectstatic`) |
| `media_files` | Hochgeladene Templates, CSVs, generierte PDFs | tar nightly |
| `nginx_cache` | Nginx Cache | nein |
| `nginx_run` | Nginx PID/Socket | nein |
BindMount im Dev: `./app:/app` für LiveReload.
---
## Konfiguration
`.env` (aus `.env.example` abgeleitet) — Auszug:
```env
# App
APP_VERSION=1.0.0
APP_BIND_IP=127.0.0.1
APP_BIND_PORT=8080
APP_UID=1000
APP_GID=1000
# Django
DJANGO_SECRET_KEY=<openssl rand -base64 64>
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=serienbrief.lan,127.0.0.1,localhost
CSRF_TRUSTED_ORIGINS=https://serienbrief.lan
SECURE_PROXY_SSL_HEADER=HTTP_X_FORWARDED_PROTO,https
USE_X_FORWARDED_HOST=True
# Postgres
POSTGRES_DB=serienbrief
POSTGRES_USER=serienbrief
DATABASE_URL=postgres://serienbrief:<HEX-PWD>@db:5432/serienbrief
# Redis
REDIS_PASSWORD=<openssl rand -hex 32>
CELERY_BROKER_URL=redis://:<REDIS_PASSWORD>@redis:6379/0
CELERY_RESULT_BACKEND=redis://:<REDIS_PASSWORD>@redis:6379/1
# Business
JOB_RETENTION_DAYS=30
```
**Wichtig:**
- `secrets/postgres_password.txt` muss exakt denselben Wert enthalten wie in `DATABASE_URL`
- Datei darf **kein** abschließendes Newline enthalten — Generierung: `openssl rand -hex 32 | tr -d '\n' > secrets/postgres_password.txt && chmod 600 secrets/postgres_password.txt`
- Hex statt Base64, weil Base64`=` URLParsing in `DATABASE_URL` bricht
---
## Inbetriebnahme
### Voraussetzungen
- Ubuntu 22.04/24.04 LTS, aktueller PatchStand
- Docker Engine 27.x+ und Compose v2 (aptPakete `docker-ce` + `docker-compose-plugin`, nicht Snap)
- HostUser in `docker`Gruppe (`sudo usermod -aG docker $USER`, dann ReLogin)
### Erststart
```bash
# 1. Repo holen / entpacken in z.B. ~/projekte/serienbrief
cd ~/projekte/serienbrief
# 2. Konfiguration aus Template ableiten
cp .env.example .env
$EDITOR .env # Werte ausfüllen, siehe oben
# 3. PostgresPasswort als Datei
openssl rand -hex 32 | tr -d '\n' > secrets/postgres_password.txt
chmod 600 secrets/postgres_password.txt
# → diesen Wert auch in DATABASE_URL eintragen
# 4. Image bauen
docker compose build
# 5. Hochfahren
docker compose up -d
# 6. Status & Logs
docker compose ps -a
docker compose logs -f web
# 7. Superuser anlegen
docker compose exec web python manage.py createsuperuser
```
### Externen 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://<docker-host-ip>: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/DOCXUploads
}
}
```
---
## Entwicklung in VS Code
- `.devcontainer/devcontainer.json` referenziert den ComposeStack mit DevOverride
- `.vscode/launch.json` enthält Configs:
- **Django: runserver (attach)** → debugpy auf 5678
- **Django: pytest** → führt Tests im Container aus
- `.vscode/tasks.json` für `makemigrations`, `migrate`, `shell`, `collectstatic`
- `.vscode/extensions.json` empfiehlt: Python, Pylance, Django, Docker, Even Better TOML, Ruff
### Workflow
```bash
# Stack hochfahren (Override greift automatisch)
docker compose up -d
# Code‑Änderungen werden via BindMount sofort sichtbar
# runserver reloaded automatisch
# Debugging: F5 in VS Code → attach an debugpy:5678
```
### Wichtige Unterschiede Dev ↔ Prod
| Aspekt | Dev | Prod |
|---|---|---|
| WSGI | `runserver` | Gunicorn |
| Code | BindMount `./app:/app` | im Image |
| Healthcheck | deaktiviert | aktiv (`/healthz`) |
| `read_only` | aus | an |
| `DJANGO_DEBUG` | True | False |
| `collectstatic` | auto im Entrypoint | im Entrypoint |
| `target` | `dev` (mit debugpy, ipython, pytest) | `runtime` |
---
## Betrieb & Wartung
### Häufige Operationen
```bash
# Logs eines einzelnen Services
docker compose logs -f worker
# In den Container schauen
docker compose exec web bash
docker compose exec db psql -U serienbrief
# Migration nach Modelländerung
docker compose exec web python manage.py makemigrations
docker compose exec web python manage.py migrate
# Statics neu generieren
docker compose exec web python manage.py collectstatic --noinput
# CeleryQueue Status
docker compose exec worker celery -A config inspect active
docker compose exec worker celery -A config inspect reserved
```
### Updates
```bash
# Code aktualisieren
git pull
# Image neu bauen
docker compose build --pull
# Mit ZeroDowntime nicht möglich (singlehost) → kurzer Restart
docker compose up -d
# DBMigrationen
docker compose exec web python manage.py migrate
```
### Backup & Restore
```bash
# Manuelles Backup erzwingen
docker compose restart backup
docker compose logs --tail=50 backup
# Restore (Beispiel)
docker compose exec -T db pg_restore -U serienbrief -d serienbrief --clean < backups/db_20260521_030000.dump
```
### Monitoring (Empfehlung, nicht enthalten)
- `docker stats` für adhoc ContainerRessourcen
- HealthcheckStatus: `docker compose ps`
- ContainerLogs via zentralem syslog/journald (Docker `json-file` rotiert 5×10 MB)
- Optional: Prometheus + cAdvisor + node_exporter, Loki für Logs
---
## Datenschutz & Compliance
- **Rechtsgrundlage:** Verarbeitung personenbezogener Adress/Empfängerdaten je nach Anwendungsfall Art. 6 Abs. 1 lit. b/c/e DSGVO; bei besonderen Datenkategorien (Art. 9 DSGVO) gesonderte Rechtsgrundlage erforderlich
- **Speicherort:** Innerbetrieblicher Server, kein CloudTransfer
- **Zugriffssteuerung:** Nur authentifizierte User; Trennung Templates/Jobs aktuell flach — Mandanten/Rollenmodell ggf. ergänzen (siehe Roadmap)
- **Aufbewahrung:** Jobs + CSVs werden nach `JOB_RETENTION_DAYS` (default 30) gelöscht — CleanupTask noch zu implementieren (siehe Roadmap)
- **Audit:** Jeder Job hat ein `JobLogEntry`Protokoll (wer, wann, was, Fehler)
- **BackupVerschlüsselung:** BindMount `./backups` liegt aktuell unverschlüsselt — für produktiven Einsatz LUKS/FilesystemEncryption am Host **dringend empfohlen**
- **DSFARelevanz:** Bei Verarbeitung besonderer Datenkategorien oder umfangreicher Profilbildung ist eine DatenschutzFolgenabschätzung gemäß Art. 35 DSGVO durchzuführen, bevor produktiver Echtbetrieb startet
---
## Bekannte Einschränkungen & Roadmap
### TechDebt
- PostgresPasswort doppelt gehalten (`DATABASE_URL` + SecretDatei) → refactor zu Single Source via Entrypoint, der `DATABASE_URL` aus `*_FILE`Env zusammenbaut
- Keine LDAP/ADAnbindung — aktuell lokale DjangoUser
- Keine Periodic Tasks im Beat registriert (RetentionCleanup, LogRotation)
- CSVHeaderValidierung ist warnonly, kein Abbruch bei fehlenden Pflichtfeldern
- Keine Vorschau (SingleLetterPreview) vor dem KomplettRender
- Image enthält LibreOffice (~400 MB) — könnte in separates WorkerImage ausgelagert werden
### Roadmap (kurzfristig)
- [ ] SingleLetterPreview im JobErstellungsFlow
- [ ] RetentionCleanup als periodic task
- [ ] CSVValidierung strict mit Pflichtfeldliste pro Template
- [ ] Mandanten/Berechtigungsmodell (Abteilung X sieht nur eigene Templates)
- [ ] LDAPAuth über `djangoauthldap` (ADIntegration GB)
### Roadmap (mittelfristig)
- [ ] AppArmorProfile pro Container
- [ ] ImageScanning in CI (Trivy)
- [ ] Separate WorkerImage ohne WebDependencies
- [ ] AESVerschlüsselung der hochgeladenen CSVs at rest
- [ ] Optional: Konvertierung über Gotenberg statt LibreOfficeimImage (cleaneres Separation of Concerns)
---
## Lizenz & Verantwortlichkeit
Internes Projekt. Verantwortlich für Konzept & Betrieb: **Datenschutzkoordination / ITSystemadministration**.
Keine externe Lizenz festgelegt — Verteilung ausschließlich innerbetrieblich.
+86
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ["celery_app"]
+6
View File
@@ -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()
+9
View File
@@ -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()
View File
+144
View File
@@ -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"
+16
View File
@@ -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"
+32
View File
@@ -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},
},
}
+27
View File
@@ -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
+6
View File
@@ -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()
+19
View File
@@ -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 "$@"
View File
+25
View File
@@ -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",)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class MailmergeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "mailmerge"
verbose_name = "Serienbrief"
+34
View File
@@ -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
@@ -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)
+68
View File
@@ -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'],
},
),
]
+86
View File
@@ -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"]
View File
+74
View File
@@ -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
+14
View File
@@ -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
+87
View File
@@ -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
+12
View File
@@ -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/<uuid:pk>/", views.template_detail, name="template-detail"),
path("jobs/new/", views.job_create, name="job-create"),
path("jobs/<uuid:pk>/", views.job_detail, name="job-detail"),
path("jobs/<uuid:pk>/download/", views.job_download, name="job-download"),
]
+94
View File
@@ -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
+19
View File
@@ -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()
+17
View File
@@ -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"
+8
View File
@@ -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
+15
View File
@@ -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
+41
View File
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Serienbrief{% endblock %}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 960px; margin: 2rem auto; padding: 0 1rem; color: #222; }
nav { display: flex; gap: 1rem; padding-bottom: 1rem; border-bottom: 1px solid #ddd; margin-bottom: 1.5rem; }
nav a { text-decoration: none; color: #0366d6; }
h1, h2 { color: #111; }
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; }
.status-pending { color: #888; }
.status-running { color: #d97706; }
.status-done { color: #059669; }
.status-failed { color: #dc2626; }
.btn { display: inline-block; padding: 0.4rem 0.8rem; background: #0366d6; color: white; border-radius: 4px; text-decoration: none; border: none; cursor: pointer; }
.messages li { padding: 0.5rem 1rem; background: #fef3c7; border-left: 4px solid #f59e0b; margin: 0.5rem 0; list-style: none; }
.log { font-family: monospace; font-size: 0.85rem; background: #f6f8fa; padding: 0.75rem; border-radius: 4px; }
.log .level-error { color: #dc2626; }
.log .level-warning { color: #d97706; }
form p { margin: 0.75rem 0; }
label { display: block; font-weight: 600; }
</style>
</head>
<body>
<nav>
<a href="{% url 'dashboard' %}">Übersicht</a>
<a href="{% url 'template-upload' %}">Neue Vorlage</a>
<a href="{% url 'job-create' %}">Neuer Serienbrief</a>
<span style="margin-left:auto">
{% if user.is_authenticated %}
{{ user.username }} · <a href="{% url 'logout' %}">Abmelden</a>
{% endif %}
</span>
</nav>
{% if messages %}<ul class="messages">{% for m in messages %}<li>{{ m }}</li>{% endfor %}</ul>{% endif %}
{% block content %}{% endblock %}
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
<div hx-get="{% url 'job-detail' job.id %}" hx-trigger="every 2s" hx-swap="outerHTML">
<p><strong>Vorlage:</strong> {{ job.template.name }}</p>
<p><strong>Status:</strong>
<span class="status-{{ job.status }}">{{ job.get_status_display }}</span>
</p>
<p><strong>Fortschritt:</strong> {{ job.processed_rows }} / {{ job.total_rows }}</p>
{% if job.status == "done" %}
<p><a class="btn" href="{% url 'job-download' job.id %}">PDF herunterladen</a></p>
{% endif %}
{% if job.error_message %}
<p style="color:#dc2626"><strong>Fehler:</strong> {{ job.error_message }}</p>
{% endif %}
<h3>Log</h3>
<div class="log">
{% for entry in logs %}
<div class="level-{{ entry.level }}">
[{{ entry.timestamp|date:"H:i:s" }}] {{ entry.level|upper }} {{ entry.message }}
</div>
{% empty %}
<em>Keine Einträge.</em>
{% endfor %}
</div>
</div>
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block content %}
<h1>Übersicht</h1>
<h2>Vorlagen</h2>
<a class="btn" href="{% url 'template-upload' %}">Neue Vorlage hochladen</a>
<table>
<thead><tr><th>Name</th><th>Platzhalter</th><th>Erstellt</th></tr></thead>
<tbody>
{% for t in templates %}
<tr>
<td><a href="{% url 'template-detail' t.id %}">{{ t.name }}</a></td>
<td>{{ t.placeholders|join:", " }}</td>
<td>{{ t.created_at|date:"d.m.Y H:i" }}</td>
</tr>
{% empty %}
<tr><td colspan="3"><em>Noch keine Vorlagen vorhanden.</em></td></tr>
{% endfor %}
</tbody>
</table>
<h2>Aufträge</h2>
<a class="btn" href="{% url 'job-create' %}">Neuen Serienbrief erstellen</a>
<table>
<thead><tr><th>ID</th><th>Vorlage</th><th>Status</th><th>Fortschritt</th><th>Erstellt</th></tr></thead>
<tbody>
{% for j in jobs %}
<tr>
<td><a href="{% url 'job-detail' j.id %}">{{ j.id|stringformat:"s"|slice:":8" }}…</a></td>
<td>{{ j.template.name }}</td>
<td class="status-{{ j.status }}">{{ j.get_status_display }}</td>
<td>{{ j.processed_rows }} / {{ j.total_rows }}</td>
<td>{{ j.created_at|date:"d.m.Y H:i" }}</td>
</tr>
{% empty %}
<tr><td colspan="5"><em>Noch keine Aufträge.</em></td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<h1>Auftrag {{ job.id|stringformat:"s"|slice:":8" }}…</h1>
<div hx-get="{% url 'job-detail' job.id %}"
hx-trigger="every 2s"
hx-swap="outerHTML">
{% include "mailmerge/_job_status.html" %}
</div>
<script src="https://unpkg.com/htmx.org@2.0.3" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" crossorigin="anonymous"></script>
{% endblock %}
+10
View File
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block content %}
<h1>Neuer Serienbrief</h1>
<p>Vorlage und Empfänger-CSV auswählen. Die Spaltennamen der CSV müssen mit den Platzhaltern der Vorlage übereinstimmen (erste Zeile = Spaltennamen).</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn">Erstellen und starten</button>
</form>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ template.name }}</h1>
<p>{{ template.description }}</p>
<p><strong>Datei:</strong> {{ template.file.name }}</p>
<p><strong>Erkannte Platzhalter:</strong></p>
<ul>
{% for p in template.placeholders %}<li><code>{{ p }}</code></li>{% empty %}<li><em>Keine gefunden.</em></li>{% endfor %}
</ul>
<a class="btn" href="{% url 'job-create' %}">Serienbrief mit dieser Vorlage erstellen</a>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block content %}
<h1>Neue Vorlage hochladen</h1>
<p>DOCX-Datei mit Platzhaltern wie <code>&#123;&#123; vorname &#125;&#125;</code>, <code>&#123;&#123; nachname &#125;&#125;</code>, … Die Spaltennamen der späteren CSV müssen mit den Platzhaltern übereinstimmen.</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn">Hochladen</button>
</form>
{% endblock %}
+10
View File
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}Anmelden{% endblock %}
{% block content %}
<h1>Anmelden</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn">Anmelden</button>
</form>
{% endblock %}
+65
View File
@@ -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"]
+309
View File
@@ -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
+9
View File
@@ -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;
+55
View File
@@ -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;
}
}
+58
View File
@@ -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;
}
@@ -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"
}
+46
View File
@@ -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
+39
View File
@@ -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
+14
View File
@@ -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"
]
}
+53
View File
@@ -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
}
]
}
+38
View File
@@ -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
}
}
+72
View File
@@ -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": []
}
]
}
+125
View File
@@ -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: <client>`
- `Host: <serienbrief.lan>`
## 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)
+86
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ["celery_app"]
+6
View File
@@ -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()
+9
View File
@@ -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()
+144
View File
@@ -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"
+16
View File
@@ -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"
@@ -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},
},
}
+27
View File
@@ -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
+6
View File
@@ -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()
+19
View File
@@ -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 "$@"
+25
View File
@@ -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",)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class MailmergeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "mailmerge"
verbose_name = "Serienbrief"
+34
View File
@@ -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
@@ -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)
+86
View File
@@ -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"]
@@ -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
@@ -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
+87
View File
@@ -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
+12
View File
@@ -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/<uuid:pk>/", views.template_detail, name="template-detail"),
path("jobs/new/", views.job_create, name="job-create"),
path("jobs/<uuid:pk>/", views.job_detail, name="job-detail"),
path("jobs/<uuid:pk>/download/", views.job_download, name="job-download"),
]
+94
View File
@@ -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
+19
View File
@@ -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()
+17
View File
@@ -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"
+8
View File
@@ -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
+15
View File
@@ -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
+41
View File
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Serienbrief{% endblock %}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 960px; margin: 2rem auto; padding: 0 1rem; color: #222; }
nav { display: flex; gap: 1rem; padding-bottom: 1rem; border-bottom: 1px solid #ddd; margin-bottom: 1.5rem; }
nav a { text-decoration: none; color: #0366d6; }
h1, h2 { color: #111; }
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; }
.status-pending { color: #888; }
.status-running { color: #d97706; }
.status-done { color: #059669; }
.status-failed { color: #dc2626; }
.btn { display: inline-block; padding: 0.4rem 0.8rem; background: #0366d6; color: white; border-radius: 4px; text-decoration: none; border: none; cursor: pointer; }
.messages li { padding: 0.5rem 1rem; background: #fef3c7; border-left: 4px solid #f59e0b; margin: 0.5rem 0; list-style: none; }
.log { font-family: monospace; font-size: 0.85rem; background: #f6f8fa; padding: 0.75rem; border-radius: 4px; }
.log .level-error { color: #dc2626; }
.log .level-warning { color: #d97706; }
form p { margin: 0.75rem 0; }
label { display: block; font-weight: 600; }
</style>
</head>
<body>
<nav>
<a href="{% url 'dashboard' %}">Übersicht</a>
<a href="{% url 'template-upload' %}">Neue Vorlage</a>
<a href="{% url 'job-create' %}">Neuer Serienbrief</a>
<span style="margin-left:auto">
{% if user.is_authenticated %}
{{ user.username }} · <a href="{% url 'logout' %}">Abmelden</a>
{% endif %}
</span>
</nav>
{% if messages %}<ul class="messages">{% for m in messages %}<li>{{ m }}</li>{% endfor %}</ul>{% endif %}
{% block content %}{% endblock %}
</body>
</html>
@@ -0,0 +1,25 @@
<div hx-get="{% url 'job-detail' job.id %}" hx-trigger="every 2s" hx-swap="outerHTML">
<p><strong>Vorlage:</strong> {{ job.template.name }}</p>
<p><strong>Status:</strong>
<span class="status-{{ job.status }}">{{ job.get_status_display }}</span>
</p>
<p><strong>Fortschritt:</strong> {{ job.processed_rows }} / {{ job.total_rows }}</p>
{% if job.status == "done" %}
<p><a class="btn" href="{% url 'job-download' job.id %}">PDF herunterladen</a></p>
{% endif %}
{% if job.error_message %}
<p style="color:#dc2626"><strong>Fehler:</strong> {{ job.error_message }}</p>
{% endif %}
<h3>Log</h3>
<div class="log">
{% for entry in logs %}
<div class="level-{{ entry.level }}">
[{{ entry.timestamp|date:"H:i:s" }}] {{ entry.level|upper }} {{ entry.message }}
</div>
{% empty %}
<em>Keine Einträge.</em>
{% endfor %}
</div>
</div>
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block content %}
<h1>Übersicht</h1>
<h2>Vorlagen</h2>
<a class="btn" href="{% url 'template-upload' %}">Neue Vorlage hochladen</a>
<table>
<thead><tr><th>Name</th><th>Platzhalter</th><th>Erstellt</th></tr></thead>
<tbody>
{% for t in templates %}
<tr>
<td><a href="{% url 'template-detail' t.id %}">{{ t.name }}</a></td>
<td>{{ t.placeholders|join:", " }}</td>
<td>{{ t.created_at|date:"d.m.Y H:i" }}</td>
</tr>
{% empty %}
<tr><td colspan="3"><em>Noch keine Vorlagen vorhanden.</em></td></tr>
{% endfor %}
</tbody>
</table>
<h2>Aufträge</h2>
<a class="btn" href="{% url 'job-create' %}">Neuen Serienbrief erstellen</a>
<table>
<thead><tr><th>ID</th><th>Vorlage</th><th>Status</th><th>Fortschritt</th><th>Erstellt</th></tr></thead>
<tbody>
{% for j in jobs %}
<tr>
<td><a href="{% url 'job-detail' j.id %}">{{ j.id|stringformat:"s"|slice:":8" }}…</a></td>
<td>{{ j.template.name }}</td>
<td class="status-{{ j.status }}">{{ j.get_status_display }}</td>
<td>{{ j.processed_rows }} / {{ j.total_rows }}</td>
<td>{{ j.created_at|date:"d.m.Y H:i" }}</td>
</tr>
{% empty %}
<tr><td colspan="5"><em>Noch keine Aufträge.</em></td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<h1>Auftrag {{ job.id|stringformat:"s"|slice:":8" }}…</h1>
<div hx-get="{% url 'job-detail' job.id %}"
hx-trigger="every 2s"
hx-swap="outerHTML">
{% include "mailmerge/_job_status.html" %}
</div>
<script src="https://unpkg.com/htmx.org@2.0.3" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" crossorigin="anonymous"></script>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block content %}
<h1>Neuer Serienbrief</h1>
<p>Vorlage und Empfänger-CSV auswählen. Die Spaltennamen der CSV müssen mit den Platzhaltern der Vorlage übereinstimmen (erste Zeile = Spaltennamen).</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn">Erstellen und starten</button>
</form>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ template.name }}</h1>
<p>{{ template.description }}</p>
<p><strong>Datei:</strong> {{ template.file.name }}</p>
<p><strong>Erkannte Platzhalter:</strong></p>
<ul>
{% for p in template.placeholders %}<li><code>{{ p }}</code></li>{% empty %}<li><em>Keine gefunden.</em></li>{% endfor %}
</ul>
<a class="btn" href="{% url 'job-create' %}">Serienbrief mit dieser Vorlage erstellen</a>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block content %}
<h1>Neue Vorlage hochladen</h1>
<p>DOCX-Datei mit Platzhaltern wie <code>&#123;&#123; vorname &#125;&#125;</code>, <code>&#123;&#123; nachname &#125;&#125;</code>, … Die Spaltennamen der späteren CSV müssen mit den Platzhaltern übereinstimmen.</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn">Hochladen</button>
</form>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}Anmelden{% endblock %}
{% block content %}
<h1>Anmelden</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn">Anmelden</button>
</form>
{% endblock %}
+62
View File
@@ -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"]
+309
View File
@@ -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
@@ -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;
+55
View File
@@ -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;
}
}
+58
View File
@@ -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;
}
+11
View File
@@ -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
1 anrede anrede_brief vorname nachname strasse plz ort datum gz personalnr funktion abteilung durchwahl
2 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
3 Herr geehrter Herr Bernhard Müller Bahngasse 8 7100 Neusiedl am See 21.05.2026 GB-DS-2026-0143 10891 Stationsarzt Chirurgie 1235
4 Frau geehrte Frau Claudia Berger Schulgasse 3 7350 Oberpullendorf 21.05.2026 GB-DS-2026-0144 11204 Medizinisch-technische Assistentin Labor 1236
5 Herr geehrter Herr Daniel Wagner Kirchenplatz 5 7400 Oberwart 21.05.2026 GB-DS-2026-0145 11458 Hausarbeiter Haustechnik 1237
6 Frau geehrte Frau Eva Schneider Lindenweg 17 7540 Güssing 21.05.2026 GB-DS-2026-0146 11876 Verwaltungsangestellte Patientenadministration 1238
7 Herr geehrter Herr Florian Maier Ringstraße 22 7000 Eisenstadt 21.05.2026 GB-DS-2026-0147 12001 IT-Administrator IT-Abteilung 1239
8 Frau geehrte Frau Gabriele Fischer Marktplatz 4 7350 Oberpullendorf 21.05.2026 GB-DS-2026-0148 12245 Diätologin Ernährungsmedizin 1240
9 Herr geehrter Herr Hannes Weber Feldweg 9 7100 Neusiedl am See 21.05.2026 GB-DS-2026-0149 12567 Oberarzt Anästhesiologie 1241
10 Frau geehrte Frau Ingrid Steiner Bergstraße 14 7400 Oberwart 21.05.2026 GB-DS-2026-0150 12789 Stationsleitung Geriatrie 1242
11 Herr geehrter Herr Johann Bauer Wienerstraße 31 7540 Güssing 21.05.2026 GB-DS-2026-0151 13012 Reinigungskraft Hauswirtschaft 1243