From 4c974bb9a97c0eb7fc0ab9725e893a0a4fd8ca89 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Thu, 8 Jan 2026 16:30:32 +0100 Subject: [PATCH 01/11] :sparkles: [#708] Add ObjectRecord.references --- src/objects/api/serializers.py | 10 ++++++- src/objects/core/constants.py | 4 +++ src/objects/core/migrations/0037_reference.py | 27 +++++++++++++++++++ src/objects/core/models.py | 20 ++++++++++++++ src/objects/tests/v2/test_auth_fields.py | 3 +++ src/objects/tests/v2/test_object_api.py | 2 ++ 6 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/objects/core/migrations/0037_reference.py diff --git a/src/objects/api/serializers.py b/src/objects/api/serializers.py index e845cf90..67fa16e8 100644 --- a/src/objects/api/serializers.py +++ b/src/objects/api/serializers.py @@ -5,7 +5,7 @@ from rest_framework import serializers from rest_framework_gis.serializers import GeometryField -from objects.core.models import Object, ObjectRecord, ObjectType +from objects.core.models import Object, ObjectRecord, ObjectType, Reference from objects.token.models import Permission, TokenAuth from objects.utils.serializers import DynamicFieldsMixin @@ -16,6 +16,12 @@ logger = structlog.stdlib.get_logger(__name__) +class ReferenceSerializer(serializers.ModelSerializer): + class Meta: + model = Reference + fields = ["type", "url"] + + class ObjectRecordSerializer(serializers.ModelSerializer): correctionFor = ObjectSlugRelatedField( source="correct", @@ -30,6 +36,7 @@ class ObjectRecordSerializer(serializers.ModelSerializer): read_only=True, help_text=_("Index of the record, which corrects the current record"), ) + references = ReferenceSerializer(many=True, read_only=False, required=False) class Meta: model = ObjectRecord @@ -38,6 +45,7 @@ class Meta: "typeVersion", "data", "geometry", + "references", "startAt", "endAt", "registrationAt", diff --git a/src/objects/core/constants.py b/src/objects/core/constants.py index d795b06f..da95e602 100644 --- a/src/objects/core/constants.py +++ b/src/objects/core/constants.py @@ -23,3 +23,7 @@ class UpdateFrequencyChoices(models.TextChoices): monthly = "monthly", _("Monthly") yearly = "yearly", _("Yearly") unknown = "unknown", _("Unknown") + + +class ReferenceType(models.TextChoices): + zaak = "zaak", _("Zaak") diff --git a/src/objects/core/migrations/0037_reference.py b/src/objects/core/migrations/0037_reference.py new file mode 100644 index 00000000..b627dfbd --- /dev/null +++ b/src/objects/core/migrations/0037_reference.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.8 on 2026-01-08 13:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0036_objecttype_is_imported'), + ] + + operations = [ + migrations.CreateModel( + name='Reference', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('zaak', 'Zaak')], max_length=4)), + ('url', models.URLField()), + ('record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='references', to='core.objectrecord')), + ], + options={ + 'indexes': [models.Index(fields=['url'], name='core_refere_url_a2dbbb_idx')], + 'constraints': [models.UniqueConstraint(fields=('record', 'url'), name='unique_ref_url')], + }, + ), + ] diff --git a/src/objects/core/models.py b/src/objects/core/models.py index dd95aa62..e28e5680 100644 --- a/src/objects/core/models.py +++ b/src/objects/core/models.py @@ -18,6 +18,7 @@ from .constants import ( DataClassificationChoices, ObjectVersionStatus, + ReferenceType, UpdateFrequencyChoices, ) from .query import ObjectQuerySet, ObjectRecordQuerySet, ObjectTypeQuerySet @@ -418,3 +419,22 @@ def save(self, *args, **kwargs): self._object_type = self.object.object_type super().save(*args, **kwargs) + + +class Reference(models.Model): + record = models.ForeignKey( + ObjectRecord, on_delete=models.CASCADE, related_name="references" + ) + type = models.CharField( + max_length=4, choices=ReferenceType.choices, null=False, blank=False + ) + url = models.URLField() + + class Meta: + indexes = [models.Index(fields=["url"])] + constraints = [ + models.UniqueConstraint(fields=["record", "url"], name="unique_ref_url") + ] + + def __str__(self): + return f"{self.type}: {self.url}" diff --git a/src/objects/tests/v2/test_auth_fields.py b/src/objects/tests/v2/test_auth_fields.py index cfd67c86..38dc5351 100644 --- a/src/objects/tests/v2/test_auth_fields.py +++ b/src/objects/tests/v2/test_auth_fields.py @@ -65,6 +65,7 @@ def test_retrieve_without_query(self): "record__geometry__coordinates", "record__geometry__type", "record__typeVersion", + "record__references", }, ) @@ -216,6 +217,7 @@ def test_list_without_query_different_object_types(self): "registrationAt": record2.registration_at.isoformat(), "correctionFor": None, "correctedBy": None, + "references": [], }, }, { @@ -231,6 +233,7 @@ def test_list_without_query_different_object_types(self): "registrationAt": record1.registration_at.isoformat(), "correctionFor": None, "correctedBy": None, + "references": [], }, }, ], diff --git a/src/objects/tests/v2/test_object_api.py b/src/objects/tests/v2/test_object_api.py index 91279b1f..94f84457 100644 --- a/src/objects/tests/v2/test_object_api.py +++ b/src/objects/tests/v2/test_object_api.py @@ -79,6 +79,7 @@ def test_list_actual_objects(self, m): "registrationAt": object_record1.registration_at.isoformat(), "correctionFor": None, "correctedBy": None, + "references": [], }, } ], @@ -116,6 +117,7 @@ def test_retrieve_object(self, m): "registrationAt": object_record.registration_at.isoformat(), "correctionFor": None, "correctedBy": None, + "references": [], }, }, ) From 61c9a6e80809745b0974a165fd4699c0da9a56ab Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Tue, 20 Jan 2026 10:49:21 +0100 Subject: [PATCH 02/11] :sparkles: [#708] Add zaak reference handling to objects endpoint --- src/objects/api/serializers.py | 20 ++- src/objects/core/tests/factories.py | 13 +- src/objects/tests/v2/test_object_api.py | 207 +++++++++++++++++++++++- 3 files changed, 234 insertions(+), 6 deletions(-) diff --git a/src/objects/api/serializers.py b/src/objects/api/serializers.py index 67fa16e8..297463ab 100644 --- a/src/objects/api/serializers.py +++ b/src/objects/api/serializers.py @@ -22,7 +22,7 @@ class Meta: fields = ["type", "url"] -class ObjectRecordSerializer(serializers.ModelSerializer): +class ObjectRecordSerializer(serializers.ModelSerializer[ObjectRecord]): correctionFor = ObjectSlugRelatedField( source="correct", slug_field="index", @@ -36,7 +36,7 @@ class ObjectRecordSerializer(serializers.ModelSerializer): read_only=True, help_text=_("Index of the record, which corrects the current record"), ) - references = ReferenceSerializer(many=True, read_only=False, required=False) + references = ReferenceSerializer(many=True, read_only=False, default=[]) class Meta: model = ObjectRecord @@ -133,7 +133,11 @@ def create(self, validated_data): object = Object.objects.create(**object_data) validated_data["object"] = object + references = validated_data.pop("references", []) record = super().create(validated_data) + Reference.objects.bulk_create( + Reference(record=record, **ref_data) for ref_data in references + ) token_auth: TokenAuth = self.context["request"].auth logger.info( "object_created", @@ -157,11 +161,19 @@ def update(self, instance, validated_data): if "start_at" not in validated_data: validated_data["start_at"] = instance.start_at - if self.partial and "data" in validated_data: + if self.partial: # Apply JSON Merge Patch for record data - validated_data["data"] = merge_patch(instance.data, validated_data["data"]) + validated_data["data"] = merge_patch( + instance.data, validated_data.pop("data", {}) + ) + references = validated_data.pop( + "references", instance.references.values("type", "url") + ) record = super().create(validated_data) + Reference.objects.bulk_create( + Reference(record=record, **ref_data) for ref_data in references + ) token_auth: TokenAuth = self.context["request"].auth logger.info( "object_updated", diff --git a/src/objects/core/tests/factories.py b/src/objects/core/tests/factories.py index 9ae6a853..9c9d5759 100644 --- a/src/objects/core/tests/factories.py +++ b/src/objects/core/tests/factories.py @@ -8,7 +8,9 @@ from factory.fuzzy import BaseFuzzyAttribute from zgw_consumers.test.factories import ServiceFactory -from ..models import Object, ObjectRecord, ObjectType, ObjectTypeVersion +from objects.core.constants import ReferenceType + +from ..models import Object, ObjectRecord, ObjectType, ObjectTypeVersion, Reference class ObjectTypeFactory(factory.django.DjangoModelFactory[ObjectType]): @@ -70,3 +72,12 @@ class ObjectRecordFactory(factory.django.DjangoModelFactory[ObjectRecord]): class Meta: model = ObjectRecord + + +class ReferenceFactory(factory.django.DjangoModelFactory[Reference]): + type = ReferenceType.zaak + url = factory.Faker("url") + record = factory.SubFactory(ObjectRecordFactory) + + class Meta: + model = Reference diff --git a/src/objects/tests/v2/test_object_api.py b/src/objects/tests/v2/test_object_api.py index 94f84457..7f1bd279 100644 --- a/src/objects/tests/v2/test_object_api.py +++ b/src/objects/tests/v2/test_object_api.py @@ -7,11 +7,12 @@ from rest_framework import status from rest_framework.test import APITestCase -from objects.core.models import Object +from objects.core.models import Object, Reference from objects.core.tests.factories import ( ObjectFactory, ObjectRecordFactory, ObjectTypeFactory, + ReferenceFactory, ) from objects.token.constants import PermissionModes from objects.token.tests.factories import PermissionFactory @@ -531,6 +532,210 @@ def test_update_object_correctionFor(self, m): last_record = object.last_record self.assertIsNone(last_record.correct) + def test_create_object_with_references(self, m): + mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") + m.get( + f"{self.object_type.url}/versions/1", + json=mock_objecttype_version(self.object_type.url), + ) + m.get(self.object_type.url, json=mock_objecttype(self.object_type.url)) + + url = reverse("object-list") + data = { + "type": self.object_type.url, + "record": { + "typeVersion": 1, + "data": {"plantDate": "2020-04-12", "diameter": 30}, + "startAt": "2020-01-01", + "references": [{"type": "zaak", "url": "https://example.com/zaak/1"}], + }, + } + + response = self.client.post(url, data, **GEO_WRITE_KWARGS) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + assert (record := Object.objects.get().record) + + self.assertSetEqual( + {(r.type, r.url) for r in record.references.all()}, + {("zaak", "https://example.com/zaak/1")}, + ) + + def test_update_object_with_references(self, m): + mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") + m.get( + f"{self.object_type.url}/versions/1", + json=mock_objecttype_version(self.object_type.url), + ) + m.get(self.object_type.url, json=mock_objecttype(self.object_type.url)) + + # other object - to check that correction works when there is another record with the same index + ObjectRecordFactory.create(object__object_type=self.object_type) + initial_record = ObjectRecordFactory.create( + object__object_type=self.object_type + ) + object = initial_record.object + + assert initial_record.end_at is None + + url = reverse("object-detail", args=[object.uuid]) + data = { + "type": object.object_type.url, + "record": { + "typeVersion": 1, + "data": {"plantDate": "2020-04-12", "diameter": 30}, + "startAt": "2020-01-01", + "correctionFor": initial_record.index, + "references": [{"type": "zaak", "url": "https://example.com/zaak/2"}], + }, + } + + response = self.client.put(url, data, **GEO_WRITE_KWARGS) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + object.refresh_from_db() + initial_record.refresh_from_db() + + self.assertEqual(object.object_type, self.object_type) + self.assertEqual(object.records.count(), 2) + + current_record = object.current_record + + self.assertSetEqual( + {(r.type, r.url) for r in current_record.references.all()}, + {("zaak", "https://example.com/zaak/2")}, + ) + + self.assertEqual(current_record.version, 1) + self.assertEqual( + current_record.data, {"plantDate": "2020-04-12", "diameter": 30} + ) + self.assertEqual(current_record.start_at, date(2020, 1, 1)) + self.assertEqual(current_record.registration_at, date(2020, 8, 8)) + self.assertIsNone(current_record.end_at) + self.assertEqual(current_record.correct, initial_record) + # assert changes to initial record + self.assertNotEqual(current_record, initial_record) + self.assertEqual(initial_record.corrected, current_record) + self.assertEqual(initial_record.end_at, date(2020, 1, 1)) + + def test_patch_object_record_with_references(self, m): + # NOTE: An almost standard JSON Merge PATCH algorithm is applied, + # but *only* on record.data, not on the record itself! + + mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") + m.get( + f"{self.object_type.url}/versions/1", + json=mock_objecttype_version(self.object_type.url), + ) + + initial_record = ObjectRecordFactory.create( + version=1, + object__object_type=self.object_type, + start_at=date.today(), + data={"name": "Name", "diameter": 20}, + ) + object = initial_record.object + + url = reverse("object-detail", args=[object.uuid]) + data = { + "record": { + "data": {"plantDate": "2020-04-12", "diameter": 30, "name": None}, + "startAt": "2020-01-01", + "correctionFor": initial_record.index, + "references": [{"type": "zaak", "url": "https://example.com/zaak/3"}], + }, + } + + response = self.client.patch(url, data, **GEO_WRITE_KWARGS) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + initial_record.refresh_from_db() + + self.assertEqual(object.records.count(), 2) + + current_record = object.current_record + + self.assertSetEqual( + {(r.type, r.url) for r in current_record.references.all()}, + {("zaak", "https://example.com/zaak/3")}, + ) + + self.assertEqual(current_record.version, initial_record.version) + # The actual behavior of the data merging is in test_merge_patch.py: + self.assertEqual( + current_record.data, + {"plantDate": "2020-04-12", "diameter": 30, "name": None}, + ) + self.assertEqual(current_record.start_at, date(2020, 1, 1)) + self.assertEqual(current_record.registration_at, date(2020, 8, 8)) + self.assertIsNone(current_record.end_at) + self.assertEqual(current_record.correct, initial_record) + # assert changes to initial record + self.assertNotEqual(current_record, initial_record) + self.assertEqual(initial_record.corrected, current_record) + self.assertEqual(initial_record.end_at, date(2020, 1, 1)) + + def test_patch_validates_merged_object_rather_than_partial_object_with_references( + self, m + ): + mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") + m.get( + f"{self.object_type.url}/versions/1", + json=mock_objecttype_version(self.object_type.url), + ) + + initial_record = ObjectRecordFactory.create( + version=1, + object__object_type=self.object_type, + start_at=date.today(), + data={"name": "Name", "diameter": 20}, + ) + + url = reverse("object-detail", args=[initial_record.object.uuid]) + data = { + "record": { + "references": [{"type": "zaak", "url": "https://example.com/zaak/4"}] + }, + } + self.client.patch(url, data, **GEO_WRITE_KWARGS) + + data = { + "record": {"data": {"plantDate": "2020-04-10"}}, + } + response = self.client.patch(url, data, **GEO_WRITE_KWARGS) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.json()["record"]["data"], + {"plantDate": "2020-04-10", "diameter": 20, "name": "Name"}, + ) + + last_record = initial_record.object.last_record + self.assertSetEqual( + {(r.type, r.url) for r in last_record.references.all()}, + {("zaak", "https://example.com/zaak/4")}, + ) + self.assertEqual( + last_record.data, + {"plantDate": "2020-04-10", "diameter": 20, "name": "Name"}, + ) + + def test_delete_object_with_references(self, m): + record = ObjectRecordFactory.create(object__object_type=self.object_type) + ReferenceFactory.create_batch(2, record=record) + object = record.object + url = reverse("object-detail", args=[object.uuid]) + + response = self.client.delete(url) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Object.objects.count(), 0) + self.assertEqual(Reference.objects.count(), 0) + @freeze_time("2024-08-31") class ObjectsAvailableRecordsTests(TokenAuthMixin, APITestCase): From 5ad9cc393c6b24492c2fde38f9838bad42731f34 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Tue, 20 Jan 2026 10:53:40 +0100 Subject: [PATCH 03/11] :sparkles: [#708] Add sending ZAAK... cloud events --- src/objects/api/v2/views.py | 49 ++++- src/objects/cloud_events/__init__.py | 0 src/objects/cloud_events/constants.py | 2 + src/objects/cloud_events/tasks.py | 69 +++++++ src/objects/core/models.py | 12 +- .../tests/v2/test_notifications_send.py | 178 ++++++++++++++++++ 6 files changed, 303 insertions(+), 7 deletions(-) create mode 100644 src/objects/cloud_events/__init__.py create mode 100644 src/objects/cloud_events/constants.py create mode 100644 src/objects/cloud_events/tasks.py diff --git a/src/objects/api/v2/views.py b/src/objects/api/v2/views.py index 74a2a2e9..88d8804e 100644 --- a/src/objects/api/v2/views.py +++ b/src/objects/api/v2/views.py @@ -1,7 +1,8 @@ import datetime from django.conf import settings -from django.db import models +from django.db import models, transaction +from django.urls import reverse from django.utils.dateparse import parse_date from drf_spectacular.utils import ( @@ -10,6 +11,7 @@ extend_schema, extend_schema_view, ) +from notifications_api_common.cloudevents import process_cloudevent from rest_framework import mixins, viewsets from rest_framework.decorators import action from rest_framework.generics import get_object_or_404 @@ -18,7 +20,10 @@ from vng_api_common.pagination import DynamicPageSizePagination from vng_api_common.search import SearchMixin -from objects.core.models import ObjectRecord +from objects.cloud_events.constants import ZAAK_ONTKOPPELD +from objects.cloud_events.tasks import send_zaak_events +from objects.core.constants import ReferenceType +from objects.core.models import Object, ObjectRecord from objects.token.models import Permission from objects.token.permissions import ObjectTypeBasedPermission @@ -153,12 +158,50 @@ def perform_create(self, serializer): super().perform_create(serializer) objects_create_counter.add(1) + if record := serializer.instance: + object_path = reverse( + "v2:object-detail", kwargs={"uuid": str(record.object.uuid)} + ) + object_url = self.request.build_absolute_uri(object_path) + send_zaak_events.delay(record.pk, object_url) + def perform_update(self, serializer): super().perform_update(serializer) objects_update_counter.add(1) + if record := serializer.instance: + object_path = reverse( + "v2:object-detail", kwargs={"uuid": str(record.object.uuid)} + ) + object_url = self.request.build_absolute_uri(object_path) + send_zaak_events.delay(record.pk, object_url) + def perform_destroy(self, instance): - instance.object.delete() + obj: Object = instance.object + + object_path = reverse("v2:object-detail", kwargs={"uuid": str(obj.uuid)}) + object_url = self.request.build_absolute_uri(object_path) + + zaak_urls = list( + obj.last_record.references.filter(type=ReferenceType.zaak).values_list( + "url", flat=True + ) + ) + + def send_events(): + for zaak_url in zaak_urls: + process_cloudevent( + ZAAK_ONTKOPPELD, + data={ + "zaak": zaak_url, + "linkTo": object_url, + "linkObjectType": "object", + }, + ) + + transaction.on_commit(send_events) + + obj.delete() objects_delete_counter.add(1) @extend_schema( diff --git a/src/objects/cloud_events/__init__.py b/src/objects/cloud_events/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/objects/cloud_events/constants.py b/src/objects/cloud_events/constants.py new file mode 100644 index 00000000..2bfd2065 --- /dev/null +++ b/src/objects/cloud_events/constants.py @@ -0,0 +1,2 @@ +ZAAK_GEKOPPELD = "nl.overheid.zaken.zaak-gekoppeld" +ZAAK_ONTKOPPELD = "nl.overheid.zaken.zaak-ontkoppeld" diff --git a/src/objects/cloud_events/tasks.py b/src/objects/cloud_events/tasks.py new file mode 100644 index 00000000..fdc013f7 --- /dev/null +++ b/src/objects/cloud_events/tasks.py @@ -0,0 +1,69 @@ +from django.conf import settings + +from celery import shared_task +from notifications_api_common.cloudevents import process_cloudevent + +from objects.core.constants import ReferenceType +from objects.core.models import ObjectRecord, Reference + +from .constants import ZAAK_GEKOPPELD, ZAAK_ONTKOPPELD + + +@shared_task +def send_zaak_events(object_record_id: int, object_url: str): + """Send all zaak gekoppeld/ontkoppeld + + In order to not slow down the object API endpoint with extra queries and + multiple cloudevent schedules, this is done in a task. + """ + if settings.NOTIFICATIONS_DISABLED: + return + + try: + record = ( + ObjectRecord.objects.select_related("object") + .prefetch_related("references") + .get(pk=object_record_id) + ) + except ObjectRecord.DoesNotExist: + return + + object = record.object + label = f"{object.object_type.name} {record}" + + current: set[str] = { + ref.url for ref in record.references.all() if ref.type == ReferenceType.zaak + } + previous: set[str] = { + ref.url + for ref in Reference.objects.filter( + type=ReferenceType.zaak, + record__object__pk=object.pk, + record__index=record.index - 1, + ) + } + + gekoppeld = current - previous + ontkoppeld = previous - current + + for zaak_url in gekoppeld: + process_cloudevent( + ZAAK_GEKOPPELD, + data={ + "zaak": zaak_url, + "linkTo": object_url, + "linkObjectType": "object", + "label": label, + }, + ) + + for zaak_url in ontkoppeld: + process_cloudevent( + ZAAK_ONTKOPPELD, + data={ + "zaak": zaak_url, + "linkTo": object_url, + "linkObjectType": "object", + # label is not used, it's pure display and not needed for removal + }, + ) diff --git a/src/objects/core/models.py b/src/objects/core/models.py index e28e5680..810181bb 100644 --- a/src/objects/core/models.py +++ b/src/objects/core/models.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import datetime import uuid -from typing import Iterable +from typing import ClassVar, Iterable from django.contrib.gis.db.models import GeometryField from django.contrib.postgres.indexes import GinIndex @@ -296,17 +298,18 @@ class Object(models.Model): ) objects = ObjectQuerySet.as_manager() + records: ClassVar[ObjectRecordQuerySet] @property - def current_record(self): + def current_record(self) -> ObjectRecord | None: return self.records.filter_for_date(datetime.date.today()).first() @property - def last_record(self): + def last_record(self) -> ObjectRecord | None: return self.records.order_by("-index").first() @property - def record(self): + def record(self) -> ObjectRecord | None: # `actual_records` attribute is set in ObjectViewSet.get_queryset if getattr(self, "actual_records", None): return self.actual_records[0] @@ -379,6 +382,7 @@ class ObjectRecord(models.Model): ) objects = ObjectRecordQuerySet.as_manager() + references: ClassVar[models.QuerySet[Reference]] class Meta: unique_together = ("object", "index") diff --git a/src/objects/tests/v2/test_notifications_send.py b/src/objects/tests/v2/test_notifications_send.py index da259e7c..44047957 100644 --- a/src/objects/tests/v2/test_notifications_send.py +++ b/src/objects/tests/v2/test_notifications_send.py @@ -1,4 +1,5 @@ from unittest.mock import patch +from urllib.parse import urlencode from django.test import override_settings @@ -10,10 +11,12 @@ from zgw_consumers.constants import APITypes from zgw_consumers.models import Service +from objects.cloud_events.constants import ZAAK_GEKOPPELD, ZAAK_ONTKOPPELD from objects.core.tests.factories import ( ObjectFactory, ObjectRecordFactory, ObjectTypeFactory, + ReferenceFactory, ) from objects.token.constants import PermissionModes from objects.token.tests.factories import PermissionFactory @@ -241,3 +244,178 @@ def test_send_notif_delete_object(self, mocker, mock_task): }, }, ) + + @patch("notifications_api_common.tasks.send_cloudevent.delay") + @patch("notifications_api_common.viewsets.send_notification.delay") + @override_settings( + CELERY_TASK_ALWAYS_EAGER=True, NOTIFICATIONS_SOURCE="objects-api-test" + ) + def test_send_cloudevent_adding_zaak(self, mocker, _notification, mock_event): + mock_service_oas_get(mocker, OBJECT_TYPES_API, "objecttypes") + mocker.get( + f"{self.object_type.url}/versions/1", + json=mock_objecttype_version(self.object_type.url), + ) + mocker.get(self.object_type.url, json=mock_objecttype(self.object_type.url)) + + url = reverse("object-list") + data = { + "type": self.object_type.url, + "record": { + "typeVersion": 1, + "data": {"plantDate": "2020-04-12", "diameter": 30}, + "startAt": "2020-01-01", + "references": [{"type": "zaak", "url": "https://example.com/zaak/1"}], + }, + } + + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post(url, data, **GEO_WRITE_KWARGS) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data) + + data = response.json() + + mock_event.assert_called_once() + event = mock_event.call_args[0][0] + self.assertEqual(event["type"], ZAAK_GEKOPPELD) + self.assertTrue( + event["data"]["label"].endswith( + str(self.object_type.objectrecord_set.first()) + ) + ) + + del event["data"]["label"] + + self.assertEqual( + event["data"], + { + "zaak": "https://example.com/zaak/1", + "linkTo": f"http://testserver/api/v2/objects/{data['uuid']}", + "linkObjectType": "object", + }, + ) + + @patch("notifications_api_common.tasks.send_cloudevent.delay") + @patch("notifications_api_common.viewsets.send_notification.delay") + @override_settings( + CELERY_TASK_ALWAYS_EAGER=True, NOTIFICATIONS_SOURCE="objects-api-test" + ) + def test_send_cloudevents_changing_zaak(self, mocker, _notification, mock_event): + mock_service_oas_get(mocker, OBJECT_TYPES_API, "objecttypes") + mocker.get( + f"{self.object_type.url}/versions/1", + json=mock_objecttype_version(self.object_type.url), + ) + mocker.get(self.object_type.url, json=mock_objecttype(self.object_type.url)) + + obj = ObjectFactory.create(object_type=self.object_type) + ref = ReferenceFactory.create( + type="zaak", url="https://example.com/zaak/1", record__object=obj + ) + ReferenceFactory.create( + type="zaak", url="https://example.com/zaak/2", record=ref.record + ) + + url = reverse("object-detail", args=[obj.uuid]) + + data = { + "type": self.object_type.url, + "record": { + "typeVersion": 1, + "data": {"plantDate": "2020-04-12", "diameter": 30}, + "geometry": { + "type": "Point", + "coordinates": [4.910649523925713, 52.37240093589432], + }, + "startAt": "2020-01-01", + "references": [ + {"type": "zaak", "url": "https://example.com/zaak/2"}, + {"type": "zaak", "url": "https://example.com/zaak/3"}, + ], + }, + } + + with self.captureOnCommitCallbacks(execute=True): + response = self.client.patch(url, data, **GEO_WRITE_KWARGS) + + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + + data = response.json() + + self.assertEqual(mock_event.call_count, 2) + + koppel_event = mock_event.call_args_list[0][0][0] + self.assertEqual(koppel_event["type"], ZAAK_GEKOPPELD) + self.assertTrue( + koppel_event["data"]["label"].endswith( + str(self.object_type.objectrecord_set.last()) + ) + ) + + del koppel_event["data"]["label"] + + self.assertEqual( + koppel_event["data"], + { + "zaak": "https://example.com/zaak/3", + "linkTo": f"http://testserver/api/v2/objects/{data['uuid']}", + "linkObjectType": "object", + }, + ) + + ontkoppel_event = mock_event.call_args_list[1][0][0] + self.assertEqual(ontkoppel_event["type"], ZAAK_ONTKOPPELD) + self.assertEqual( + ontkoppel_event["data"], + { + "zaak": "https://example.com/zaak/1", + "linkTo": f"http://testserver/api/v2/objects/{data['uuid']}", + "linkObjectType": "object", + }, + ) + + @patch("notifications_api_common.tasks.send_cloudevent.delay") + @patch("notifications_api_common.viewsets.send_notification.delay") + @override_settings( + CELERY_TASK_ALWAYS_EAGER=True, NOTIFICATIONS_SOURCE="objects-api-test" + ) + def test_send_cloudevents_deleting_object(self, mocker, _notification, mock_event): + mock_service_oas_get(mocker, OBJECT_TYPES_API, "objecttypes") + mocker.get( + f"{self.object_type.url}/versions/1", + json=mock_objecttype_version(self.object_type.url), + ) + mocker.get(self.object_type.url, json=mock_objecttype(self.object_type.url)) + + obj = ObjectFactory.create(object_type=self.object_type) + ref = ReferenceFactory.create( + type="zaak", url="https://example.com/zaak/1", record__object=obj + ) + ReferenceFactory.create( + type="zaak", url="https://example.com/zaak/2", record=ref.record + ) + + url = reverse("object-detail", args=[obj.uuid]) + + with self.captureOnCommitCallbacks(execute=True): + response = self.client.delete(url, *GEO_WRITE_KWARGS) + + self.assertEqual( + response.status_code, status.HTTP_204_NO_CONTENT, response.data + ) + + self.assertEqual(mock_event.call_count, 2) + + events = [args[0][0] for args in mock_event.call_args_list] + + assert {event["type"] for event in events} == {ZAAK_ONTKOPPELD} + assert {event["data"]["linkTo"] for event in events} == { + f"http://testserver{url}" + } + assert {event["data"]["linkObjectType"] for event in events} == {"object"} + + assert {event["data"]["zaak"] for event in events} == { + "https://example.com/zaak/1", + "https://example.com/zaak/2", + } From 51119be13c1e80245631220f3957d4c9e0f93ec7 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Tue, 20 Jan 2026 12:02:41 +0100 Subject: [PATCH 04/11] :sparkles: [#708] Add Open Archiefbeheer object destruction support --- src/objects/api/v2/views.py | 48 +++-- .../tests/v2/test_notifications_send.py | 177 +++++++++++++++++- 2 files changed, 210 insertions(+), 15 deletions(-) diff --git a/src/objects/api/v2/views.py b/src/objects/api/v2/views.py index 88d8804e..ec06a6da 100644 --- a/src/objects/api/v2/views.py +++ b/src/objects/api/v2/views.py @@ -12,7 +12,7 @@ extend_schema_view, ) from notifications_api_common.cloudevents import process_cloudevent -from rest_framework import mixins, viewsets +from rest_framework import mixins, status, viewsets from rest_framework.decorators import action from rest_framework.generics import get_object_or_404 from rest_framework.response import Response @@ -176,20 +176,22 @@ def perform_update(self, serializer): object_url = self.request.build_absolute_uri(object_path) send_zaak_events.delay(record.pk, object_url) - def perform_destroy(self, instance): + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + notification_data = self.get_serializer(instance).data obj: Object = instance.object object_path = reverse("v2:object-detail", kwargs={"uuid": str(obj.uuid)}) object_url = self.request.build_absolute_uri(object_path) - - zaak_urls = list( - obj.last_record.references.filter(type=ReferenceType.zaak).values_list( - "url", flat=True - ) - ) - - def send_events(): - for zaak_url in zaak_urls: + zaak_references = obj.last_record.references.filter(type=ReferenceType.zaak) + + if zaak_urls := list(zaak_references.values_list("url", flat=True)): + if (zaak_url := request.query_params.get("zaak")) and zaak_urls != [ + zaak_url + ]: + # OAB is archiving one of many ZAKEN attached to this object; + # just remove this single zaak reference. + zaak_references.filter(url="zaak_url").delete() process_cloudevent( ZAAK_ONTKOPPELD, data={ @@ -199,11 +201,33 @@ def send_events(): }, ) - transaction.on_commit(send_events) + response = Response( + {"behouden": [object_url]}, status=status.HTTP_200_OK + ) + self.action = "update" # change action for notification + self.notify(response.status_code, notification_data, instance=instance) + return response + + def send_events(): + for zaak_url in zaak_urls: + process_cloudevent( + ZAAK_ONTKOPPELD, + data={ + "zaak": zaak_url, + "linkTo": object_url, + "linkObjectType": "object", + }, + ) + + transaction.on_commit(send_events) obj.delete() objects_delete_counter.add(1) + response = Response(status=status.HTTP_204_NO_CONTENT) + self.notify(response.status_code, notification_data, instance=instance) + return response + @extend_schema( description="Retrieve all RECORDs of an OBJECT.", responses={"200": HistoryRecordSerializer(many=True)}, diff --git a/src/objects/tests/v2/test_notifications_send.py b/src/objects/tests/v2/test_notifications_send.py index 44047957..ee898b8c 100644 --- a/src/objects/tests/v2/test_notifications_send.py +++ b/src/objects/tests/v2/test_notifications_send.py @@ -250,7 +250,7 @@ def test_send_notif_delete_object(self, mocker, mock_task): @override_settings( CELERY_TASK_ALWAYS_EAGER=True, NOTIFICATIONS_SOURCE="objects-api-test" ) - def test_send_cloudevent_adding_zaak(self, mocker, _notification, mock_event): + def test_send_cloudevent_adding_zaak(self, mocker, mock_notification, mock_event): mock_service_oas_get(mocker, OBJECT_TYPES_API, "objecttypes") mocker.get( f"{self.object_type.url}/versions/1", @@ -296,12 +296,29 @@ def test_send_cloudevent_adding_zaak(self, mocker, _notification, mock_event): }, ) + mock_notification.assert_called_once_with( + { + "kanaal": "objecten", + "source": "objects-api-test", + "hoofdObject": data["url"], + "resource": "object", + "resourceUrl": data["url"], + "actie": "create", + "aanmaakdatum": "2018-09-07T02:00:00+02:00", + "kenmerken": { + "objectType": self.object_type.url, + }, + }, + ) + @patch("notifications_api_common.tasks.send_cloudevent.delay") @patch("notifications_api_common.viewsets.send_notification.delay") @override_settings( CELERY_TASK_ALWAYS_EAGER=True, NOTIFICATIONS_SOURCE="objects-api-test" ) - def test_send_cloudevents_changing_zaak(self, mocker, _notification, mock_event): + def test_send_cloudevents_changing_zaak( + self, mocker, mock_notification, mock_event + ): mock_service_oas_get(mocker, OBJECT_TYPES_API, "objecttypes") mocker.get( f"{self.object_type.url}/versions/1", @@ -375,12 +392,29 @@ def test_send_cloudevents_changing_zaak(self, mocker, _notification, mock_event) }, ) + mock_notification.assert_called_once_with( + { + "kanaal": "objecten", + "hoofdObject": f"http://testserver{url}", + "resource": "object", + "resourceUrl": f"http://testserver{url}", + "actie": "partial_update", + "aanmaakdatum": "2018-09-07T02:00:00+02:00", + "kenmerken": { + "objectType": self.object_type.url, + }, + "source": "objects-api-test", + }, + ) + @patch("notifications_api_common.tasks.send_cloudevent.delay") @patch("notifications_api_common.viewsets.send_notification.delay") @override_settings( CELERY_TASK_ALWAYS_EAGER=True, NOTIFICATIONS_SOURCE="objects-api-test" ) - def test_send_cloudevents_deleting_object(self, mocker, _notification, mock_event): + def test_send_cloudevents_deleting_object( + self, mocker, mock_notification, mock_event + ): mock_service_oas_get(mocker, OBJECT_TYPES_API, "objecttypes") mocker.get( f"{self.object_type.url}/versions/1", @@ -419,3 +453,140 @@ def test_send_cloudevents_deleting_object(self, mocker, _notification, mock_even "https://example.com/zaak/1", "https://example.com/zaak/2", } + + mock_notification.assert_called_once_with( + { + "kanaal": "objecten", + "hoofdObject": f"http://testserver{url}", + "resource": "object", + "resourceUrl": f"http://testserver{url}", + "actie": "destroy", + "aanmaakdatum": "2018-09-07T02:00:00+02:00", + "kenmerken": { + "objectType": self.object_type.url, + }, + "source": "objects-api-test", + }, + ) + + @patch("notifications_api_common.tasks.send_cloudevent.delay") + @patch("notifications_api_common.viewsets.send_notification.delay") + @override_settings( + CELERY_TASK_ALWAYS_EAGER=True, NOTIFICATIONS_SOURCE="objects-api-test" + ) + def test_send_cloudevents_deleting_object_archiving_only_zaak( + self, mocker, mock_notification, mock_event + ): + "Open Archiefbeheer DELETEs with zaak queryparam when archiving" + mock_service_oas_get(mocker, OBJECT_TYPES_API, "objecttypes") + mocker.get( + f"{self.object_type.url}/versions/1", + json=mock_objecttype_version(self.object_type.url), + ) + mocker.get(self.object_type.url, json=mock_objecttype(self.object_type.url)) + + zaak_1 = "https://example.com/zaak/1" + + obj = ObjectFactory.create(object_type=self.object_type) + ReferenceFactory.create(type="zaak", url=zaak_1, record__object=obj) + + url = reverse("object-detail", args=[obj.uuid]) + + with self.captureOnCommitCallbacks(execute=True): + response = self.client.delete( + f"{url}?{urlencode({'zaak': zaak_1})}", *GEO_WRITE_KWARGS + ) + + self.assertEqual( + response.status_code, status.HTTP_204_NO_CONTENT, response.data + ) + + self.assertEqual(mock_event.call_count, 1) + + events = [args[0][0] for args in mock_event.call_args_list] + + assert {event["type"] for event in events} == {ZAAK_ONTKOPPELD} + assert {event["data"]["linkTo"] for event in events} == { + f"http://testserver{url}" + } + assert {event["data"]["linkObjectType"] for event in events} == {"object"} + + assert {event["data"]["zaak"] for event in events} == { + "https://example.com/zaak/1" + } + + mock_notification.assert_called_once_with( + { + "kanaal": "objecten", + "hoofdObject": f"http://testserver{url}", + "resource": "object", + "resourceUrl": f"http://testserver{url}", + "actie": "destroy", + "aanmaakdatum": "2018-09-07T02:00:00+02:00", + "kenmerken": { + "objectType": self.object_type.url, + }, + "source": "objects-api-test", + }, + ) + + @patch("notifications_api_common.tasks.send_cloudevent.delay") + @patch("notifications_api_common.viewsets.send_notification.delay") + @override_settings( + CELERY_TASK_ALWAYS_EAGER=True, NOTIFICATIONS_SOURCE="objects-api-test" + ) + def test_send_cloudevents_deleting_object_archiving_a_zaak( + self, mocker, mock_notification, mock_event + ): + "Open Archiefbeheer DELETEs with zaak queryparam when archiving" + mock_service_oas_get(mocker, OBJECT_TYPES_API, "objecttypes") + mocker.get( + f"{self.object_type.url}/versions/1", + json=mock_objecttype_version(self.object_type.url), + ) + mocker.get(self.object_type.url, json=mock_objecttype(self.object_type.url)) + + zaak_1 = "https://example.com/zaak/1" + + obj = ObjectFactory.create(object_type=self.object_type) + ref = ReferenceFactory.create(type="zaak", url=zaak_1, record__object=obj) + ReferenceFactory.create( + type="zaak", url="https://example.com/zaak/2", record=ref.record + ) + + url = reverse("object-detail", args=[obj.uuid]) + + with self.captureOnCommitCallbacks(execute=True): + response = self.client.delete( + f"{url}?{urlencode({'zaak': zaak_1})}", *GEO_WRITE_KWARGS + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + + self.assertEqual(mock_event.call_count, 1) + + event = mock_event.call_args_list[0][0][0] + + assert event["type"] == ZAAK_ONTKOPPELD + assert event["data"]["linkTo"] == f"http://testserver{url}" + + assert event["data"]["linkObjectType"] == "object" + + assert event["data"]["zaak"] == "https://example.com/zaak/1" + + # check correct notification + + mock_notification.assert_called_once_with( + { + "kanaal": "objecten", + "hoofdObject": f"http://testserver{url}", + "resource": "object", + "resourceUrl": f"http://testserver{url}", + "actie": "update", # wasn't destroyed, but changed! + "aanmaakdatum": "2018-09-07T02:00:00+02:00", + "kenmerken": { + "objectType": self.object_type.url, + }, + "source": "objects-api-test", + }, + ) From 26b448e8243d2da10fde14f3f07c8bbbb2794fdd Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Tue, 20 Jan 2026 14:48:13 +0100 Subject: [PATCH 05/11] :green_heart: [#708] Configure Celery in CI --- .github/workflows/ci.yml | 9 +++++++++ src/objects/conf/ci.py | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae2743d1..61002346 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,11 @@ jobs: # needed because the postgres container does not provide a healthcheck options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + redis: + image: redis:8 + ports: + - 6379:6379 + steps: - uses: actions/checkout@v4 - name: Set up backend environment @@ -62,11 +67,15 @@ jobs: coverage run src/manage.py test src env: DJANGO_SETTINGS_MODULE: objects.conf.ci + DEBUG: 'true' SECRET_KEY: dummy DB_USER: postgres DB_PASSWORD: '' DB_POOL_ENABLED: ${{ matrix.use_pooling }} + CELERY_BROKER_URL: 'redis://localhost:6379/0' + CELERY_ONCE_REDIS_URL: 'redis://localhost:6379/0' + - name: Publish coverage report uses: codecov/codecov-action@v4 with: diff --git a/src/objects/conf/ci.py b/src/objects/conf/ci.py index 4ccbc9ad..b6944643 100644 --- a/src/objects/conf/ci.py +++ b/src/objects/conf/ci.py @@ -32,3 +32,9 @@ AXES_BEHIND_REVERSE_PROXY = False NOTIFICATIONS_DISABLED = True + +CELERY_BROKER_TRANSPORT_OPTIONS = { + # when running in CI with a deliberately broken broker URL, tests should fail/error + # instead of retrying forever if the broker isn't available (which it won't be). + "max_retries": 0, +} From 5f149a3d602607ca03ea0ddd27fa768b2c7f6dc7 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Tue, 20 Jan 2026 15:42:23 +0100 Subject: [PATCH 06/11] :memo: [#708] Update OAS spec --- src/objects/api/v2/openapi.yaml | 89 ++++++++++++++++++++++++++++----- src/objects/api/v2/views.py | 30 ++++++++++- 2 files changed, 105 insertions(+), 14 deletions(-) diff --git a/src/objects/api/v2/openapi.yaml b/src/objects/api/v2/openapi.yaml index 7caaea03..815270be 100644 --- a/src/objects/api/v2/openapi.yaml +++ b/src/objects/api/v2/openapi.yaml @@ -524,11 +524,41 @@ paths: format: uuid description: Unique identifier (UUID4) required: true + - in: query + name: zaak + schema: + type: string + format: uri + description: '**Experimental** When destructing for archiving a ztc.ZAAK pass + in the ZAAK URL that is being destroyed. If this OBJECT''s current RECORD + has other ZAAK references, then object destruction is cancelled and only + this ZAAK URL is removed from the RECORD''s references.' tags: - objects security: - tokenAuth: [] responses: + '200': + headers: + Content-Crs: + schema: + type: string + enum: + - EPSG:4326 + description: 'The ''Coordinate Reference System'' (CRS) of the request + data. According to the GeoJSON spec, WGS84 is the default (EPSG: 4326 + is the same as WGS84).' + API-version: + schema: + type: string + description: 'Geeft een specifieke API-versie aan in de context van + een specifieke aanroep. Voorbeeld: 1.2.1.' + content: + application/json: + schema: + $ref: '#/components/schemas/BehoudenResponse' + description: OBJECT kept because it has multiple ZAKEN. Specified ZAAK parameter + removed from RECORD. '204': headers: API-version: @@ -536,7 +566,7 @@ paths: type: string description: 'Geeft een specifieke API-versie aan in de context van een specifieke aanroep. Voorbeeld: 1.2.1.' - description: No response body + description: OBJECT and all its RECORDs deleted. /objects/{uuid}/{index}: get: operationId: object_history_detail @@ -857,6 +887,17 @@ paths: description: OK components: schemas: + BehoudenResponse: + type: object + properties: + behouden: + type: array + items: + type: string + format: uri + description: Kept OBJECT URLs + required: + - behouden GeoJSONGeometry: title: GeoJSONGeometry type: object @@ -886,7 +927,7 @@ components: properties: type: allOf: - - $ref: '#/components/schemas/TypeEnum' + - $ref: '#/components/schemas/GeometryTypeEnum' description: The geometry type GeometryCollection: type: object @@ -903,6 +944,18 @@ components: type: array items: $ref: '#/components/schemas/Geometry' + GeometryTypeEnum: + type: string + enum: + - Point + - MultiPoint + - LineString + - MultiLineString + - Polygon + - MultiPolygon + - Feature + - FeatureCollection + - GeometryCollection HistoryRecord: type: object properties: @@ -1087,6 +1140,11 @@ components: of the object. Geometry can be added only if the related OBJECTTYPE allows this (`OBJECTTYPE.allowGeometry = true` or `OBJECTTYPE.allowGeometry` doesn't exist) + references: + type: array + items: + $ref: '#/components/schemas/Reference' + default: [] startAt: type: string format: date @@ -1287,18 +1345,23 @@ components: type: array items: $ref: '#/components/schemas/Point2D' - TypeEnum: - type: string + Reference: + type: object + properties: + type: + $ref: '#/components/schemas/ReferenceTypeEnum' + url: + type: string + format: uri + maxLength: 200 + required: + - type + - url + ReferenceTypeEnum: enum: - - Point - - MultiPoint - - LineString - - MultiLineString - - Polygon - - MultiPolygon - - Feature - - FeatureCollection - - GeometryCollection + - zaak + type: string + description: '* `zaak` - Zaak' securitySchemes: tokenAuth: type: apiKey diff --git a/src/objects/api/v2/views.py b/src/objects/api/v2/views.py index ec06a6da..bacc1b22 100644 --- a/src/objects/api/v2/views.py +++ b/src/objects/api/v2/views.py @@ -7,12 +7,14 @@ from drf_spectacular.utils import ( OpenApiParameter, + OpenApiResponse, OpenApiTypes, extend_schema, extend_schema_view, + inline_serializer, ) from notifications_api_common.cloudevents import process_cloudevent -from rest_framework import mixins, status, viewsets +from rest_framework import mixins, serializers, status, viewsets from rest_framework.decorators import action from rest_framework.generics import get_object_or_404 from rest_framework.response import Response @@ -85,6 +87,32 @@ destroy=extend_schema( description="Delete an OBJECT and all RECORDs belonging to it.", operation_id="object_delete", + parameters=[ + OpenApiParameter( + name="zaak", + description=( + "**Experimental** When destructing for archiving a ztc.ZAAK " + "pass in the ZAAK URL that is being destroyed. " + "If this OBJECT's current RECORD has other ZAAK references, " + "then object destruction is cancelled " + "and only this ZAAK URL is removed from the RECORD's references." + ), + ) + ], + responses={ + 200: OpenApiResponse( + description="OBJECT kept because it has multiple ZAKEN. Specified ZAAK parameter removed from RECORD references.", + response=inline_serializer( + name="BehoudenResponse", + fields={ + "behouden": serializers.ListField( + child=serializers.URLField(), help_text="Kept OBJECT URLs" + ) + }, + ), + ), + 204: OpenApiResponse(description="OBJECT and all its RECORDs deleted."), + }, ), ) class ObjectViewSet( From ab530e96d05eabdb89328c65a19edbf0102f956a Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Wed, 21 Jan 2026 13:02:38 +0100 Subject: [PATCH 07/11] :bug: [#708] Fix Reference deletion in single zaak case Rewrote the nested if-clauses to a pattern match for clarity. --- src/objects/api/v2/views.py | 40 ++++++++++--------- .../tests/v2/test_notifications_send.py | 9 +++++ 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/objects/api/v2/views.py b/src/objects/api/v2/views.py index bacc1b22..e3a3ffef 100644 --- a/src/objects/api/v2/views.py +++ b/src/objects/api/v2/views.py @@ -213,17 +213,17 @@ def destroy(self, request, *args, **kwargs): object_url = self.request.build_absolute_uri(object_path) zaak_references = obj.last_record.references.filter(type=ReferenceType.zaak) - if zaak_urls := list(zaak_references.values_list("url", flat=True)): - if (zaak_url := request.query_params.get("zaak")) and zaak_urls != [ - zaak_url - ]: + match list(zaak_references.values_list("url", flat=True)): + case [*zaak_urls] if ( + archiving_zaak_url := request.query_params.get("zaak") + ) and len(zaak_urls) > 1: # OAB is archiving one of many ZAKEN attached to this object; - # just remove this single zaak reference. - zaak_references.filter(url="zaak_url").delete() + # just remove this single zaak reference; don't delete the object + zaak_references.filter(url=archiving_zaak_url).delete() process_cloudevent( ZAAK_ONTKOPPELD, data={ - "zaak": zaak_url, + "zaak": archiving_zaak_url, "linkTo": object_url, "linkObjectType": "object", }, @@ -236,18 +236,20 @@ def destroy(self, request, *args, **kwargs): self.notify(response.status_code, notification_data, instance=instance) return response - def send_events(): - for zaak_url in zaak_urls: - process_cloudevent( - ZAAK_ONTKOPPELD, - data={ - "zaak": zaak_url, - "linkTo": object_url, - "linkObjectType": "object", - }, - ) - - transaction.on_commit(send_events) + case [*zaak_urls] if zaak_urls: + + def send_events(): + for zaak_url in zaak_urls: + process_cloudevent( + ZAAK_ONTKOPPELD, + data={ + "zaak": zaak_url, + "linkTo": object_url, + "linkObjectType": "object", + }, + ) + + transaction.on_commit(send_events) obj.delete() objects_delete_counter.add(1) diff --git a/src/objects/tests/v2/test_notifications_send.py b/src/objects/tests/v2/test_notifications_send.py index ee898b8c..9141bd90 100644 --- a/src/objects/tests/v2/test_notifications_send.py +++ b/src/objects/tests/v2/test_notifications_send.py @@ -12,6 +12,7 @@ from zgw_consumers.models import Service from objects.cloud_events.constants import ZAAK_GEKOPPELD, ZAAK_ONTKOPPELD +from objects.core.models import Reference from objects.core.tests.factories import ( ObjectFactory, ObjectRecordFactory, @@ -439,6 +440,8 @@ def test_send_cloudevents_deleting_object( response.status_code, status.HTTP_204_NO_CONTENT, response.data ) + assert not Reference.objects.exists() + self.assertEqual(mock_event.call_count, 2) events = [args[0][0] for args in mock_event.call_args_list] @@ -501,6 +504,8 @@ def test_send_cloudevents_deleting_object_archiving_only_zaak( response.status_code, status.HTTP_204_NO_CONTENT, response.data ) + assert not Reference.objects.exists() + self.assertEqual(mock_event.call_count, 1) events = [args[0][0] for args in mock_event.call_args_list] @@ -563,6 +568,10 @@ def test_send_cloudevents_deleting_object_archiving_a_zaak( self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + assert {ref.url for ref in Reference.objects.all()} == { + "https://example.com/zaak/2" + } + self.assertEqual(mock_event.call_count, 1) event = mock_event.call_args_list[0][0][0] From dd3a70472b6e6dc1f95f4a73210c02f5530f163a Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Wed, 21 Jan 2026 14:30:20 +0100 Subject: [PATCH 08/11] :zap: [#708] Fix performance regression --- src/objects/api/v2/views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/objects/api/v2/views.py b/src/objects/api/v2/views.py index e3a3ffef..7eca579b 100644 --- a/src/objects/api/v2/views.py +++ b/src/objects/api/v2/views.py @@ -97,11 +97,12 @@ "then object destruction is cancelled " "and only this ZAAK URL is removed from the RECORD's references." ), + type=OpenApiTypes.URI, ) ], responses={ 200: OpenApiResponse( - description="OBJECT kept because it has multiple ZAKEN. Specified ZAAK parameter removed from RECORD references.", + description="OBJECT kept because it has multiple ZAKEN. Specified ZAAK parameter removed from RECORD.", response=inline_serializer( name="BehoudenResponse", fields={ @@ -125,7 +126,7 @@ class ObjectViewSet( "correct", "corrected", ) - .prefetch_related("object") + .prefetch_related("object", "references") .order_by("-pk") ) serializer_class = ObjectSerializer From af6908beb358a6fe85002deb13311e15746d6bf2 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Wed, 21 Jan 2026 14:58:55 +0100 Subject: [PATCH 09/11] :zap: [#708] Don't query references on destruction of not needed. --- src/objects/api/v2/views.py | 4 +- src/objects/cloud_events/tasks.py | 2 +- .../tests/v2/test_notifications_send.py | 37 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/objects/api/v2/views.py b/src/objects/api/v2/views.py index 7eca579b..41dda891 100644 --- a/src/objects/api/v2/views.py +++ b/src/objects/api/v2/views.py @@ -214,7 +214,9 @@ def destroy(self, request, *args, **kwargs): object_url = self.request.build_absolute_uri(object_path) zaak_references = obj.last_record.references.filter(type=ReferenceType.zaak) - match list(zaak_references.values_list("url", flat=True)): + match not settings.NOTIFICATIONS_DISABLED and list( + zaak_references.values_list("url", flat=True) + ): case [*zaak_urls] if ( archiving_zaak_url := request.query_params.get("zaak") ) and len(zaak_urls) > 1: diff --git a/src/objects/cloud_events/tasks.py b/src/objects/cloud_events/tasks.py index fdc013f7..68b9ea67 100644 --- a/src/objects/cloud_events/tasks.py +++ b/src/objects/cloud_events/tasks.py @@ -25,7 +25,7 @@ def send_zaak_events(object_record_id: int, object_url: str): .prefetch_related("references") .get(pk=object_record_id) ) - except ObjectRecord.DoesNotExist: + except ObjectRecord.DoesNotExist: # pragma: no cover return object = record.object diff --git a/src/objects/tests/v2/test_notifications_send.py b/src/objects/tests/v2/test_notifications_send.py index 9141bd90..3a693c75 100644 --- a/src/objects/tests/v2/test_notifications_send.py +++ b/src/objects/tests/v2/test_notifications_send.py @@ -599,3 +599,40 @@ def test_send_cloudevents_deleting_object_archiving_a_zaak( "source": "objects-api-test", }, ) + + @override_settings( + NOTIFICATIONS_DISABLED=True, + CELERY_TASK_ALWAYS_EAGER=True, + NOTIFICATIONS_SOURCE="objects-api-test", + ) + @patch("notifications_api_common.tasks.send_cloudevent.delay") + @patch("notifications_api_common.viewsets.send_notification.delay") + def test_no_notifications_sent_when_disabled( + self, mocker, mock_notification, mock_events + ): + mock_service_oas_get(mocker, OBJECT_TYPES_API, "objecttypes") + mocker.get( + f"{self.object_type.url}/versions/1", + json=mock_objecttype_version(self.object_type.url), + ) + mocker.get(self.object_type.url, json=mock_objecttype(self.object_type.url)) + + url = reverse("object-list") + data = { + "type": self.object_type.url, + "record": { + "typeVersion": 1, + "data": {"plantDate": "2020-04-12", "diameter": 30}, + "startAt": "2020-01-01", + "references": [{"type": "zaak", "url": "https://example.com/zaak/1"}], + }, + } + + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post(url, data, **GEO_WRITE_KWARGS) + data = response.json() + self.client.put(url, data, **GEO_WRITE_KWARGS) + self.client.delete(data["url"]) + + assert mock_notification.call_args_list == [] + assert mock_events.call_args_list == [] From 7fe2caf0cf42e13f218993316aee5c169784ab33 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Thu, 22 Jan 2026 20:17:47 +0100 Subject: [PATCH 10/11] :wrench: [#708] Add ENABLE_CLOUD_EVENTS setting --- src/objects/api/v2/views.py | 2 +- src/objects/cloud_events/tasks.py | 2 +- src/objects/conf/base.py | 21 +++++++++++- .../tests/v2/test_notifications_send.py | 34 +++++++++++++------ 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/objects/api/v2/views.py b/src/objects/api/v2/views.py index 41dda891..f755eec5 100644 --- a/src/objects/api/v2/views.py +++ b/src/objects/api/v2/views.py @@ -214,7 +214,7 @@ def destroy(self, request, *args, **kwargs): object_url = self.request.build_absolute_uri(object_path) zaak_references = obj.last_record.references.filter(type=ReferenceType.zaak) - match not settings.NOTIFICATIONS_DISABLED and list( + match settings.ENABLE_CLOUD_EVENTS and list( zaak_references.values_list("url", flat=True) ): case [*zaak_urls] if ( diff --git a/src/objects/cloud_events/tasks.py b/src/objects/cloud_events/tasks.py index 68b9ea67..cf23bb60 100644 --- a/src/objects/cloud_events/tasks.py +++ b/src/objects/cloud_events/tasks.py @@ -16,7 +16,7 @@ def send_zaak_events(object_record_id: int, object_url: str): In order to not slow down the object API endpoint with extra queries and multiple cloudevent schedules, this is done in a task. """ - if settings.NOTIFICATIONS_DISABLED: + if not settings.ENABLE_CLOUD_EVENTS: return try: diff --git a/src/objects/conf/base.py b/src/objects/conf/base.py index 5407a355..6286d931 100644 --- a/src/objects/conf/base.py +++ b/src/objects/conf/base.py @@ -2,12 +2,13 @@ os.environ["_USE_STRUCTLOG"] = "True" +from django.core.exceptions import ImproperlyConfigured + from open_api_framework.conf.base import * # noqa from open_api_framework.conf.utils import config from .api import * # noqa - DATABASES["default"]["DISABLE_SERVER_SIDE_CURSORS"] = config( "DB_DISABLE_SERVER_SIDE_CURSORS", False, @@ -114,6 +115,24 @@ # settings for sending notifications NOTIFICATIONS_KANAAL = "objecten" +ENABLE_CLOUD_EVENTS = config( + "ENABLE_CLOUD_EVENTS", + default=False, + add_to_docs=False, + cast=bool, + help_text="**EXPERIMENTAL**: indicates whether or not cloud events should be sent to the configured endpoint for specific operations on Zaak (not ready for use in production)", +) + +NOTIFICATIONS_SOURCE = config( + "NOTIFICATIONS_SOURCE", + default="", + add_to_docs=False, + help_text="**EXPERIMENTAL**: the identifier of this application to use as the source in notifications and cloudevents", +) + +if ENABLE_CLOUD_EVENTS and not NOTIFICATIONS_SOURCE: + raise ImproperlyConfigured("NOTIFICATIONS_SOURCE is REQUIRED for CloudEvents") + CELERY_RESULT_EXPIRES = config( "CELERY_RESULT_EXPIRES", 3600, diff --git a/src/objects/tests/v2/test_notifications_send.py b/src/objects/tests/v2/test_notifications_send.py index 3a693c75..9e8d9c02 100644 --- a/src/objects/tests/v2/test_notifications_send.py +++ b/src/objects/tests/v2/test_notifications_send.py @@ -249,7 +249,9 @@ def test_send_notif_delete_object(self, mocker, mock_task): @patch("notifications_api_common.tasks.send_cloudevent.delay") @patch("notifications_api_common.viewsets.send_notification.delay") @override_settings( - CELERY_TASK_ALWAYS_EAGER=True, NOTIFICATIONS_SOURCE="objects-api-test" + CELERY_TASK_ALWAYS_EAGER=True, + ENABLE_CLOUD_EVENTS=True, + NOTIFICATIONS_SOURCE="objects-api-test", ) def test_send_cloudevent_adding_zaak(self, mocker, mock_notification, mock_event): mock_service_oas_get(mocker, OBJECT_TYPES_API, "objecttypes") @@ -315,7 +317,9 @@ def test_send_cloudevent_adding_zaak(self, mocker, mock_notification, mock_event @patch("notifications_api_common.tasks.send_cloudevent.delay") @patch("notifications_api_common.viewsets.send_notification.delay") @override_settings( - CELERY_TASK_ALWAYS_EAGER=True, NOTIFICATIONS_SOURCE="objects-api-test" + CELERY_TASK_ALWAYS_EAGER=True, + ENABLE_CLOUD_EVENTS=True, + NOTIFICATIONS_SOURCE="objects-api-test", ) def test_send_cloudevents_changing_zaak( self, mocker, mock_notification, mock_event @@ -411,7 +415,9 @@ def test_send_cloudevents_changing_zaak( @patch("notifications_api_common.tasks.send_cloudevent.delay") @patch("notifications_api_common.viewsets.send_notification.delay") @override_settings( - CELERY_TASK_ALWAYS_EAGER=True, NOTIFICATIONS_SOURCE="objects-api-test" + CELERY_TASK_ALWAYS_EAGER=True, + ENABLE_CLOUD_EVENTS=True, + NOTIFICATIONS_SOURCE="objects-api-test", ) def test_send_cloudevents_deleting_object( self, mocker, mock_notification, mock_event @@ -475,7 +481,9 @@ def test_send_cloudevents_deleting_object( @patch("notifications_api_common.tasks.send_cloudevent.delay") @patch("notifications_api_common.viewsets.send_notification.delay") @override_settings( - CELERY_TASK_ALWAYS_EAGER=True, NOTIFICATIONS_SOURCE="objects-api-test" + CELERY_TASK_ALWAYS_EAGER=True, + ENABLE_CLOUD_EVENTS=True, + NOTIFICATIONS_SOURCE="objects-api-test", ) def test_send_cloudevents_deleting_object_archiving_only_zaak( self, mocker, mock_notification, mock_event @@ -538,7 +546,9 @@ def test_send_cloudevents_deleting_object_archiving_only_zaak( @patch("notifications_api_common.tasks.send_cloudevent.delay") @patch("notifications_api_common.viewsets.send_notification.delay") @override_settings( - CELERY_TASK_ALWAYS_EAGER=True, NOTIFICATIONS_SOURCE="objects-api-test" + CELERY_TASK_ALWAYS_EAGER=True, + ENABLE_CLOUD_EVENTS=True, + NOTIFICATIONS_SOURCE="objects-api-test", ) def test_send_cloudevents_deleting_object_archiving_a_zaak( self, mocker, mock_notification, mock_event @@ -601,9 +611,8 @@ def test_send_cloudevents_deleting_object_archiving_a_zaak( ) @override_settings( - NOTIFICATIONS_DISABLED=True, CELERY_TASK_ALWAYS_EAGER=True, - NOTIFICATIONS_SOURCE="objects-api-test", + ENABLE_CLOUD_EVENTS=False, ) @patch("notifications_api_common.tasks.send_cloudevent.delay") @patch("notifications_api_common.viewsets.send_notification.delay") @@ -630,9 +639,12 @@ def test_no_notifications_sent_when_disabled( with self.captureOnCommitCallbacks(execute=True): response = self.client.post(url, data, **GEO_WRITE_KWARGS) - data = response.json() - self.client.put(url, data, **GEO_WRITE_KWARGS) - self.client.delete(data["url"]) + object_url = response.json()["url"] + put_reponse = self.client.put(object_url, data, **GEO_WRITE_KWARGS) + delete_response = self.client.delete(object_url) + + assert put_reponse.status_code == status.HTTP_200_OK + assert delete_response.status_code == status.HTTP_204_NO_CONTENT - assert mock_notification.call_args_list == [] + assert mock_notification.call_count == 3 assert mock_events.call_args_list == [] From 2e119cc66ba544c1352b3f64a32dc740bc6ef192 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Thu, 22 Jan 2026 20:18:55 +0100 Subject: [PATCH 11/11] :loud_sound: [#708] Add warnings for unexpected cases --- src/objects/api/v2/views.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/objects/api/v2/views.py b/src/objects/api/v2/views.py index f755eec5..3ae11fa5 100644 --- a/src/objects/api/v2/views.py +++ b/src/objects/api/v2/views.py @@ -5,6 +5,7 @@ from django.urls import reverse from django.utils.dateparse import parse_date +import structlog from drf_spectacular.utils import ( OpenApiParameter, OpenApiResponse, @@ -64,6 +65,8 @@ explode=True, ) +logger = structlog.stdlib.get_logger(__name__) + @extend_schema_view( list=extend_schema( @@ -193,6 +196,8 @@ def perform_create(self, serializer): ) object_url = self.request.build_absolute_uri(object_path) send_zaak_events.delay(record.pk, object_url) + else: + logger.warning("missing_record") # will this happen? def perform_update(self, serializer): super().perform_update(serializer) @@ -204,6 +209,8 @@ def perform_update(self, serializer): ) object_url = self.request.build_absolute_uri(object_path) send_zaak_events.delay(record.pk, object_url) + else: + logger.warning("missing_record") # will this happen? def destroy(self, request, *args, **kwargs): instance = self.get_object()