143 lines
4.7 KiB
Python
143 lines
4.7 KiB
Python
|
|
"""
|
||
|
|
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,
|
||
|
|
)
|