cleanup von abgeschlossenen jobs
This commit is contained in:
@@ -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),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user