cleanup von abgeschlossenen jobs

This commit is contained in:
2026-05-22 09:14:31 +02:00
parent d61dbd46fd
commit 848e48b0dd
7 changed files with 689 additions and 31 deletions
+120 -31
View File
@@ -14,16 +14,18 @@ Zielumgebung: internes LAN, hinter zentralem Reverse‑Proxy (TLS‑Terminierung
4. [Komponenten im Detail](#komponenten-im-detail) 4. [Komponenten im Detail](#komponenten-im-detail)
5. [Verzeichnisstruktur](#verzeichnisstruktur) 5. [Verzeichnisstruktur](#verzeichnisstruktur)
6. [Datenfluss eines Serienbrief‑Jobs](#datenfluss-eines-serienbriefjobs) 6. [Datenfluss eines Serienbrief‑Jobs](#datenfluss-eines-serienbriefjobs)
7. [Datenmodell](#datenmodell) 7. [Single‑Letter‑Preview](#singleletterpreview)
8. [Sicherheitskonzept](#sicherheitskonzept) 8. [Datenmodell](#datenmodell)
9. [Netzwerk & Ports](#netzwerk--ports) 9. [Sicherheitskonzept](#sicherheitskonzept)
10. [Volumes & Persistenz](#volumes--persistenz) 10. [Netzwerk & Ports](#netzwerk--ports)
11. [Konfiguration](#konfiguration) 11. [Volumes & Persistenz](#volumes--persistenz)
12. [Inbetriebnahme](#inbetriebnahme) 12. [Konfiguration](#konfiguration)
13. [Entwicklung in VS Code](#entwicklung-in-vs-code) 13. [Inbetriebnahme](#inbetriebnahme)
14. [Betrieb & Wartung](#betrieb--wartung) 14. [Entwicklung in VS Code](#entwicklung-in-vs-code)
15. [Datenschutz & Compliance](#datenschutz--compliance) 15. [Tests](#tests)
16. [Bekannte Einschränkungen & Roadmap](#bekannte-einschränkungen--roadmap) 16. [Betrieb & Wartung](#betrieb--wartung)
17. [Datenschutz & Compliance](#datenschutz--compliance)
18. [Bekannte Einschränkungen & Roadmap](#bekannte-einschränkungen--roadmap)
--- ---
@@ -31,13 +33,16 @@ Zielumgebung: internes LAN, hinter zentralem Reverse‑Proxy (TLS‑Terminierung
- DOCX‑Vorlagen mit Jinja‑artigen Platzhaltern (`{{ feldname }}`) via **docxtpl** - DOCX‑Vorlagen mit Jinja‑artigen Platzhaltern (`{{ feldname }}`) via **docxtpl**
- CSV‑Upload als Empfängerliste (UTF‑8, Komma‑Trenner) - CSV‑Upload als Empfängerliste (UTF‑8, Komma‑Trenner)
- Asynchrone Verarbeitung über Celery — UI bleibt responsiv, Status‑Polling per HTMX - **Single‑Letter‑Preview** vor dem Komplett‑Render: rendert nur die erste CSV‑Zeile synchron als PDF und liefert es inline aus — kein Job, keine Persistenz, kein Queue‑Roundtrip
- Asynchrone Verarbeitung des Komplett‑Jobs über Celery — UI bleibt responsiv, Status‑Polling per HTMX
- DOCX → PDF Konversion mit headless **LibreOffice** - DOCX → PDF Konversion mit headless **LibreOffice**
- Zusammenführung aller Einzel‑PDFs via **pypdf** - Zusammenführung aller Einzel‑PDFs via **pypdf**
- Authentifizierung über Djangos Auth‑System, Brute‑Force‑Schutz mit **django‑axes** - Authentifizierung über Djangos Auth‑System, Brute‑Force‑Schutz mit **django‑axes**
- Logout per POST‑Formular (Django 5 Pflicht)
- Geschütztes Ausliefern generierter PDFs via **X‑Accel‑Redirect** (Nginx) - Geschütztes Ausliefern generierter PDFs via **X‑Accel‑Redirect** (Nginx)
- Audit‑Log pro Job (Status‑Wechsel, Fehler, abgearbeitete Zeilen) - Audit‑Log pro Job (Status‑Wechsel, Fehler, abgearbeitete Zeilen)
- Tägliches Backup (pg_dump + Media‑Tar, 14 Tage Retention) - Tägliches Backup (pg_dump + Media‑Tar, 14 Tage Retention)
- **Test‑Suite** mit pytest‑django (Service‑Unit, View‑Layer, Integration mit echtem LibreOffice) — siehe [TEST.md](TEST.md)
--- ---
@@ -96,6 +101,7 @@ Zwei Bridge‑Netze trennen Verantwortlichkeiten:
| UI‑Interaktion | django‑htmx | aktuell | | UI‑Interaktion | django‑htmx | aktuell |
| DB‑Adapter | psycopg | 3.2.3 (binary) | | DB‑Adapter | psycopg | 3.2.3 (binary) |
| Python | CPython | 3.12 (slim‑bookworm) | | Python | CPython | 3.12 (slim‑bookworm) |
| Tests | pytest, pytest‑django | 8.3 / 4.9 |
--- ---
@@ -114,6 +120,7 @@ Zwei Bridge‑Netze trennen Verantwortlichkeiten:
- Settings‑Split: `base.py`, `dev.py`, `production.py` - Settings‑Split: `base.py`, `dev.py`, `production.py`
- Entrypoint wartet auf DB‑Readiness, führt `migrate` und (in Prod) `collectstatic` aus - Entrypoint wartet auf DB‑Readiness, führt `migrate` und (in Prod) `collectstatic` aus
- Healthcheck via `/healthz` - Healthcheck via `/healthz`
- **Synchron**er Preview‑Endpoint (`/jobs/preview/`) rendert über LibreOffice direkt im `web`‑Container — kein Celery‑Roundtrip
### `worker` – Celery ### `worker` – Celery
- Verarbeitet `process_mailmerge_job`‑Tasks - Verarbeitet `process_mailmerge_job`‑Tasks
@@ -122,8 +129,9 @@ Zwei Bridge‑Netze trennen Verantwortlichkeiten:
### `beat` – Celery Scheduler ### `beat` – Celery Scheduler
- Nutzt `DatabaseScheduler` von django‑celery‑beat - Nutzt `DatabaseScheduler` von django‑celery‑beat
- Geplant für: Retention‑Cleanup (alte Jobs/PDFs), Audit‑Log‑Rotation - Periodic Tasks werden über Django‑Migrationen registriert (reproduzierbar, single source of truth)
- *Hinweis: Konkrete Periodic Tasks aktuell noch nicht registriert (siehe Roadmap)* - Aktiv:
- **Retention‑Cleanup** (`mailmerge.cleanup_expired_jobs`) — täglich 03:15 (`CELERY_TIMEZONE`)
### `db` – PostgreSQL 16 ### `db` – PostgreSQL 16
- Eigenes Volume `postgres_data` - Eigenes Volume `postgres_data`
@@ -165,20 +173,28 @@ serienbrief/
│ │ └── production.py │ │ └── production.py
│ ├── mailmerge/ # Hauptapp │ ├── mailmerge/ # Hauptapp
│ │ ├── models.py # LetterTemplate, MailMergeJob, JobLogEntry │ │ ├── models.py # LetterTemplate, MailMergeJob, JobLogEntry
│ │ ├── views.py │ │ ├── views.py # incl. job_preview (synchron)
│ │ ├── admin.py │ │ ├── admin.py
│ │ ├── forms.py │ │ ├── forms.py
│ │ ├── urls.py │ │ ├── urls.py # incl. "jobs/preview/"
│ │ ├── tasks.py # Celery tasks │ │ ├── tasks.py # Celery tasks
│ │ ├── services/ │ │ ├── services/
│ │ │ ├── docx_renderer.py # docxtpl + LibreOffice │ │ │ ├── docx_renderer.py # docxtpl + LibreOffice
│ │ │ └── pdf_merge.py # pypdf │ │ │ ├── pdf_merge.py # pypdf
│ │ │ └── preview.py # Single-Letter-Preview (synchron)
│ │ ├── tests/ # pytest-django Tests
│ │ │ ├── conftest.py
│ │ │ ├── test_preview_service.py
│ │ │ ├── test_preview_view.py
│ │ │ └── test_preview_integration.py # marker: integration
│ │ └── management/commands/ │ │ └── management/commands/
│ │ └── wait_for_db.py │ │ └── wait_for_db.py
│ ├── pyproject.toml # pytest config (markers, addopts)
│ └── templates/ │ └── templates/
│ ├── base.html │ ├── base.html # Logout via POST-Form (Django 5)
│ ├── registration/login.html │ ├── registration/login.html
│ └── mailmerge/… │ └── mailmerge/
│ └── job_form.html # Form + Preview-Button + iframe
├── nginx/ ├── nginx/
│ ├── nginx.conf │ ├── nginx.conf
│ └── conf.d/serienbrief.conf │ └── conf.d/serienbrief.conf
@@ -200,16 +216,50 @@ serienbrief/
## Datenfluss eines Serienbrief‑Jobs ## Datenfluss eines Serienbrief‑Jobs
1. **Upload Vorlage:** `LetterTemplate` mit DOCX‑Datei wird angelegt (Admin oder UI) 1. **Upload Vorlage:** `LetterTemplate` mit DOCX‑Datei wird angelegt (Admin oder UI)
2. **Job‑Erstellung:** User wählt Vorlage + lädt CSV hoch → `MailMergeJob(status=queued)` 2. **Job‑Erstellung:** User wählt Vorlage + lädt CSV hoch im `job_form.html`
3. **Enqueue:** View löst `process_mailmerge_job.delay(job.pk)` aus 3. **Optional: Preview** — Button »Vorschau (erste Zeile)« löst synchronen POST an `/jobs/preview/` aus, Ergebnis‑PDF erscheint inline im iframe (siehe nächster Abschnitt)
4. **Worker holt Task:** 4. **Submit:** Klick auf »Job starten« → `MailMergeJob(status=queued)` angelegt
5. **Enqueue:** View löst `process_mailmerge_job.delay(job.pk)` aus
6. **Worker holt Task:**
- Liest CSV (UTF‑8, validiert Header gegen Platzhalter) - Liest CSV (UTF‑8, validiert Header gegen Platzhalter)
- Iteriert Zeilen → docxtpl rendert pro Zeile eine temporäre DOCX in `/tmp` - Iteriert Zeilen → docxtpl rendert pro Zeile eine temporäre DOCX in `/tmp`
- LibreOffice (`soffice --headless --convert-to pdf …`) erzeugt pro DOCX ein PDF - LibreOffice (`soffice --headless --convert-to pdf …`) erzeugt pro DOCX ein PDF
- pypdf merged alle PDFs in eine Ausgabedatei unter `media_files` - pypdf merged alle PDFs in eine Ausgabedatei unter `media_files`
- `JobLogEntry`s pro Schritt; Status `running → done` bzw. `failed` - `JobLogEntry`s pro Schritt; Status `running → done` bzw. `failed`
5. **UI‑Polling:** HTMX fragt `/jobs/<id>/status/` alle ~2 s ab → Fortschrittsbalken 7. **UI‑Polling:** HTMX fragt `/jobs/<id>/status/` alle ~2 s ab → Fortschrittsbalken
6. **Download:** Nutzer klickt Download → View prüft Auth → Antwort enthält `X‑Accel‑Redirect: /protected/…` → Nginx streamt das PDF 8. **Download:** Nutzer klickt Download → View prüft Auth → Antwort enthält `X‑Accel‑Redirect: /protected/…` → Nginx streamt das PDF
---
## Single‑Letter‑Preview
Vor dem Komplett‑Render kann eine Vorschau auf Basis der ersten CSV‑Zeile erstellt werden. Spart Zeit bei der Template‑Entwicklung und verhindert, dass fehlerhafte Templates 200 PDFs ungenutzt produzieren.
### Design‑Entscheidungen
| Aspekt | Wahl | Begründung |
|---|---|---|
| Ausführung | **synchron im `web`‑Container** | Renderzeit < 5 s, kein Celery‑Roundtrip nötig, kein State |
| Persistenz | **keine** — kein `MailMergeJob`, keine Datei auf `media_files` | Preview ist ephemer, hinterlässt keine DSGVO‑relevanten Artefakte |
| Render‑Pfad | tmpfs `/tmp` (size‑limitiert, kein Disk‑I/O) | LibreOffice‑User‑Profil und Zwischen‑DOCX werden mit Request‑Ende verworfen |
| Validierung | strikt: fehlende Spalten → `PreviewError` | Im Komplett‑Job aktuell warn‑only — Preview‑Pfad gibt klare Fehlermeldung ins UI |
| Auth | `@login_required`, POST‑only | gleiche Schutzklasse wie Komplett‑Job |
| Response | `Content‑Type: application/pdf`, `Content‑Disposition: inline` | iframe im Form zeigt das PDF direkt |
### Response‑Header
Die Preview‑View setzt Diagnose‑Header, die das Front‑End auswertet:
- `X-Preview-Placeholders` — Komma‑Liste der im Template gefundenen Platzhalter
- `X-Preview-Extra-Columns` — Komma‑Liste der CSV‑Spalten, die das Template nicht verwendet (UI zeigt Hinweis)
### Fehlerfälle
| Auslöser | Verhalten |
|---|---|
| Form ungültig (fehlende Datei, falsche Extension) | Formular wird mit Django‑Form‑Errors neu gerendert |
| `PreviewError` (z.B. Spalte fehlt) | Formular mit `preview_error`‑Message neu gerendert |
| LibreOffice‑Crash / Timeout | Formular mit generischer Fehlermeldung; Stacktrace im Server‑Log |
--- ---
@@ -518,6 +568,8 @@ docker compose up -d
# Debugging: F5 in VS Code → attach an debugpy:5678 # Debugging: F5 in VS Code → attach an debugpy:5678
``` ```
**Hinweis zum Compose‑Aufruf:** Niemals `docker compose -f docker-compose.yml up -d` verwenden — das ignoriert die Override‑Datei und führt zu Netzwerk‑Race‑Conditions sowie zur falschen UID im Container. Compose merged `docker-compose.yml` + `docker-compose.override.yml` automatisch, wenn keine `-f`‑Flags gesetzt sind.
### Wichtige Unterschiede Dev ↔ Prod ### Wichtige Unterschiede Dev ↔ Prod
| Aspekt | Dev | Prod | | Aspekt | Dev | Prod |
@@ -532,6 +584,38 @@ docker compose up -d
--- ---
## Tests
Die Test‑Suite liegt unter `app/mailmerge/tests/` und ist in drei Schichten organisiert:
| Layer | Datei | Was wird geprüft | LibreOffice |
|---|---|---|---|
| Service‑Unit | `test_preview_service.py` | CSV‑Parsing, Header‑Validierung, Result‑Aufbau, Fehlerpfade | gemockt |
| View | `test_preview_view.py` | HTTP‑Layer: Auth, Method‑Restriction, Form‑Validation, Response‑Header, Fehler‑Rendering | gemockt |
| Integration | `test_preview_integration.py` | End‑to‑End mit echtem `soffice --headless` | **real** (Marker `integration`) |
**Pytest‑Konfiguration** (`app/pyproject.toml`):
- `--reuse-db` für schnelle Re‑Runs
- Default‑Selektor `-m 'not integration'` — Integration‑Tests laufen nur auf Anforderung
- `python_files = ["test_*.py", "*_test.py", "tests.py"]`
**Schnellstart:**
```bash
# Erster Lauf (DB anlegen)
docker compose exec web pytest mailmerge/tests/ -v --create-db
# Normale Re‑Runs
docker compose exec web pytest mailmerge/tests/ -v
# Inkl. Integration (langsamer, benötigt LibreOffice im Image)
docker compose exec web pytest mailmerge/tests/ -m integration -v
```
Detaillierte Anleitung inkl. Coverage, Fixtures, Debugging und CI‑Integration: siehe **[TEST.md](TEST.md)**.
---
## Betrieb & Wartung ## Betrieb & Wartung
### Häufige Operationen ### Häufige Operationen
@@ -596,7 +680,7 @@ docker compose exec -T db pg_restore -U serienbrief -d serienbrief --clean < bac
- **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 - **Rechtsgrundlage:** Verarbeitung personenbezogener Adress‑/Empfängerdaten je nach Anwendungsfall Art. 6 Abs. 1 lit. b/c/e DSGVO; bei besonderen Datenkategorien (Art. 9 DSGVO) gesonderte Rechtsgrundlage erforderlich
- **Speicherort:** Innerbetrieblicher Server, kein Cloud‑Transfer - **Speicherort:** Innerbetrieblicher Server, kein Cloud‑Transfer
- **Zugriffssteuerung:** Nur authentifizierte User; Trennung Templates/Jobs aktuell flach — Mandanten‑/Rollenmodell ggf. ergänzen (siehe Roadmap) - **Zugriffssteuerung:** Nur authentifizierte User; Trennung Templates/Jobs aktuell flach — Mandanten‑/Rollenmodell ggf. ergänzen (siehe Roadmap)
- **Aufbewahrung:** Jobs + CSVs werden nach `JOB_RETENTION_DAYS` (default 30) gelöscht — Cleanup‑Task noch zu implementieren (siehe Roadmap) - **Aufbewahrung:** Jobs + CSVs + Output‑PDFs werden nach `JOB_RETENTION_DAYS` (default 30) automatisch gelöscht. Der Retention‑Cleanup läuft als Celery‑Beat‑Periodic‑Task einmal täglich. Manuelle Ausführung/Dry‑Run: `docker compose exec web python manage.py cleanup_jobs --dry-run`
- **Audit:** Jeder Job hat ein `JobLogEntry`‑Protokoll (wer, wann, was, Fehler) - **Audit:** Jeder Job hat ein `JobLogEntry`‑Protokoll (wer, wann, was, Fehler)
- **Backup‑Verschlüsselung:** Bind‑Mount `./backups` liegt aktuell unverschlüsselt — für produktiven Einsatz LUKS/Filesystem‑Encryption am Host **dringend empfohlen** - **Backup‑Verschlüsselung:** Bind‑Mount `./backups` liegt aktuell unverschlüsselt — für produktiven Einsatz LUKS/Filesystem‑Encryption am Host **dringend empfohlen**
- **DSFA‑Relevanz:** Bei Verarbeitung besonderer Datenkategorien oder umfangreicher Profilbildung ist eine Datenschutz‑Folgenabschätzung gemäß Art. 35 DSGVO durchzuführen, bevor produktiver Echtbetrieb startet - **DSFA‑Relevanz:** Bei Verarbeitung besonderer Datenkategorien oder umfangreicher Profilbildung ist eine Datenschutz‑Folgenabschätzung gemäß Art. 35 DSGVO durchzuführen, bevor produktiver Echtbetrieb startet
@@ -608,17 +692,22 @@ docker compose exec -T db pg_restore -U serienbrief -d serienbrief --clean < bac
### Tech‑Debt ### Tech‑Debt
- Postgres‑Passwort doppelt gehalten (`DATABASE_URL` + Secret‑Datei) → refactor zu Single Source via Entrypoint, der `DATABASE_URL` aus `*_FILE`‑Env zusammenbaut - Postgres‑Passwort doppelt gehalten (`DATABASE_URL` + Secret‑Datei) → refactor zu Single Source via Entrypoint, der `DATABASE_URL` aus `*_FILE`‑Env zusammenbaut
- Keine LDAP/AD‑Anbindung — aktuell lokale Django‑User - Keine LDAP/AD‑Anbindung — aktuell lokale Django‑User
- Keine Periodic Tasks im Beat registriert (Retention‑Cleanup, Log‑Rotation) - CSV‑Header‑Validierung im **Haupt‑Job‑Flow** warn‑only (im Preview‑Pfad bereits strikt)
- CSV‑Header‑Validierung ist warn‑only, kein Abbruch bei fehlenden Pflichtfeldern
- Keine Vorschau (Single‑Letter‑Preview) vor dem Komplett‑Render
- Image enthält LibreOffice (~400 MB) — könnte in separates Worker‑Image ausgelagert werden - Image enthält LibreOffice (~400 MB) — könnte in separates Worker‑Image ausgelagert werden
- Tests decken Preview + Retention ab — Haupt‑Job‑Flow (Celery‑Task `run_mailmerge`, Status‑Polling, X‑Accel‑Redirect) noch ohne automatisierte Tests
### Erledigt
- [x] Single‑Letter‑Preview im Job‑Erstellungs‑Flow
- [x] Test‑Suite mit pytest‑django (3 Layer)
- [x] Logout via POST‑Form (Django 5)
- [x] Retention‑Cleanup als periodic task (`mailmerge.cleanup_expired_jobs`, täglich)
### Roadmap (kurzfristig) ### Roadmap (kurzfristig)
- [ ] Single‑Letter‑Preview im Job‑Erstellungs‑Flow - [ ] CSV‑Validierung strict mit Pflichtfeldliste pro Template (auch im Haupt‑Flow)
- [ ] Retention‑Cleanup als periodic task - [ ] Tests für Celery‑Task `run_mailmerge` (mit `CELERY_TASK_ALWAYS_EAGER`)
- [ ] CSV‑Validierung strict mit Pflichtfeldliste pro Template
- [ ] Mandanten‑/Berechtigungsmodell (Abteilung X sieht nur eigene Templates) - [ ] Mandanten‑/Berechtigungsmodell (Abteilung X sieht nur eigene Templates)
- [ ] LDAP‑Auth über `django‑auth‑ldap` (AD‑Integration GB) - [ ] LDAP‑Auth über `django‑auth‑ldap`
- [ ] Postgres‑Passwort als Single Source of Truth (`*_FILE`‑Env)
### Roadmap (mittelfristig) ### Roadmap (mittelfristig)
- [ ] AppArmor‑Profile pro Container - [ ] AppArmor‑Profile pro Container
@@ -0,0 +1,56 @@
"""
Management-Command für den Retention-Cleanup.
Nutzbar für:
- Dry-Run-Audits ("welche Jobs würden gelöscht werden?")
- Ad-hoc-Ausführung außerhalb des Beat-Schedules (z.B. nach DSGVO-Anfrage)
- Smoke-Test in CI
Beispiele:
docker compose exec web python manage.py cleanup_jobs --dry-run
docker compose exec web python manage.py cleanup_jobs --days 7
docker compose exec web python manage.py cleanup_jobs
"""
from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
from mailmerge.services.retention import cleanup_expired_jobs
class Command(BaseCommand):
help = "Löscht abgelaufene MailMergeJobs (Status DONE/FAILED, älter als Retention-Frist)."
def add_arguments(self, parser):
parser.add_argument(
"--days",
type=int,
default=None,
help="Überschreibt JOB_RETENTION_DAYS aus den Settings.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Nur Kandidaten zählen, nichts löschen.",
)
def handle(self, *args, **options):
days = options["days"]
dry_run = options["dry_run"]
try:
result = cleanup_expired_jobs(retention_days=days, dry_run=dry_run)
except ValueError as exc:
raise CommandError(str(exc)) from exc
self.stdout.write(f"Stichtag : {result.cutoff.isoformat()}")
self.stdout.write(f"Dry-Run : {result.dry_run}")
self.stdout.write(f"Kandidaten : {result.candidates}")
self.stdout.write(f"Gelöschte Jobs : {result.deleted_jobs}")
self.stdout.write(f"Gelöschte Files: {result.deleted_files}")
if result.errors:
self.stdout.write(self.style.WARNING(f"Fehler ({len(result.errors)}):"))
for err in result.errors:
self.stdout.write(self.style.WARNING(f" - {err}"))
elif not dry_run:
self.stdout.write(self.style.SUCCESS("Cleanup erfolgreich."))
@@ -0,0 +1,74 @@
"""
Registriert den Retention-Cleanup als Periodic Task in django_celery_beat.
Standardplan: täglich um 03:15 Uhr (kollidiert nicht mit dem Backup-Service,
der i.d.R. um 03:00 läuft, und liegt in der Wartungsphase).
Die Zeitzone richtet sich nach `settings.CELERY_TIMEZONE` bzw. `TIME_ZONE`.
"""
from __future__ import annotations
from django.db import migrations
CRON_HOUR = "3"
CRON_MINUTE = "15"
TASK_NAME = "Retention-Cleanup: abgelaufene Jobs löschen"
TASK_DOTTED = "mailmerge.cleanup_expired_jobs"
def create_periodic_task(apps, schema_editor):
CrontabSchedule = apps.get_model("django_celery_beat", "CrontabSchedule")
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
from django.conf import settings
schedule, _ = CrontabSchedule.objects.get_or_create(
minute=CRON_MINUTE,
hour=CRON_HOUR,
day_of_week="*",
day_of_month="*",
month_of_year="*",
timezone=getattr(settings, "CELERY_TIMEZONE", settings.TIME_ZONE),
)
PeriodicTask.objects.update_or_create(
name=TASK_NAME,
defaults={
"crontab": schedule,
"task": TASK_DOTTED,
"enabled": True,
"description": (
"Entfernt MailMergeJobs im Status DONE/FAILED, deren "
"finished_at älter als JOB_RETENTION_DAYS ist. "
"Inkl. recipients_csv und result_pdf auf dem Storage."
),
},
)
def remove_periodic_task(apps, schema_editor):
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
PeriodicTask.objects.filter(name=TASK_NAME).delete()
class Migration(migrations.Migration):
"""Achtung: Wir definieren KEINE konkrete Migrationsabhängigkeit zu
django_celery_beat, weil dessen letzte Migration je Paketversion
unterschiedlich heißt. `run_before`/`dependencies` mit `("__latest__")`
gibt es nicht. Stattdessen verlassen wir uns auf die Reihenfolge:
django_celery_beat steht in INSTALLED_APPS und wird mit `migrate` immer
vorab gewandert (Django sortiert nach Abhängigkeiten der App-Initials).
Beim Build wird `migrate` ohnehin sequentiell ausgeführt, daher läuft
diese Migration zuverlässig nach allen django_celery_beat-Migrationen,
sobald `mailmerge.0001_initial` durch ist.
"""
dependencies = [
("mailmerge", "0001_initial"),
]
operations = [
migrations.RunPython(create_periodic_task, remove_periodic_task),
]
+142
View File
@@ -0,0 +1,142 @@
"""
Retention-/Cleanup-Service für abgeschlossene MailMergeJobs.
Designziele:
- DSGVO-Compliance: personenbezogene CSV-Daten und generierte PDFs werden
nach Ablauf der Aufbewahrungsfrist entfernt.
- Service-Logik strikt von Celery getrennt → unit-testbar ohne Broker.
- Bulk-Löschung über das ORM, damit File-Felder ihren `delete()`-Storage-Hook
aufrufen (FileField-Dateien werden mit gelöscht).
- Dry-Run als First-Class-Citizen für Audits.
Was wird gelöscht:
- MailMergeJobs mit `status in {DONE, FAILED}` UND
`finished_at` (oder `created_at` als Fallback) älter als Stichtag.
- Wartende oder laufende Jobs werden **nie** gelöscht.
Was bleibt:
- LetterTemplates (Vorlagen sind keine personenbezogenen Daten).
- JobLogEntries werden über `on_delete=CASCADE` mit dem Job entfernt.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
from django.conf import settings
from django.db.models import Q, QuerySet
from django.utils import timezone
from mailmerge.models import MailMergeJob
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class CleanupResult:
"""Ergebnis eines Cleanup-Laufs."""
cutoff: datetime
dry_run: bool
candidates: int
deleted_jobs: int
deleted_files: int
errors: list[str]
# Status-Werte, die als "fertig" gelten und damit für Cleanup in Frage kommen.
TERMINAL_STATUSES = (MailMergeJob.Status.DONE, MailMergeJob.Status.FAILED)
def get_cutoff(retention_days: Optional[int] = None) -> datetime:
"""Berechnet den Stichtag. Jobs, die vor diesem Zeitpunkt fertig wurden,
sind Kandidaten für die Löschung."""
days = retention_days if retention_days is not None else settings.JOB_RETENTION_DAYS
if days < 1:
raise ValueError(
f"retention_days muss >= 1 sein (war: {days}). "
"0 oder negativ würde alle Jobs sofort löschen."
)
return timezone.now() - timedelta(days=days)
def expired_jobs_queryset(cutoff: datetime) -> QuerySet[MailMergeJob]:
"""QuerySet aller Jobs, die für die Löschung in Frage kommen.
Nutzt `finished_at` wenn gesetzt, sonst `created_at` als Fallback
(z.B. falls ein Job ohne sauberen Finish-Zeitstempel hängengeblieben ist).
"""
return MailMergeJob.objects.filter(
Q(status__in=TERMINAL_STATUSES)
& (
Q(finished_at__lt=cutoff)
| Q(finished_at__isnull=True, created_at__lt=cutoff)
)
)
def cleanup_expired_jobs(
retention_days: Optional[int] = None,
dry_run: bool = False,
) -> CleanupResult:
"""Löscht alle abgelaufenen Jobs inkl. ihrer FileField-Dateien.
Args:
retention_days: Optional, überschreibt `settings.JOB_RETENTION_DAYS`.
dry_run: Wenn True, wird nichts gelöscht — nur Kandidaten gezählt.
Returns:
CleanupResult mit Stichtag, Kandidatenzahl, gelöschten Datensätzen
und gelöschten Dateien. Bei Fehlern wird die Exception einzelner
Datei-Löschungen geloggt, der Lauf bricht aber nicht ab.
"""
cutoff = get_cutoff(retention_days)
qs = expired_jobs_queryset(cutoff)
candidates = qs.count()
logger.info(
"Cleanup gestartet: cutoff=%s, dry_run=%s, kandidaten=%d",
cutoff.isoformat(), dry_run, candidates,
)
if dry_run or candidates == 0:
return CleanupResult(
cutoff=cutoff, dry_run=dry_run, candidates=candidates,
deleted_jobs=0, deleted_files=0, errors=[],
)
deleted_files = 0
errors: list[str] = []
# Wichtig: einzeln iterieren, damit FileField.delete() pro Job
# die zugehörigen Dateien vom Storage entfernt.
# `MailMergeJob.objects.filter(...).delete()` würde die Storage-Hooks
# NICHT aufrufen — Files blieben verwaist.
for job in qs.iterator(chunk_size=100):
try:
if job.recipients_csv and job.recipients_csv.name:
job.recipients_csv.delete(save=False)
deleted_files += 1
if job.result_pdf and job.result_pdf.name:
job.result_pdf.delete(save=False)
deleted_files += 1
except OSError as exc:
msg = f"Datei-Löschung für Job {job.id} fehlgeschlagen: {exc}"
logger.warning(msg)
errors.append(msg)
# DB-Löschung in einem Bulk-Statement (CASCADE entfernt JobLogEntry)
deleted_jobs, _ = qs.delete()
logger.info(
"Cleanup beendet: jobs_deleted=%d, files_deleted=%d, errors=%d",
deleted_jobs, deleted_files, len(errors),
)
return CleanupResult(
cutoff=cutoff, dry_run=False, candidates=candidates,
deleted_jobs=deleted_jobs, deleted_files=deleted_files,
errors=errors,
)
+26
View File
@@ -16,6 +16,7 @@ from django.utils import timezone
from .models import JobLogEntry, MailMergeJob from .models import JobLogEntry, MailMergeJob
from .services.docx_renderer import docx_to_pdf, render_docx from .services.docx_renderer import docx_to_pdf, render_docx
from .services.pdf_merge import merge_pdfs from .services.pdf_merge import merge_pdfs
from .services.retention import cleanup_expired_jobs
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -85,3 +86,28 @@ def run_mailmerge(self, job_id: str) -> str:
job.save() job.save()
_log(job, "error", f"Job fehlgeschlagen: {exc}") _log(job, "error", f"Job fehlgeschlagen: {exc}")
raise raise
# ---------------------------------------------------------------------------
# Retention-Cleanup
# ---------------------------------------------------------------------------
@shared_task(name="mailmerge.cleanup_expired_jobs")
def cleanup_expired_jobs_task(retention_days: int | None = None,
dry_run: bool = False) -> dict:
"""Periodic Task: löscht abgelaufene Jobs inkl. Files.
Wird vom Beat-Scheduler aufgerufen. Ergebnis als Dict (Celery-Result-
Backend serialisiert Dataclass-Instanzen nicht out-of-the-box).
"""
result = cleanup_expired_jobs(retention_days=retention_days, dry_run=dry_run)
summary = {
"cutoff": result.cutoff.isoformat(),
"dry_run": result.dry_run,
"candidates": result.candidates,
"deleted_jobs": result.deleted_jobs,
"deleted_files": result.deleted_files,
"errors": result.errors,
}
logger.info("Retention-Cleanup-Task abgeschlossen: %s", summary)
return summary
@@ -0,0 +1,245 @@
"""
Unit-Tests für mailmerge.services.retention.
Decken ab:
- Stichtag-Berechnung (inkl. Edge Cases days < 1)
- QuerySet-Logik (welche Jobs sind Kandidaten?)
- Bulk-Cleanup mit Dry-Run
- File-Löschung wird ausgelöst
- Wartende/laufende Jobs werden NICHT gelöscht
- finished_at-Fallback auf created_at
"""
from __future__ import annotations
from datetime import timedelta
from unittest.mock import patch
import pytest
from django.core.files.base import ContentFile
from django.utils import timezone
from mailmerge.models import JobLogEntry, LetterTemplate, MailMergeJob
from mailmerge.services.retention import (
CleanupResult,
cleanup_expired_jobs,
expired_jobs_queryset,
get_cutoff,
)
pytestmark = pytest.mark.django_db
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def template(user):
return LetterTemplate.objects.create(
name="Retention-Testvorlage",
file=ContentFile(b"dummy-docx", name="tpl.docx"),
placeholders=["vorname"],
created_by=user,
)
def _make_job(template, user, *, status, finished_offset_days=None,
created_offset_days=0, with_files=False):
"""Hilfsfunktion zum Anlegen eines Jobs mit künstlichem Zeitstempel.
finished_offset_days: positive Zahl = vor X Tagen abgeschlossen.
None = finished_at bleibt NULL (Fallback-Test).
Reihenfolge wichtig: Files MUSS vor dem Zurückdatieren der Timestamps
angehängt werden, weil `FileField.save(save=True)` ein `instance.save()`
triggert, das die per `.update()` gesetzten Datumswerte wieder mit den
aktuellen Memory-Werten überschreibt.
"""
now = timezone.now()
job = MailMergeJob.objects.create(
template=template,
recipients_csv=ContentFile(b"vorname\nAnna\n", name="r.csv"),
status=status,
created_by=user,
)
if with_files:
# save=False, damit kein zusätzliches instance.save() die
# Zeitstempel verbiegt. Die Datei landet trotzdem auf dem Storage.
job.result_pdf.save("out.pdf", ContentFile(b"%PDF-1.4"), save=False)
job.save(update_fields=["result_pdf"])
# Jetzt erst die Timestamps zurückdatieren — über `.update()`, damit
# auto_now_add nicht greift und kein zusätzliches .save() ausgelöst wird.
new_created = now - timedelta(days=created_offset_days)
new_finished = (
now - timedelta(days=finished_offset_days)
if finished_offset_days is not None else None
)
MailMergeJob.objects.filter(pk=job.pk).update(
created_at=new_created,
finished_at=new_finished,
)
job.refresh_from_db()
return job
# ---------------------------------------------------------------------------
# get_cutoff
# ---------------------------------------------------------------------------
class TestGetCutoff:
def test_uses_settings_default(self, settings):
settings.JOB_RETENTION_DAYS = 30
cutoff = get_cutoff()
delta = timezone.now() - cutoff
# Toleranz, weil ein paar Millisekunden zwischen den Aufrufen liegen
assert timedelta(days=29, hours=23) < delta < timedelta(days=30, hours=1)
def test_explicit_overrides_settings(self, settings):
settings.JOB_RETENTION_DAYS = 30
cutoff = get_cutoff(retention_days=7)
delta = timezone.now() - cutoff
assert timedelta(days=6, hours=23) < delta < timedelta(days=7, hours=1)
def test_zero_raises(self):
with pytest.raises(ValueError):
get_cutoff(retention_days=0)
def test_negative_raises(self):
with pytest.raises(ValueError):
get_cutoff(retention_days=-5)
# ---------------------------------------------------------------------------
# expired_jobs_queryset
# ---------------------------------------------------------------------------
class TestExpiredJobsQueryset:
def test_done_job_older_than_cutoff_is_candidate(self, template, user):
_make_job(template, user, status=MailMergeJob.Status.DONE,
finished_offset_days=40)
cutoff = timezone.now() - timedelta(days=30)
assert expired_jobs_queryset(cutoff).count() == 1
def test_done_job_younger_than_cutoff_is_not_candidate(self, template, user):
_make_job(template, user, status=MailMergeJob.Status.DONE,
finished_offset_days=10)
cutoff = timezone.now() - timedelta(days=30)
assert expired_jobs_queryset(cutoff).count() == 0
def test_failed_jobs_are_candidates(self, template, user):
_make_job(template, user, status=MailMergeJob.Status.FAILED,
finished_offset_days=40)
cutoff = timezone.now() - timedelta(days=30)
assert expired_jobs_queryset(cutoff).count() == 1
def test_running_jobs_are_never_candidates(self, template, user):
_make_job(template, user, status=MailMergeJob.Status.RUNNING,
finished_offset_days=None, created_offset_days=999)
cutoff = timezone.now() - timedelta(days=30)
assert expired_jobs_queryset(cutoff).count() == 0
def test_pending_jobs_are_never_candidates(self, template, user):
_make_job(template, user, status=MailMergeJob.Status.PENDING,
finished_offset_days=None, created_offset_days=999)
cutoff = timezone.now() - timedelta(days=30)
assert expired_jobs_queryset(cutoff).count() == 0
def test_finished_at_null_falls_back_to_created_at(self, template, user):
"""Job ohne finished_at, aber created_at > cutoff → muss matchen.
Schützt vor verwaisten 'DONE'-Jobs, bei denen aus irgendeinem Grund
finished_at fehlt (z.B. Crash zwischen save() und finished_at).
"""
_make_job(template, user, status=MailMergeJob.Status.DONE,
finished_offset_days=None, created_offset_days=40)
cutoff = timezone.now() - timedelta(days=30)
assert expired_jobs_queryset(cutoff).count() == 1
# ---------------------------------------------------------------------------
# cleanup_expired_jobs
# ---------------------------------------------------------------------------
class TestCleanupHappyPath:
def test_deletes_expired_done_job(self, template, user):
_make_job(template, user, status=MailMergeJob.Status.DONE,
finished_offset_days=40)
result = cleanup_expired_jobs(retention_days=30)
assert result.deleted_jobs == 1
assert MailMergeJob.objects.count() == 0
def test_keeps_recent_job(self, template, user):
_make_job(template, user, status=MailMergeJob.Status.DONE,
finished_offset_days=10)
result = cleanup_expired_jobs(retention_days=30)
assert result.deleted_jobs == 0
assert MailMergeJob.objects.count() == 1
def test_deletes_associated_files(self, template, user):
job = _make_job(template, user, status=MailMergeJob.Status.DONE,
finished_offset_days=40, with_files=True)
# Eingangs-CSV + Output-PDF = 2 Dateien
assert job.recipients_csv.name
assert job.result_pdf.name
result = cleanup_expired_jobs(retention_days=30)
assert result.deleted_files == 2
def test_cascade_removes_log_entries(self, template, user):
job = _make_job(template, user, status=MailMergeJob.Status.DONE,
finished_offset_days=40)
JobLogEntry.objects.create(job=job, level="info", message="x")
JobLogEntry.objects.create(job=job, level="info", message="y")
assert JobLogEntry.objects.count() == 2
cleanup_expired_jobs(retention_days=30)
assert JobLogEntry.objects.count() == 0
def test_returns_cleanup_result(self, template, user):
_make_job(template, user, status=MailMergeJob.Status.DONE,
finished_offset_days=40)
result = cleanup_expired_jobs(retention_days=30)
assert isinstance(result, CleanupResult)
assert result.dry_run is False
assert result.candidates == 1
class TestCleanupDryRun:
def test_dry_run_deletes_nothing(self, template, user):
_make_job(template, user, status=MailMergeJob.Status.DONE,
finished_offset_days=40)
result = cleanup_expired_jobs(retention_days=30, dry_run=True)
assert result.dry_run is True
assert result.candidates == 1
assert result.deleted_jobs == 0
assert MailMergeJob.objects.count() == 1
def test_dry_run_counts_candidates(self, template, user):
for _ in range(3):
_make_job(template, user, status=MailMergeJob.Status.DONE,
finished_offset_days=40)
result = cleanup_expired_jobs(retention_days=30, dry_run=True)
assert result.candidates == 3
class TestCleanupMixed:
def test_only_expired_terminal_jobs_are_touched(self, template, user):
# 1× expired DONE, 1× expired FAILED → werden gelöscht
_make_job(template, user, status=MailMergeJob.Status.DONE,
finished_offset_days=40)
_make_job(template, user, status=MailMergeJob.Status.FAILED,
finished_offset_days=40)
# 1× recent DONE, 1× recent FAILED → bleiben
_make_job(template, user, status=MailMergeJob.Status.DONE,
finished_offset_days=10)
_make_job(template, user, status=MailMergeJob.Status.FAILED,
finished_offset_days=10)
# 1× running, 1× pending (älter als cutoff) → bleiben in jedem Fall
_make_job(template, user, status=MailMergeJob.Status.RUNNING,
finished_offset_days=None, created_offset_days=999)
_make_job(template, user, status=MailMergeJob.Status.PENDING,
finished_offset_days=None, created_offset_days=999)
result = cleanup_expired_jobs(retention_days=30)
assert result.deleted_jobs == 2
assert MailMergeJob.objects.count() == 4
+26
View File
@@ -0,0 +1,26 @@
cd ~/projekte/serienbrief
# 1) Neue Dateien an ihre Plätze legen (Pfade siehe oben in der Liste)
# - app/mailmerge/services/retention.py (NEU)
# - app/mailmerge/tasks.py (PATCH: cleanup-Task hinten dran)
# - app/mailmerge/migrations/0002_retention_cleanup_periodic_task.py (NEU)
# - app/mailmerge/management/commands/cleanup_jobs.py (NEU)
# - app/mailmerge/tests/test_retention_service.py (NEU)
# - README.md (PATCH)
# 2) Migration ausführen
docker compose exec web python manage.py migrate
# 3) Dry-Run: was würde gelöscht werden?
docker compose exec web python manage.py cleanup_jobs --dry-run
# 4) Optional sofort manuell laufen lassen
docker compose exec web python manage.py cleanup_jobs
# 5) Im Admin (/admin/django_celery_beat/periodictask/) prüfen,
# dass "Retention-Cleanup: abgelaufene Jobs löschen" eingetragen ist
# 6) Tests
docker compose exec web pytest mailmerge/tests/test_retention_service.py -v
# oder alles:
docker compose exec web pytest mailmerge/tests/ -v