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