From 848e48b0dd43af5e3d6ef173d27adaa0ef373752 Mon Sep 17 00:00:00 2001 From: Hans-Christian Payer Date: Fri, 22 May 2026 09:14:31 +0200 Subject: [PATCH] cleanup von abgeschlossenen jobs --- README.md | 151 ++++++++--- .../management/commands/cleanup_jobs.py | 56 ++++ .../0002_retention_cleanup_periodic_task.py | 74 ++++++ app/mailmerge/services/retention.py | 142 ++++++++++ app/mailmerge/tasks.py | 26 ++ app/mailmerge/tests/test_retention_service.py | 245 ++++++++++++++++++ notes | 26 ++ 7 files changed, 689 insertions(+), 31 deletions(-) create mode 100644 app/mailmerge/management/commands/cleanup_jobs.py create mode 100644 app/mailmerge/migrations/0002_retention_cleanup_periodic_task.py create mode 100644 app/mailmerge/services/retention.py create mode 100644 app/mailmerge/tests/test_retention_service.py create mode 100644 notes diff --git a/README.md b/README.md index 5ba21be..4c903d5 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,18 @@ Zielumgebung: internes LAN, hinter zentralem Reverse‑Proxy (TLS‑Terminierung 4. [Komponenten im Detail](#komponenten-im-detail) 5. [Verzeichnisstruktur](#verzeichnisstruktur) 6. [Datenfluss eines Serienbrief‑Jobs](#datenfluss-eines-serienbriefjobs) -7. [Datenmodell](#datenmodell) -8. [Sicherheitskonzept](#sicherheitskonzept) -9. [Netzwerk & Ports](#netzwerk--ports) -10. [Volumes & Persistenz](#volumes--persistenz) -11. [Konfiguration](#konfiguration) -12. [Inbetriebnahme](#inbetriebnahme) -13. [Entwicklung in VS Code](#entwicklung-in-vs-code) -14. [Betrieb & Wartung](#betrieb--wartung) -15. [Datenschutz & Compliance](#datenschutz--compliance) -16. [Bekannte Einschränkungen & Roadmap](#bekannte-einschränkungen--roadmap) +7. [Single‑Letter‑Preview](#singleletterpreview) +8. [Datenmodell](#datenmodell) +9. [Sicherheitskonzept](#sicherheitskonzept) +10. [Netzwerk & Ports](#netzwerk--ports) +11. [Volumes & Persistenz](#volumes--persistenz) +12. [Konfiguration](#konfiguration) +13. [Inbetriebnahme](#inbetriebnahme) +14. [Entwicklung in VS Code](#entwicklung-in-vs-code) +15. [Tests](#tests) +16. [Betrieb & Wartung](#betrieb--wartung) +17. [Datenschutz & Compliance](#datenschutz--compliance) +18. [Bekannte Einschränkungen & Roadmap](#bekannte-einschränkungen--roadmap) --- @@ -31,13 +33,16 @@ Zielumgebung: internes LAN, hinter zentralem Reverse‑Proxy (TLS‑Terminierung - DOCX‑Vorlagen mit Jinja‑artigen Platzhaltern (`{{ feldname }}`) via **docxtpl** - CSV‑Upload als Empfängerliste (UTF‑8, Komma‑Trenner) -- Asynchrone Verarbeitung über Celery — UI bleibt responsiv, Status‑Polling per HTMX +- **Single‑Letter‑Preview** vor dem Komplett‑Render: rendert nur die erste CSV‑Zeile synchron als PDF und liefert es inline aus — kein Job, keine Persistenz, kein Queue‑Roundtrip +- Asynchrone Verarbeitung des Komplett‑Jobs über Celery — UI bleibt responsiv, Status‑Polling per HTMX - DOCX → PDF Konversion mit headless **LibreOffice** - Zusammenführung aller Einzel‑PDFs via **pypdf** - Authentifizierung über Djangos Auth‑System, Brute‑Force‑Schutz mit **django‑axes** +- Logout per POST‑Formular (Django 5 Pflicht) - Geschütztes Ausliefern generierter PDFs via **X‑Accel‑Redirect** (Nginx) - Audit‑Log pro Job (Status‑Wechsel, Fehler, abgearbeitete Zeilen) - Tägliches Backup (pg_dump + Media‑Tar, 14 Tage Retention) +- **Test‑Suite** mit pytest‑django (Service‑Unit, View‑Layer, Integration mit echtem LibreOffice) — siehe [TEST.md](TEST.md) --- @@ -96,6 +101,7 @@ Zwei Bridge‑Netze trennen Verantwortlichkeiten: | UI‑Interaktion | django‑htmx | aktuell | | DB‑Adapter | psycopg | 3.2.3 (binary) | | Python | CPython | 3.12 (slim‑bookworm) | +| Tests | pytest, pytest‑django | 8.3 / 4.9 | --- @@ -114,6 +120,7 @@ Zwei Bridge‑Netze trennen Verantwortlichkeiten: - Settings‑Split: `base.py`, `dev.py`, `production.py` - Entrypoint wartet auf DB‑Readiness, führt `migrate` und (in Prod) `collectstatic` aus - Healthcheck via `/healthz` +- **Synchron**er Preview‑Endpoint (`/jobs/preview/`) rendert über LibreOffice direkt im `web`‑Container — kein Celery‑Roundtrip ### `worker` – Celery - Verarbeitet `process_mailmerge_job`‑Tasks @@ -122,8 +129,9 @@ Zwei Bridge‑Netze trennen Verantwortlichkeiten: ### `beat` – Celery Scheduler - Nutzt `DatabaseScheduler` von django‑celery‑beat -- Geplant für: Retention‑Cleanup (alte Jobs/PDFs), Audit‑Log‑Rotation -- *Hinweis: Konkrete Periodic Tasks aktuell noch nicht registriert (siehe Roadmap)* +- Periodic Tasks werden über Django‑Migrationen registriert (reproduzierbar, single source of truth) +- Aktiv: + - **Retention‑Cleanup** (`mailmerge.cleanup_expired_jobs`) — täglich 03:15 (`CELERY_TIMEZONE`) ### `db` – PostgreSQL 16 - Eigenes Volume `postgres_data` @@ -165,20 +173,28 @@ serienbrief/ │ │ └── production.py │ ├── mailmerge/ # Hauptapp │ │ ├── models.py # LetterTemplate, MailMergeJob, JobLogEntry -│ │ ├── views.py +│ │ ├── views.py # incl. job_preview (synchron) │ │ ├── admin.py │ │ ├── forms.py -│ │ ├── urls.py +│ │ ├── urls.py # incl. "jobs/preview/" │ │ ├── tasks.py # Celery tasks │ │ ├── services/ │ │ │ ├── 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/ │ │ └── wait_for_db.py +│ ├── pyproject.toml # pytest config (markers, addopts) │ └── templates/ -│ ├── base.html +│ ├── base.html # Logout via POST-Form (Django 5) │ ├── registration/login.html -│ └── mailmerge/… +│ └── mailmerge/ +│ └── job_form.html # Form + Preview-Button + iframe ├── nginx/ │ ├── nginx.conf │ └── conf.d/serienbrief.conf @@ -200,16 +216,50 @@ serienbrief/ ## Datenfluss eines Serienbrief‑Jobs 1. **Upload Vorlage:** `LetterTemplate` mit DOCX‑Datei wird angelegt (Admin oder UI) -2. **Job‑Erstellung:** User wählt Vorlage + lädt CSV hoch → `MailMergeJob(status=queued)` -3. **Enqueue:** View löst `process_mailmerge_job.delay(job.pk)` aus -4. **Worker holt Task:** +2. **Job‑Erstellung:** User wählt Vorlage + lädt CSV hoch im `job_form.html` +3. **Optional: Preview** — Button »Vorschau (erste Zeile)« löst synchronen POST an `/jobs/preview/` aus, Ergebnis‑PDF erscheint inline im iframe (siehe nächster Abschnitt) +4. **Submit:** Klick auf »Job starten« → `MailMergeJob(status=queued)` angelegt +5. **Enqueue:** View löst `process_mailmerge_job.delay(job.pk)` aus +6. **Worker holt Task:** - Liest CSV (UTF‑8, validiert Header gegen Platzhalter) - Iteriert Zeilen → docxtpl rendert pro Zeile eine temporäre DOCX in `/tmp` - LibreOffice (`soffice --headless --convert-to pdf …`) erzeugt pro DOCX ein PDF - pypdf merged alle PDFs in eine Ausgabedatei unter `media_files` - `JobLogEntry`s pro Schritt; Status `running → done` bzw. `failed` -5. **UI‑Polling:** HTMX fragt `/jobs//status/` alle ~2 s ab → Fortschrittsbalken -6. **Download:** Nutzer klickt Download → View prüft Auth → Antwort enthält `X‑Accel‑Redirect: /protected/…` → Nginx streamt das PDF +7. **UI‑Polling:** HTMX fragt `/jobs//status/` alle ~2 s ab → Fortschrittsbalken +8. **Download:** Nutzer klickt Download → View prüft Auth → Antwort enthält `X‑Accel‑Redirect: /protected/…` → Nginx streamt das PDF + +--- + +## Single‑Letter‑Preview + +Vor dem Komplett‑Render kann eine Vorschau auf Basis der ersten CSV‑Zeile erstellt werden. Spart Zeit bei der Template‑Entwicklung und verhindert, dass fehlerhafte Templates 200 PDFs ungenutzt produzieren. + +### Design‑Entscheidungen + +| Aspekt | Wahl | Begründung | +|---|---|---| +| Ausführung | **synchron im `web`‑Container** | Renderzeit < 5 s, kein Celery‑Roundtrip nötig, kein State | +| Persistenz | **keine** — kein `MailMergeJob`, keine Datei auf `media_files` | Preview ist ephemer, hinterlässt keine DSGVO‑relevanten Artefakte | +| Render‑Pfad | tmpfs `/tmp` (size‑limitiert, kein Disk‑I/O) | LibreOffice‑User‑Profil und Zwischen‑DOCX werden mit Request‑Ende verworfen | +| Validierung | strikt: fehlende Spalten → `PreviewError` | Im Komplett‑Job aktuell warn‑only — Preview‑Pfad gibt klare Fehlermeldung ins UI | +| Auth | `@login_required`, POST‑only | gleiche Schutzklasse wie Komplett‑Job | +| Response | `Content‑Type: application/pdf`, `Content‑Disposition: inline` | iframe im Form zeigt das PDF direkt | + +### Response‑Header + +Die Preview‑View setzt Diagnose‑Header, die das Front‑End auswertet: + +- `X-Preview-Placeholders` — Komma‑Liste der im Template gefundenen Platzhalter +- `X-Preview-Extra-Columns` — Komma‑Liste der CSV‑Spalten, die das Template nicht verwendet (UI zeigt Hinweis) + +### Fehlerfälle + +| Auslöser | Verhalten | +|---|---| +| Form ungültig (fehlende Datei, falsche Extension) | Formular wird mit Django‑Form‑Errors neu gerendert | +| `PreviewError` (z.B. Spalte fehlt) | Formular mit `preview_error`‑Message neu gerendert | +| LibreOffice‑Crash / Timeout | Formular mit generischer Fehlermeldung; Stacktrace im Server‑Log | --- @@ -518,6 +568,8 @@ docker compose up -d # Debugging: F5 in VS Code → attach an debugpy:5678 ``` +**Hinweis zum Compose‑Aufruf:** Niemals `docker compose -f docker-compose.yml up -d` verwenden — das ignoriert die Override‑Datei und führt zu Netzwerk‑Race‑Conditions sowie zur falschen UID im Container. Compose merged `docker-compose.yml` + `docker-compose.override.yml` automatisch, wenn keine `-f`‑Flags gesetzt sind. + ### Wichtige Unterschiede Dev ↔ Prod | Aspekt | Dev | Prod | @@ -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 ### 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 - **Speicherort:** Innerbetrieblicher Server, kein Cloud‑Transfer - **Zugriffssteuerung:** Nur authentifizierte User; Trennung Templates/Jobs aktuell flach — Mandanten‑/Rollenmodell ggf. ergänzen (siehe Roadmap) -- **Aufbewahrung:** Jobs + CSVs werden nach `JOB_RETENTION_DAYS` (default 30) gelöscht — Cleanup‑Task noch zu implementieren (siehe Roadmap) +- **Aufbewahrung:** Jobs + CSVs + Output‑PDFs werden nach `JOB_RETENTION_DAYS` (default 30) automatisch gelöscht. Der Retention‑Cleanup läuft als Celery‑Beat‑Periodic‑Task einmal täglich. Manuelle Ausführung/Dry‑Run: `docker compose exec web python manage.py cleanup_jobs --dry-run` - **Audit:** Jeder Job hat ein `JobLogEntry`‑Protokoll (wer, wann, was, Fehler) - **Backup‑Verschlüsselung:** Bind‑Mount `./backups` liegt aktuell unverschlüsselt — für produktiven Einsatz LUKS/Filesystem‑Encryption am Host **dringend empfohlen** - **DSFA‑Relevanz:** Bei Verarbeitung besonderer Datenkategorien oder umfangreicher Profilbildung ist eine Datenschutz‑Folgenabschätzung gemäß Art. 35 DSGVO durchzuführen, bevor produktiver Echtbetrieb startet @@ -608,17 +692,22 @@ docker compose exec -T db pg_restore -U serienbrief -d serienbrief --clean < bac ### Tech‑Debt - Postgres‑Passwort doppelt gehalten (`DATABASE_URL` + Secret‑Datei) → refactor zu Single Source via Entrypoint, der `DATABASE_URL` aus `*_FILE`‑Env zusammenbaut - Keine LDAP/AD‑Anbindung — aktuell lokale Django‑User -- Keine Periodic Tasks im Beat registriert (Retention‑Cleanup, Log‑Rotation) -- CSV‑Header‑Validierung ist warn‑only, kein Abbruch bei fehlenden Pflichtfeldern -- Keine Vorschau (Single‑Letter‑Preview) vor dem Komplett‑Render +- CSV‑Header‑Validierung im **Haupt‑Job‑Flow** warn‑only (im Preview‑Pfad bereits strikt) - Image enthält LibreOffice (~400 MB) — könnte in separates Worker‑Image ausgelagert werden +- Tests decken Preview + Retention ab — Haupt‑Job‑Flow (Celery‑Task `run_mailmerge`, Status‑Polling, X‑Accel‑Redirect) noch ohne automatisierte Tests + +### Erledigt +- [x] Single‑Letter‑Preview im Job‑Erstellungs‑Flow +- [x] Test‑Suite mit pytest‑django (3 Layer) +- [x] Logout via POST‑Form (Django 5) +- [x] Retention‑Cleanup als periodic task (`mailmerge.cleanup_expired_jobs`, täglich) ### Roadmap (kurzfristig) -- [ ] Single‑Letter‑Preview im Job‑Erstellungs‑Flow -- [ ] Retention‑Cleanup als periodic task -- [ ] CSV‑Validierung strict mit Pflichtfeldliste pro Template +- [ ] CSV‑Validierung strict mit Pflichtfeldliste pro Template (auch im Haupt‑Flow) +- [ ] Tests für Celery‑Task `run_mailmerge` (mit `CELERY_TASK_ALWAYS_EAGER`) - [ ] Mandanten‑/Berechtigungsmodell (Abteilung X sieht nur eigene Templates) -- [ ] LDAP‑Auth über `django‑auth‑ldap` (AD‑Integration GB) +- [ ] LDAP‑Auth über `django‑auth‑ldap` +- [ ] Postgres‑Passwort als Single Source of Truth (`*_FILE`‑Env) ### Roadmap (mittelfristig) - [ ] AppArmor‑Profile pro Container diff --git a/app/mailmerge/management/commands/cleanup_jobs.py b/app/mailmerge/management/commands/cleanup_jobs.py new file mode 100644 index 0000000..3991fd6 --- /dev/null +++ b/app/mailmerge/management/commands/cleanup_jobs.py @@ -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.")) diff --git a/app/mailmerge/migrations/0002_retention_cleanup_periodic_task.py b/app/mailmerge/migrations/0002_retention_cleanup_periodic_task.py new file mode 100644 index 0000000..05c3165 --- /dev/null +++ b/app/mailmerge/migrations/0002_retention_cleanup_periodic_task.py @@ -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), + ] diff --git a/app/mailmerge/services/retention.py b/app/mailmerge/services/retention.py new file mode 100644 index 0000000..446b4d8 --- /dev/null +++ b/app/mailmerge/services/retention.py @@ -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, + ) diff --git a/app/mailmerge/tasks.py b/app/mailmerge/tasks.py index 32ce487..873fb87 100644 --- a/app/mailmerge/tasks.py +++ b/app/mailmerge/tasks.py @@ -16,6 +16,7 @@ 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 +from .services.retention import cleanup_expired_jobs logger = logging.getLogger(__name__) @@ -85,3 +86,28 @@ def run_mailmerge(self, job_id: str) -> str: job.save() _log(job, "error", f"Job fehlgeschlagen: {exc}") 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 diff --git a/app/mailmerge/tests/test_retention_service.py b/app/mailmerge/tests/test_retention_service.py new file mode 100644 index 0000000..bceefa5 --- /dev/null +++ b/app/mailmerge/tests/test_retention_service.py @@ -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 diff --git a/notes b/notes new file mode 100644 index 0000000..d9aa2c1 --- /dev/null +++ b/notes @@ -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 \ No newline at end of file