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 ReverseProxy (TLSTerminierung
4. [Komponenten im Detail](#komponenten-im-detail)
5. [Verzeichnisstruktur](#verzeichnisstruktur)
6. [Datenfluss eines SerienbriefJobs](#datenfluss-eines-serienbriefjobs)
7. [Datenmodell](#datenmodell)
8. [Sicherheitskonzept](#sicherheitskonzept)
9. [Netzwerk & Ports](#netzwerk--ports)
10. [Volumes & Persistenz](#volumes--persistenz)
11. [Konfiguration](#konfiguration)
12. [Inbetriebnahme](#inbetriebnahme)
13. [Entwicklung in VS Code](#entwicklung-in-vs-code)
14. [Betrieb & Wartung](#betrieb--wartung)
15. [Datenschutz & Compliance](#datenschutz--compliance)
16. [Bekannte Einschränkungen & Roadmap](#bekannte-einschränkungen--roadmap)
7. [SingleLetterPreview](#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 ReverseProxy (TLSTerminierung
- DOCXVorlagen mit Jinjaartigen Platzhaltern (`{{ feldname }}`) via **docxtpl**
- CSVUpload als Empfängerliste (UTF8, KommaTrenner)
- Asynchrone Verarbeitung über Celery — UI bleibt responsiv, StatusPolling per HTMX
- **SingleLetterPreview** vor dem KomplettRender: rendert nur die erste CSVZeile synchron als PDF und liefert es inline aus — kein Job, keine Persistenz, kein QueueRoundtrip
- Asynchrone Verarbeitung des KomplettJobs über Celery — UI bleibt responsiv, StatusPolling per HTMX
- DOCX → PDF Konversion mit headless **LibreOffice**
- Zusammenführung aller EinzelPDFs via **pypdf**
- Authentifizierung über Djangos AuthSystem, BruteForceSchutz mit **djangoaxes**
- Logout per POSTFormular (Django 5 Pflicht)
- Geschütztes Ausliefern generierter PDFs via **XAccelRedirect** (Nginx)
- AuditLog pro Job (StatusWechsel, Fehler, abgearbeitete Zeilen)
- Tägliches Backup (pg_dump + MediaTar, 14 Tage Retention)
- **TestSuite** mit pytestdjango (ServiceUnit, ViewLayer, Integration mit echtem LibreOffice) — siehe [TEST.md](TEST.md)
---
@@ -96,6 +101,7 @@ Zwei BridgeNetze trennen Verantwortlichkeiten:
| UIInteraktion | djangohtmx | aktuell |
| DBAdapter | psycopg | 3.2.3 (binary) |
| Python | CPython | 3.12 (slimbookworm) |
| Tests | pytest, pytestdjango | 8.3 / 4.9 |
---
@@ -114,6 +120,7 @@ Zwei BridgeNetze trennen Verantwortlichkeiten:
- SettingsSplit: `base.py`, `dev.py`, `production.py`
- Entrypoint wartet auf DBReadiness, führt `migrate` und (in Prod) `collectstatic` aus
- Healthcheck via `/healthz`
- **Synchron**er PreviewEndpoint (`/jobs/preview/`) rendert über LibreOffice direkt im `web`Container — kein CeleryRoundtrip
### `worker` Celery
- Verarbeitet `process_mailmerge_job`Tasks
@@ -122,8 +129,9 @@ Zwei BridgeNetze trennen Verantwortlichkeiten:
### `beat` Celery Scheduler
- Nutzt `DatabaseScheduler` von djangocelerybeat
- Geplant für: RetentionCleanup (alte Jobs/PDFs), AuditLogRotation
- *Hinweis: Konkrete Periodic Tasks aktuell noch nicht registriert (siehe Roadmap)*
- Periodic Tasks werden über DjangoMigrationen registriert (reproduzierbar, single source of truth)
- Aktiv:
- **RetentionCleanup** (`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 SerienbriefJobs
1. **Upload Vorlage:** `LetterTemplate` mit DOCXDatei wird angelegt (Admin oder UI)
2. **JobErstellung:** User wählt Vorlage + lädt CSV hoch `MailMergeJob(status=queued)`
3. **Enqueue:** View löst `process_mailmerge_job.delay(job.pk)` aus
4. **Worker holt Task:**
2. **JobErstellung:** 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, ErgebnisPDF 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 (UTF8, validiert Header gegen Platzhalter)
- Iteriert Zeilen → docxtpl rendert pro Zeile eine temporäre DOCX in `/tmp`
- LibreOffice (`soffice --headless --convert-to pdf …`) erzeugt pro DOCX ein PDF
- pypdf merged alle PDFs in eine Ausgabedatei unter `media_files`
- `JobLogEntry`s pro Schritt; Status `running → done` bzw. `failed`
5. **UIPolling:** HTMX fragt `/jobs/<id>/status/` alle ~2 s ab → Fortschrittsbalken
6. **Download:** Nutzer klickt Download → View prüft Auth → Antwort enthält `XAccelRedirect: /protected/…` → Nginx streamt das PDF
7. **UIPolling:** HTMX fragt `/jobs/<id>/status/` alle ~2 s ab → Fortschrittsbalken
8. **Download:** Nutzer klickt Download → View prüft Auth → Antwort enthält `XAccelRedirect: /protected/…` → Nginx streamt das PDF
---
## SingleLetterPreview
Vor dem KomplettRender kann eine Vorschau auf Basis der ersten CSVZeile erstellt werden. Spart Zeit bei der TemplateEntwicklung und verhindert, dass fehlerhafte Templates 200 PDFs ungenutzt produzieren.
### DesignEntscheidungen
| Aspekt | Wahl | Begründung |
|---|---|---|
| Ausführung | **synchron im `web`Container** | Renderzeit < 5 s, kein CeleryRoundtrip nötig, kein State |
| Persistenz | **keine** — kein `MailMergeJob`, keine Datei auf `media_files` | Preview ist ephemer, hinterlässt keine DSGVOrelevanten Artefakte |
| RenderPfad | tmpfs `/tmp` (sizelimitiert, kein DiskI/O) | LibreOfficeUserProfil und ZwischenDOCX werden mit RequestEnde verworfen |
| Validierung | strikt: fehlende Spalten → `PreviewError` | Im KomplettJob aktuell warnonly — PreviewPfad gibt klare Fehlermeldung ins UI |
| Auth | `@login_required`, POSTonly | gleiche Schutzklasse wie KomplettJob |
| Response | `ContentType: application/pdf`, `ContentDisposition: inline` | iframe im Form zeigt das PDF direkt |
### ResponseHeader
Die PreviewView setzt DiagnoseHeader, die das FrontEnd auswertet:
- `X-Preview-Placeholders` — KommaListe der im Template gefundenen Platzhalter
- `X-Preview-Extra-Columns` — KommaListe der CSVSpalten, die das Template nicht verwendet (UI zeigt Hinweis)
### Fehlerfälle
| Auslöser | Verhalten |
|---|---|
| Form ungültig (fehlende Datei, falsche Extension) | Formular wird mit DjangoFormErrors neu gerendert |
| `PreviewError` (z.B. Spalte fehlt) | Formular mit `preview_error`Message neu gerendert |
| LibreOfficeCrash / Timeout | Formular mit generischer Fehlermeldung; Stacktrace im ServerLog |
---
@@ -518,6 +568,8 @@ docker compose up -d
# Debugging: F5 in VS Code → attach an debugpy:5678
```
**Hinweis zum ComposeAufruf:** Niemals `docker compose -f docker-compose.yml up -d` verwenden — das ignoriert die OverrideDatei und führt zu NetzwerkRaceConditions 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 TestSuite liegt unter `app/mailmerge/tests/` und ist in drei Schichten organisiert:
| Layer | Datei | Was wird geprüft | LibreOffice |
|---|---|---|---|
| ServiceUnit | `test_preview_service.py` | CSVParsing, HeaderValidierung, ResultAufbau, Fehlerpfade | gemockt |
| View | `test_preview_view.py` | HTTPLayer: Auth, MethodRestriction, FormValidation, ResponseHeader, FehlerRendering | gemockt |
| Integration | `test_preview_integration.py` | EndtoEnd mit echtem `soffice --headless` | **real** (Marker `integration`) |
**PytestKonfiguration** (`app/pyproject.toml`):
- `--reuse-db` für schnelle ReRuns
- DefaultSelektor `-m 'not integration'` — IntegrationTests 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 ReRuns
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 CIIntegration: 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 CloudTransfer
- **Zugriffssteuerung:** Nur authentifizierte User; Trennung Templates/Jobs aktuell flach — Mandanten/Rollenmodell ggf. ergänzen (siehe Roadmap)
- **Aufbewahrung:** Jobs + CSVs werden nach `JOB_RETENTION_DAYS` (default 30) gelöscht — CleanupTask noch zu implementieren (siehe Roadmap)
- **Aufbewahrung:** Jobs + CSVs + OutputPDFs werden nach `JOB_RETENTION_DAYS` (default 30) automatisch gelöscht. Der RetentionCleanup läuft als CeleryBeatPeriodicTask einmal täglich. Manuelle Ausführung/DryRun: `docker compose exec web python manage.py cleanup_jobs --dry-run`
- **Audit:** Jeder Job hat ein `JobLogEntry`Protokoll (wer, wann, was, Fehler)
- **BackupVerschlüsselung:** BindMount `./backups` liegt aktuell unverschlüsselt — für produktiven Einsatz LUKS/FilesystemEncryption am Host **dringend empfohlen**
- **DSFARelevanz:** Bei Verarbeitung besonderer Datenkategorien oder umfangreicher Profilbildung ist eine DatenschutzFolgenabschätzung gemäß Art. 35 DSGVO durchzuführen, bevor produktiver Echtbetrieb startet
@@ -608,17 +692,22 @@ docker compose exec -T db pg_restore -U serienbrief -d serienbrief --clean < bac
### TechDebt
- PostgresPasswort doppelt gehalten (`DATABASE_URL` + SecretDatei) → refactor zu Single Source via Entrypoint, der `DATABASE_URL` aus `*_FILE`Env zusammenbaut
- Keine LDAP/ADAnbindung — aktuell lokale DjangoUser
- Keine Periodic Tasks im Beat registriert (RetentionCleanup, LogRotation)
- CSVHeaderValidierung ist warnonly, kein Abbruch bei fehlenden Pflichtfeldern
- Keine Vorschau (SingleLetterPreview) vor dem KomplettRender
- CSVHeaderValidierung im **HauptJobFlow** warnonly (im PreviewPfad bereits strikt)
- Image enthält LibreOffice (~400 MB) — könnte in separates WorkerImage ausgelagert werden
- Tests decken Preview + Retention ab — HauptJobFlow (CeleryTask `run_mailmerge`, StatusPolling, XAccelRedirect) noch ohne automatisierte Tests
### Erledigt
- [x] SingleLetterPreview im JobErstellungsFlow
- [x] TestSuite mit pytestdjango (3 Layer)
- [x] Logout via POSTForm (Django 5)
- [x] RetentionCleanup als periodic task (`mailmerge.cleanup_expired_jobs`, täglich)
### Roadmap (kurzfristig)
- [ ] SingleLetterPreview im JobErstellungsFlow
- [ ] RetentionCleanup als periodic task
- [ ] CSVValidierung strict mit Pflichtfeldliste pro Template
- [ ] CSVValidierung strict mit Pflichtfeldliste pro Template (auch im HauptFlow)
- [ ] Tests für CeleryTask `run_mailmerge` (mit `CELERY_TASK_ALWAYS_EAGER`)
- [ ] Mandanten/Berechtigungsmodell (Abteilung X sieht nur eigene Templates)
- [ ] LDAPAuth über `djangoauthldap` (ADIntegration GB)
- [ ] LDAPAuth über `djangoauthldap`
- [ ] PostgresPasswort als Single Source of Truth (`*_FILE`Env)
### Roadmap (mittelfristig)
- [ ] AppArmorProfile 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 .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
@@ -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