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
+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,
)