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/api/serializers.py b/src/objects/api/serializers.py index e845cf90..297463ab 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,7 +16,13 @@ logger = structlog.stdlib.get_logger(__name__) -class ObjectRecordSerializer(serializers.ModelSerializer): +class ReferenceSerializer(serializers.ModelSerializer): + class Meta: + model = Reference + fields = ["type", "url"] + + +class ObjectRecordSerializer(serializers.ModelSerializer[ObjectRecord]): correctionFor = ObjectSlugRelatedField( source="correct", slug_field="index", @@ -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, default=[]) class Meta: model = ObjectRecord @@ -38,6 +45,7 @@ class Meta: "typeVersion", "data", "geometry", + "references", "startAt", "endAt", "registrationAt", @@ -125,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", @@ -149,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/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 74a2a2e9..3ae11fa5 100644 --- a/src/objects/api/v2/views.py +++ b/src/objects/api/v2/views.py @@ -1,16 +1,21 @@ 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 +import structlog from drf_spectacular.utils import ( OpenApiParameter, + OpenApiResponse, OpenApiTypes, extend_schema, extend_schema_view, + inline_serializer, ) -from rest_framework import mixins, viewsets +from notifications_api_common.cloudevents import process_cloudevent +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 @@ -18,7 +23,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 @@ -57,6 +65,8 @@ explode=True, ) +logger = structlog.stdlib.get_logger(__name__) + @extend_schema_view( list=extend_schema( @@ -80,6 +90,33 @@ 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." + ), + type=OpenApiTypes.URI, + ) + ], + responses={ + 200: OpenApiResponse( + description="OBJECT kept because it has multiple ZAKEN. Specified ZAAK parameter removed from RECORD.", + 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( @@ -92,7 +129,7 @@ class ObjectViewSet( "correct", "corrected", ) - .prefetch_related("object") + .prefetch_related("object", "references") .order_by("-pk") ) serializer_class = ObjectSerializer @@ -153,14 +190,84 @@ 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) + else: + logger.warning("missing_record") # will this happen? + def perform_update(self, serializer): super().perform_update(serializer) objects_update_counter.add(1) - def perform_destroy(self, instance): - instance.object.delete() + 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) + else: + logger.warning("missing_record") # will this happen? + + 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_references = obj.last_record.references.filter(type=ReferenceType.zaak) + + match settings.ENABLE_CLOUD_EVENTS 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: + # OAB is archiving one of many ZAKEN attached to this object; + # 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": archiving_zaak_url, + "linkTo": object_url, + "linkObjectType": "object", + }, + ) + + 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 + + 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) + 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/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..cf23bb60 --- /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 not settings.ENABLE_CLOUD_EVENTS: + return + + try: + record = ( + ObjectRecord.objects.select_related("object") + .prefetch_related("references") + .get(pk=object_record_id) + ) + except ObjectRecord.DoesNotExist: # pragma: no cover + 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/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/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, +} 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..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 @@ -18,6 +20,7 @@ from .constants import ( DataClassificationChoices, ObjectVersionStatus, + ReferenceType, UpdateFrequencyChoices, ) from .query import ObjectQuerySet, ObjectRecordQuerySet, ObjectTypeQuerySet @@ -295,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] @@ -378,6 +382,7 @@ class ObjectRecord(models.Model): ) objects = ObjectRecordQuerySet.as_manager() + references: ClassVar[models.QuerySet[Reference]] class Meta: unique_together = ("object", "index") @@ -418,3 +423,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/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_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_notifications_send.py b/src/objects/tests/v2/test_notifications_send.py index da259e7c..9e8d9c02 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,13 @@ 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.models import Reference 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 +245,406 @@ 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, + 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") + 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", + }, + ) + + 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, + ENABLE_CLOUD_EVENTS=True, + NOTIFICATIONS_SOURCE="objects-api-test", + ) + 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", + 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", + }, + ) + + 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, + ENABLE_CLOUD_EVENTS=True, + NOTIFICATIONS_SOURCE="objects-api-test", + ) + 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", + 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 + ) + + assert not Reference.objects.exists() + + 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", + } + + 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, + ENABLE_CLOUD_EVENTS=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 + ) + + assert not Reference.objects.exists() + + 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, + ENABLE_CLOUD_EVENTS=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) + + 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] + + 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", + }, + ) + + @override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + ENABLE_CLOUD_EVENTS=False, + ) + @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) + 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_count == 3 + assert mock_events.call_args_list == [] diff --git a/src/objects/tests/v2/test_object_api.py b/src/objects/tests/v2/test_object_api.py index 91279b1f..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 @@ -79,6 +80,7 @@ def test_list_actual_objects(self, m): "registrationAt": object_record1.registration_at.isoformat(), "correctionFor": None, "correctedBy": None, + "references": [], }, } ], @@ -116,6 +118,7 @@ def test_retrieve_object(self, m): "registrationAt": object_record.registration_at.isoformat(), "correctionFor": None, "correctedBy": None, + "references": [], }, }, ) @@ -529,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):