diff --git a/docs/manual/scripts.rst b/docs/manual/scripts.rst index ceb7f96ca..6b6b2a1d6 100644 --- a/docs/manual/scripts.rst +++ b/docs/manual/scripts.rst @@ -26,6 +26,48 @@ To export only specific data, the desired component names can be specified: Adding the "--csv" flag exports all tables in the supplied components to CSV files. These files are temporarily placed in "csv_dumps" and combined into a TAR file. +Delete Klantcontacten not related to Zaken +------------------------------------------- + +The ``delete_klantcontacten_not_related_to_zaken`` management command is a temporary +cleanup script that deletes **Klantcontacten** that are not related +to a **Zaak**, along with their related Betrokkenen, Onderwerpobjecten, Bijlagen, +Interne taken (contactverzoeken) and any Partijen that are no longer related to +anything else after deletion. + +This script exists as a stopgap while `Open Archiefbeheer `_ +does not yet support archiving/destroying Klantcontacten that have no Zaak relation. + +By default, only Klantcontacten with a ``plaatsgevondenOp`` date at least 12 months in +the past are eligible, and the command runs in **preview mode** (``--dry-run``, enabled +by default): it prints a destruction list ("vernietigingslijst") without deleting +anything. Pass ``--no-dry-run`` to actually delete the listed records. + +.. code-block:: shell + + python src/manage.py delete_klantcontacten_not_related_to_zaken + +Optionally, a date range can be specified with ``--minimum-date`` and/or +``--maximum-date`` (format ``YYYY-MM-DD``). ``--maximum-date`` must be at least 12 +months in the past. + +.. code-block:: shell + + python src/manage.py delete_klantcontacten_not_related_to_zaken \\ + --minimum-date 2020-01-01 --maximum-date 2023-01-01 + +To actually delete the eligible records instead of only previewing them: + +.. code-block:: shell + + python src/manage.py delete_klantcontacten_not_related_to_zaken --no-dry-run + +.. note:: + + Run this command inside the Docker container, e.g. via + ``docker compose exec web python src/manage.py delete_klantcontacten_not_related_to_zaken``, + when running Open Klant with Docker Compose. + Environment variabelen ---------------------- diff --git a/src/openklant/management/commands/delete_klantcontacten_not_related_to_zaken.py b/src/openklant/management/commands/delete_klantcontacten_not_related_to_zaken.py new file mode 100644 index 000000000..a3b3f8948 --- /dev/null +++ b/src/openklant/management/commands/delete_klantcontacten_not_related_to_zaken.py @@ -0,0 +1,275 @@ +import argparse +import calendar +import os +from datetime import date, datetime, timedelta + +from django.core.management.base import BaseCommand, CommandError, CommandParser +from django.db import transaction +from django.db.models import Exists, OuterRef, Q, QuerySet +from django.utils import timezone + +import structlog + +from openklant.components.klantinteracties.models.digitaal_adres import DigitaalAdres +from openklant.components.klantinteracties.models.internetaken import InterneTaak +from openklant.components.klantinteracties.models.klantcontacten import ( + Betrokkene, + Klantcontact, + Onderwerpobject, +) +from openklant.components.klantinteracties.models.partijen import Partij + +logger = structlog.stdlib.get_logger(__name__) + +MINIMUM_MONTHS_IN_THE_PAST = 12 + + +def _subtract_months(d: date, months: int) -> date: + """ + `d` minus a number of calendar months, clamping the day to the last day + of the resulting month (e.g. 2026-02-29 minus 12 months -> 2025-02-28). + """ + month_index = d.month - 1 - months + year = d.year + month_index // 12 + month = month_index % 12 + 1 + day = min(d.day, calendar.monthrange(year, month)[1]) + return date(year, month, day) + + +def _default_maximum_date() -> date: + """ + Last day of the previous month, minus 1 year. + """ + first_of_this_month = timezone.now().date().replace(day=1) + last_day_of_previous_month = first_of_this_month - timedelta(days=1) + return _subtract_months(last_day_of_previous_month, 12) + + +def _parse_date(value: str) -> date: + try: + return datetime.strptime(value, "%Y-%m-%d").date() + except ValueError: + raise argparse.ArgumentTypeError( + f"'{value}' is geen geldige datum (verwacht formaat: YYYY-MM-DD)." + ) + + +class Command(BaseCommand): + help = ( + "Tijdelijk opruimscript: verwijdert Klantcontacten (contactmomenten) die niet " + "gerelateerd zijn aan een Zaak, samen met hun gerelateerde Betrokkenen, " + "Onderwerpobjecten, Bijlagen, Interne taken (contactverzoeken) en eventuele " + "Partijen die na verwijdering nergens anders meer bij betrokken zijn. Dit " + "script bestaat in afwachting van ondersteuning hiervoor in Open " + "Archiefbeheer." + ) + + def add_arguments(self, parser: CommandParser) -> None: + parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help=( + "Modus waarin het script draait. Indien ingeschakeld (standaard: " + "previewmodus) wordt alleen een vernietigingslijst getoond zonder " + "daadwerkelijk te verwijderen. Gebruik --no-dry-run voor de " + "vernietigingsmodus, waarin de records daadwerkelijk verwijderd " + "worden." + ), + ) + parser.add_argument( + "--minimum-date", + dest="minimum_date", + type=_parse_date, + default=None, + metavar="YYYY-MM-DD", + help=( + "Sluitingsdatum minimum. Alleen Klantcontacten met een " + "'plaatsgevondenOp' datum op of na deze datum komen in aanmerking " + "voor verwijdering. Standaard is er geen ondergrens." + ), + ) + parser.add_argument( + "--maximum-date", + dest="maximum_date", + type=_parse_date, + default=None, + metavar="YYYY-MM-DD", + help=( + "Sluitingsdatum maximum. Alleen Klantcontacten met een " + "'plaatsgevondenOp' datum op of voor deze datum komen in " + "aanmerking voor verwijdering. Moet minimaal 12 maanden in het " + "verleden liggen. Standaard: de " + "laatste dag van de vorige maand, min 1 jaar." + ), + ) + + def handle(self, *args, **options) -> None: + dry_run = options["dry_run"] + minimum_date: date | None = options["minimum_date"] + explicit_maximum_date: date | None = options["maximum_date"] + + if explicit_maximum_date is not None: + earliest_allowed = _subtract_months( + timezone.now().date(), MINIMUM_MONTHS_IN_THE_PAST + ) + if explicit_maximum_date > earliest_allowed: + raise CommandError( + "maximum-date moet minimaal 12 maanden in het verleden liggen " + f"(dus op of voor {earliest_allowed.isoformat()})." + ) + + maximum_date: date = explicit_maximum_date or _default_maximum_date() + + if minimum_date and minimum_date > maximum_date: + raise CommandError("minimum-date kan niet na maximum-date liggen.") + + started_at = timezone.now() + + klantcontact_ids = list( + self.get_eligible_klantcontacten(minimum_date, maximum_date) + .order_by("plaatsgevonden_op", "pk") + .values_list("pk", flat=True) + ) + klantcontacten = Klantcontact.objects.filter(pk__in=klantcontact_ids).order_by( + "plaatsgevonden_op", "pk" + ) + + records = self.build_records(klantcontacten) + + self.stdout.write("Vernietigingslijst contactmomenten en contactverzoeken") + self.stdout.write("") + self.stdout.write( + f"Modus: {'previewmodus' if dry_run else 'vernietigingsmodus'}" + ) + self.stdout.write( + "Sluitingsdatum minimum: " + f"{minimum_date.isoformat() if minimum_date else 'niet opgegeven'}" + ) + self.stdout.write(f"Sluitingsdatum maximum: {maximum_date.isoformat()}") + self.stdout.write("") + self.stdout.write("Records:") + if records: + id_width = max(len(str(record["identificatie"])) for record in records) + type_width = max(len(record["type"]) for record in records) + else: + id_width = type_width = 0 + for record in records: + self.stdout.write( + f"Record {record['identificatie']:>{id_width}} " + f"{record['type']:<{type_width}} " + f"{record['aangemaakt']} {record['gesloten']}" + ) + self.stdout.write("") + self.stdout.write(f"Totaal aantal geselecteerde records: {len(records)}") + self.stdout.write("") + + logger.info( + "klantcontacten_not_related_to_zaken_selected", + dry_run=dry_run, + minimum_date=minimum_date.isoformat() if minimum_date else None, + maximum_date=maximum_date.isoformat(), + klantcontacten_count=len(klantcontact_ids), + records_count=len(records), + ) + + if dry_run: + return + + deleted_count = self.delete_klantcontacten(klantcontacten, len(records)) + finished_at = timezone.now() + + logger.info( + "klantcontacten_not_related_to_zaken_deleted", + started_at=started_at.isoformat(), + finished_at=finished_at.isoformat(), + deleted_count=deleted_count, + ) + + self.stdout.write( + "Bewijs van vernietiging. Tussen " + f"{started_at.isoformat()} en {finished_at.isoformat()} zijn " + f"{deleted_count} records vernietigd overeenkomstig de " + "vernietigingslijst." + ) + + def get_eligible_klantcontacten( + self, minimum_date: date | None, maximum_date: date + ) -> QuerySet[Klantcontact]: + """Return Klantcontacten without a Zaak in the given date range.""" + + zaak_objecttype = os.getenv("KLANTCONTACT_CLEANUP_ZAAK_OBJECTTYPE", "zaak") + + onderwerpobject_zaak = Onderwerpobject.objects.filter( + Q(klantcontact=OuterRef("pk")) | Q(was_klantcontact=OuterRef("pk")), + onderwerpobjectidentificator_code_objecttype__iexact=zaak_objecttype, + ) + + queryset = Klantcontact.objects.annotate( + _heeft_zaak=Exists(onderwerpobject_zaak) + ).filter(_heeft_zaak=False, plaatsgevonden_op__date__lte=maximum_date) + + if minimum_date: + queryset = queryset.filter(plaatsgevonden_op__date__gte=minimum_date) + + return queryset + + def build_records(self, klantcontacten: QuerySet[Klantcontact]) -> list[dict]: + interne_taken_by_klantcontact: dict[int, list[InterneTaak]] = {} + interne_taken = ( + InterneTaak.objects.filter(klantcontact__in=klantcontacten) + .select_related("klantcontact") + .order_by("toegewezen_op", "pk") + ) + for interne_taak in interne_taken: + interne_taken_by_klantcontact.setdefault( + interne_taak.klantcontact.pk, [] + ).append(interne_taak) + + records = [] + for klantcontact in klantcontacten: + klantcontact_datum = klantcontact.plaatsgevonden_op.isoformat() + records.append( + { + "identificatie": klantcontact.nummer or str(klantcontact.uuid), + "type": "Klantcontact", + "aangemaakt": klantcontact_datum, + "gesloten": klantcontact_datum, + } + ) + for interne_taak in interne_taken_by_klantcontact.get(klantcontact.pk, []): + records.append( + { + "identificatie": interne_taak.nummer or str(interne_taak.uuid), + "type": "Interne taak", + "aangemaakt": klantcontact_datum, + "gesloten": klantcontact_datum, + } + ) + + return records + + @transaction.atomic + def delete_klantcontacten( + self, klantcontacten: QuerySet[Klantcontact], record_count: int + ) -> int: + betrokkenen = Betrokkene.objects.filter(klantcontact__in=klantcontacten) + + partij_ids = set( + betrokkenen.filter(partij__isnull=False).values_list("partij_id", flat=True) + ) + + DigitaalAdres.objects.filter( + betrokkene__in=betrokkenen, partij__isnull=True + ).delete() + + klantcontacten.delete() + + if partij_ids: + Partij.objects.filter(pk__in=partij_ids).annotate( + _heeft_betrokkene=Exists( + Betrokkene.objects.filter(partij=OuterRef("pk")) + ) + ).filter(_heeft_betrokkene=False).delete() + + return record_count diff --git a/src/openklant/tests/test_delete_klantcontacten_not_related_to_zaken.py b/src/openklant/tests/test_delete_klantcontacten_not_related_to_zaken.py new file mode 100644 index 000000000..254e1e581 --- /dev/null +++ b/src/openklant/tests/test_delete_klantcontacten_not_related_to_zaken.py @@ -0,0 +1,356 @@ +import os +from datetime import datetime, timezone as dt_timezone +from io import StringIO +from unittest import mock + +from django.core.management import CommandError, call_command +from django.test import TestCase + +from freezegun.api import freeze_time + +from openklant.components.klantinteracties.models.actoren import ( + Actor, + ActorKlantcontact, +) +from openklant.components.klantinteracties.models.digitaal_adres import DigitaalAdres +from openklant.components.klantinteracties.models.internetaken import InterneTaak +from openklant.components.klantinteracties.models.klantcontacten import ( + Betrokkene, + Bijlage, + Klantcontact, + Onderwerpobject, +) +from openklant.components.klantinteracties.models.partijen import Partij +from openklant.components.klantinteracties.models.tests.factories import ( + ActorFactory, + ActorKlantcontactFactory, + BetrokkeneFactory, + BijlageFactory, + DigitaalAdresFactory, + InterneTaakFactory, + KlantcontactFactory, + OnderwerpobjectFactory, + PartijFactory, +) + + +def _run_command(*args) -> str: + stdout = StringIO() + call_command("delete_klantcontacten_not_related_to_zaken", *args, stdout=stdout) + return stdout.getvalue() + + +class DeleteKlantcontactenNotRelatedToZakenTests(TestCase): + def test_dry_run_by_default_does_not_delete(self): + klantcontact = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc) + ) + + output = _run_command() + + self.assertTrue(Klantcontact.objects.filter(pk=klantcontact.pk).exists()) + self.assertIn("Modus: previewmodus", output) + self.assertNotIn("Bewijs van vernietiging", output) + + def test_no_dry_run_deletes_klantcontact_not_related_to_zaak(self): + klantcontact = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc) + ) + + output = _run_command("--no-dry-run") + + self.assertFalse(Klantcontact.objects.filter(pk=klantcontact.pk).exists()) + self.assertIn("Modus: vernietigingsmodus", output) + self.assertIn("Bewijs van vernietiging", output) + self.assertIn("1 records vernietigd", output) + self.assertIn("overeenkomstig de vernietigingslijst.", output) + + def test_klantcontact_related_to_zaak_via_klantcontact_side_is_kept(self): + klantcontact = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc) + ) + OnderwerpobjectFactory.create( + klantcontact=klantcontact, + was_klantcontact=None, + onderwerpobjectidentificator_code_objecttype="zaak", + ) + + output = _run_command("--no-dry-run") + + self.assertTrue(Klantcontact.objects.filter(pk=klantcontact.pk).exists()) + self.assertIn("Totaal aantal geselecteerde records: 0", output) + + def test_klantcontact_related_to_zaak_via_was_klantcontact_side_is_kept(self): + klantcontact = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc) + ) + OnderwerpobjectFactory.create( + klantcontact=None, + was_klantcontact=klantcontact, + onderwerpobjectidentificator_code_objecttype="ZAAK", + ) + + output = _run_command("--no-dry-run") + + self.assertTrue(Klantcontact.objects.filter(pk=klantcontact.pk).exists()) + self.assertIn("Totaal aantal geselecteerde records: 0", output) + + def test_klantcontact_related_to_non_zaak_onderwerpobject_is_deleted(self): + klantcontact = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc) + ) + OnderwerpobjectFactory.create( + klantcontact=klantcontact, + was_klantcontact=None, + onderwerpobjectidentificator_code_objecttype="document", + ) + + _run_command("--no-dry-run") + + self.assertFalse(Klantcontact.objects.filter(pk=klantcontact.pk).exists()) + + @mock.patch.dict( + os.environ, {"KLANTCONTACT_CLEANUP_ZAAK_OBJECTTYPE": "zaakidentificatie"} + ) + def test_zaak_objecttype_env_var_overrides_default(self): + klantcontact = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc) + ) + OnderwerpobjectFactory.create( + klantcontact=klantcontact, + was_klantcontact=None, + onderwerpobjectidentificator_code_objecttype="zaakidentificatie", + ) + + output = _run_command("--no-dry-run") + + self.assertTrue(Klantcontact.objects.filter(pk=klantcontact.pk).exists()) + self.assertIn("Totaal aantal geselecteerde records: 0", output) + + @mock.patch.dict( + os.environ, {"KLANTCONTACT_CLEANUP_ZAAK_OBJECTTYPE": "zaakidentificatie"} + ) + def test_default_objecttype_is_no_longer_recognized_when_env_var_overridden(self): + klantcontact = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc) + ) + OnderwerpobjectFactory.create( + klantcontact=klantcontact, + was_klantcontact=None, + onderwerpobjectidentificator_code_objecttype="zaak", + ) + + _run_command("--no-dry-run") + + self.assertFalse(Klantcontact.objects.filter(pk=klantcontact.pk).exists()) + + def test_deleting_eligible_klantcontact_cascades_shared_onderwerpobject_but_keeps_other_side( + self, + ): + eligible = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc) + ) + kept = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc) + ) + OnderwerpobjectFactory.create( + klantcontact=kept, + was_klantcontact=None, + onderwerpobjectidentificator_code_objecttype="zaak", + ) + shared_onderwerpobject = OnderwerpobjectFactory.create( + klantcontact=kept, + was_klantcontact=eligible, + onderwerpobjectidentificator_code_objecttype="document", + ) + + _run_command("--no-dry-run") + + self.assertFalse(Klantcontact.objects.filter(pk=eligible.pk).exists()) + self.assertTrue(Klantcontact.objects.filter(pk=kept.pk).exists()) + self.assertFalse( + Onderwerpobject.objects.filter(pk=shared_onderwerpobject.pk).exists() + ) + + def test_minimum_date_excludes_older_klantcontacten(self): + too_old = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2019, 12, 31, tzinfo=dt_timezone.utc) + ) + in_range = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc) + ) + + _run_command("--no-dry-run", "--minimum-date", "2020-01-01") + + self.assertTrue(Klantcontact.objects.filter(pk=too_old.pk).exists()) + self.assertFalse(Klantcontact.objects.filter(pk=in_range.pk).exists()) + + def test_maximum_date_excludes_newer_klantcontacten(self): + in_range = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc) + ) + too_new = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 2, tzinfo=dt_timezone.utc) + ) + + _run_command("--no-dry-run", "--maximum-date", "2020-01-01") + + self.assertFalse(Klantcontact.objects.filter(pk=in_range.pk).exists()) + self.assertTrue(Klantcontact.objects.filter(pk=too_new.pk).exists()) + + def test_minimum_date_after_maximum_date_raises(self): + with self.assertRaises(CommandError): + call_command( + "delete_klantcontacten_not_related_to_zaken", + "--minimum-date", + "2020-06-01", + "--maximum-date", + "2020-01-01", + stdout=StringIO(), + ) + + def test_invalid_date_format_raises(self): + with self.assertRaises(CommandError): + call_command( + "delete_klantcontacten_not_related_to_zaken", + "--minimum-date", + "not-a-date", + stdout=StringIO(), + ) + + @freeze_time("2026-09-11") + def test_default_maximum_date_is_last_day_of_previous_month_minus_one_year(self): + output = _run_command() + + self.assertIn("Sluitingsdatum maximum: 2025-08-31", output) + + @freeze_time("2028-03-01") + def test_default_maximum_date_handles_leap_day_in_previous_month(self): + output = _run_command() + + self.assertIn("Sluitingsdatum maximum: 2027-02-28", output) + + def test_cascade_deletes_related_objects(self): + klantcontact = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc) + ) + betrokkene = BetrokkeneFactory.create(klantcontact=klantcontact, partij=None) + bijlage = BijlageFactory.create(klantcontact=klantcontact) + interne_taak = InterneTaakFactory.create(klantcontact=klantcontact) + actor = ActorFactory.create() + actor_klantcontact = ActorKlantcontactFactory.create( + klantcontact=klantcontact, actor=actor + ) + onderwerpobject = OnderwerpobjectFactory.create( + klantcontact=klantcontact, + was_klantcontact=None, + onderwerpobjectidentificator_code_objecttype="document", + ) + orphan_digitaal_adres = DigitaalAdresFactory.create( + betrokkene=betrokkene, partij=None + ) + + _run_command("--no-dry-run") + + self.assertFalse(Klantcontact.objects.filter(pk=klantcontact.pk).exists()) + self.assertFalse(Betrokkene.objects.filter(pk=betrokkene.pk).exists()) + self.assertFalse(Bijlage.objects.filter(pk=bijlage.pk).exists()) + self.assertFalse(InterneTaak.objects.filter(pk=interne_taak.pk).exists()) + self.assertFalse( + ActorKlantcontact.objects.filter(pk=actor_klantcontact.pk).exists() + ) + self.assertTrue(Actor.objects.filter(pk=actor.pk).exists()) + self.assertFalse(Onderwerpobject.objects.filter(pk=onderwerpobject.pk).exists()) + self.assertFalse( + DigitaalAdres.objects.filter(pk=orphan_digitaal_adres.pk).exists() + ) + + def test_trailing_partij_deleted_when_no_longer_referenced(self): + klantcontact = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc) + ) + partij = PartijFactory.create() + BetrokkeneFactory.create(klantcontact=klantcontact, partij=partij) + + _run_command("--no-dry-run") + + self.assertFalse(Partij.objects.filter(pk=partij.pk).exists()) + + def test_partij_kept_when_still_referenced_by_other_betrokkene(self): + klantcontact = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc) + ) + other_klantcontact = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc) + ) + OnderwerpobjectFactory.create( + klantcontact=other_klantcontact, + was_klantcontact=None, + onderwerpobjectidentificator_code_objecttype="zaak", + ) + partij = PartijFactory.create() + BetrokkeneFactory.create(klantcontact=klantcontact, partij=partij) + BetrokkeneFactory.create(klantcontact=other_klantcontact, partij=partij) + + _run_command("--no-dry-run") + + self.assertFalse(Klantcontact.objects.filter(pk=klantcontact.pk).exists()) + self.assertTrue(Klantcontact.objects.filter(pk=other_klantcontact.pk).exists()) + self.assertTrue(Partij.objects.filter(pk=partij.pk).exists()) + + def test_digitaal_adres_with_partij_is_kept(self): + klantcontact = KlantcontactFactory.create( + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc) + ) + partij = PartijFactory.create() + betrokkene = BetrokkeneFactory.create(klantcontact=klantcontact, partij=None) + digitaal_adres = DigitaalAdresFactory.create( + betrokkene=betrokkene, partij=partij + ) + + _run_command("--no-dry-run") + + self.assertTrue(DigitaalAdres.objects.filter(pk=digitaal_adres.pk).exists()) + self.assertTrue(Partij.objects.filter(pk=partij.pk).exists()) + + def test_output_lists_klantcontact_and_related_interne_taak_as_separate_records( + self, + ): + klantcontact = KlantcontactFactory.create( + nummer="1234567890", + plaatsgevonden_op=datetime(2020, 1, 1, tzinfo=dt_timezone.utc), + ) + InterneTaakFactory.create(klantcontact=klantcontact, nummer="9876543210") + + output = _run_command() + + expected_datum = "2020-01-01T00:00:00+00:00" + self.assertIn( + f"Record 1234567890 Klantcontact {expected_datum} {expected_datum}", + output, + ) + self.assertIn( + f"Record 9876543210 Interne taak {expected_datum} {expected_datum}", + output, + ) + self.assertIn("Totaal aantal geselecteerde records: 2", output) + + @freeze_time("2026-09-11") + def test_maximum_date_less_than_twelve_months_in_the_past_raises(self): + with self.assertRaisesMessage( + CommandError, + "maximum-date moet minimaal 12 maanden in het verleden liggen " + "(dus op of voor 2025-09-11).", + ): + call_command( + "delete_klantcontacten_not_related_to_zaken", + "--maximum-date", + "2026-03-11", + stdout=StringIO(), + ) + + @freeze_time("2026-09-11") + def test_maximum_date_exactly_twelve_months_in_the_past_is_allowed(self): + output = _run_command("--maximum-date", "2025-09-11") + + self.assertIn("Sluitingsdatum maximum: 2025-09-11", output)