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