From 465b8ed2150e5ac5775f540241f5cde6c78ac3eb Mon Sep 17 00:00:00 2001 From: floris272 Date: Fri, 19 Dec 2025 15:15:30 +0100 Subject: [PATCH 01/15] :memo: [#564] add objecttypes code --- requirements/base.in | 2 + requirements/base.txt | 3 + requirements/ci.txt | 5 + requirements/dev.txt | 5 + src/objects/accounts/tests/factories.py | 5 + src/objects/api/metrics.py | 26 +- src/objects/api/mixins.py | 17 + src/objects/api/serializers.py | 133 +- src/objects/api/v2/filters.py | 14 + src/objects/api/v2/urls.py | 15 +- src/objects/api/v2/views.py | 172 +- src/objects/api/validators.py | 15 + src/objects/conf/base.py | 1 + src/objects/core/admin.py | 182 +- src/objects/core/constants.py | 2 +- src/objects/core/forms.py | 72 + .../management/commands/import_objecttypes.py | 4 +- src/objects/core/models.py | 19 +- src/objects/core/query.py | 14 +- src/objects/core/tests/factories.py | 7 +- src/objects/core/widgets.py | 18 + src/objects/fixtures/demodata.json | 1702 ++++++++++++++++- .../admin/core/objecttype/object_history.html | 35 + .../core/objecttype/object_import_form.html | 37 + .../admin/core/objecttype/object_list.html | 11 + .../admin/core/objecttype/submit_line.html | 14 + src/objects/tests/admin/test_core_views.py | 3 + .../tests/admin/test_objecttype_admin.py | 465 +++++ .../tests/test_objectversion_generate.py | 33 + src/objects/tests/test_widgets.py | 44 + src/objects/tests/v2/test_auth.py | 34 + src/objects/tests/v2/test_filters.py | 25 + src/objects/tests/v2/test_metrics.py | 42 + src/objects/tests/v2/test_objecttype_api.py | 160 ++ .../tests/v2/test_objecttypeversion_api.py | 134 ++ src/objects/tests/v2/test_validation.py | 117 +- src/objects/token/permissions.py | 5 + .../tests/test_objecttype_authentication.py | 63 + 38 files changed, 3591 insertions(+), 64 deletions(-) create mode 100644 src/objects/core/forms.py create mode 100644 src/objects/core/widgets.py create mode 100644 src/objects/templates/admin/core/objecttype/object_history.html create mode 100644 src/objects/templates/admin/core/objecttype/object_import_form.html create mode 100644 src/objects/templates/admin/core/objecttype/object_list.html create mode 100644 src/objects/templates/admin/core/objecttype/submit_line.html create mode 100644 src/objects/tests/admin/test_objecttype_admin.py create mode 100644 src/objects/tests/test_objectversion_generate.py create mode 100644 src/objects/tests/test_widgets.py create mode 100644 src/objects/tests/v2/test_objecttype_api.py create mode 100644 src/objects/tests/v2/test_objecttypeversion_api.py create mode 100644 src/objects/token/tests/test_objecttype_authentication.py diff --git a/requirements/base.in b/requirements/base.in index e980947d..3894b9f0 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -8,6 +8,8 @@ furl # Django libraries django-capture-tag +django-jsonsuit + # Common ground libraries django-setup-configuration>=0.5.0 notifications-api-common[setup-configuration] diff --git a/requirements/base.txt b/requirements/base.txt index 1be9fee2..022592cb 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -82,6 +82,7 @@ django==5.2.11 # django-filter # django-formtools # django-jsonform + # django-jsonsuit # django-log-outgoing-requests # django-markup # django-otp @@ -138,6 +139,8 @@ django-jsonform==2.22.0 # via # mozilla-django-oidc-db # open-api-framework +django-jsonsuit==0.5.0 + # via -r requirements/base.in django-log-outgoing-requests==0.6.1 # via open-api-framework django-markup==1.8.1 diff --git a/requirements/ci.txt b/requirements/ci.txt index dd6b1aea..23c2036c 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -145,6 +145,7 @@ django==5.2.11 # django-filter # django-formtools # django-jsonform + # django-jsonsuit # django-log-outgoing-requests # django-markup # django-otp @@ -222,6 +223,10 @@ django-jsonform==2.22.0 # -r requirements/base.txt # mozilla-django-oidc-db # open-api-framework +django-jsonsuit==0.5.0 + # via + # -c requirements/base.txt + # -r requirements/base.txt django-log-outgoing-requests==0.6.1 # via # -c requirements/base.txt diff --git a/requirements/dev.txt b/requirements/dev.txt index 015d22d0..de4140e5 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -184,6 +184,7 @@ django==5.2.11 # django-filter # django-formtools # django-jsonform + # django-jsonsuit # django-log-outgoing-requests # django-markup # django-otp @@ -266,6 +267,10 @@ django-jsonform==2.22.0 # -r requirements/ci.txt # mozilla-django-oidc-db # open-api-framework +django-jsonsuit==0.5.0 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt django-log-outgoing-requests==0.6.1 # via # -c requirements/ci.txt diff --git a/src/objects/accounts/tests/factories.py b/src/objects/accounts/tests/factories.py index 77ed12b2..420aa78c 100644 --- a/src/objects/accounts/tests/factories.py +++ b/src/objects/accounts/tests/factories.py @@ -29,6 +29,11 @@ class Params: ) +class SuperUserFactory(UserFactory): + is_staff = True + is_superuser = True + + class StaffUserFactory(UserFactory): is_staff = True diff --git a/src/objects/api/metrics.py b/src/objects/api/metrics.py index ad9de2f8..06a23d72 100644 --- a/src/objects/api/metrics.py +++ b/src/objects/api/metrics.py @@ -1,19 +1,37 @@ from opentelemetry import metrics -meter = metrics.get_meter("objects.api") +object_meter = metrics.get_meter("objects.api") -objects_create_counter = meter.create_counter( +objects_create_counter = object_meter.create_counter( "objects.object.creates", description="Amount of objects created (via the API).", unit="1", ) -objects_update_counter = meter.create_counter( +objects_update_counter = object_meter.create_counter( "objects.object.updates", description="Amount of objects updated (via the API).", unit="1", ) -objects_delete_counter = meter.create_counter( +objects_delete_counter = object_meter.create_counter( "objects.object.deletes", description="Amount of objects deleted (via the API).", unit="1", ) + +objecttype_meter = metrics.get_meter("objecttypes.api.v2") + +objecttype_create_counter = objecttype_meter.create_counter( + "objecttypes.objecttype.creates", + description="Amount of objecttypes created (via the API).", + unit="1", +) +objecttype_update_counter = objecttype_meter.create_counter( + "objecttypes.objecttype.updates", + description="Amount of objecttypes updated (via the API).", + unit="1", +) +objecttype_delete_counter = objecttype_meter.create_counter( + "objecttypes.objecttype.deletes", + description="Amount of objecttypes deleted (via the API).", + unit="1", +) diff --git a/src/objects/api/mixins.py b/src/objects/api/mixins.py index 00d48470..e759088e 100644 --- a/src/objects/api/mixins.py +++ b/src/objects/api/mixins.py @@ -1,4 +1,6 @@ +from django.core.exceptions import ValidationError from django.db import models +from django.http import Http404 from notifications_api_common.viewsets import ( NotificationCreateMixin, @@ -8,6 +10,7 @@ ) from rest_framework.exceptions import NotAcceptable from rest_framework.renderers import BrowsableAPIRenderer +from rest_framework_nested.viewsets import NestedViewSetMixin as _NestedViewSetMixin from vng_api_common.exceptions import PreconditionFailed from vng_api_common.geo import ( DEFAULT_CRS, @@ -18,6 +21,20 @@ ) +class NestedViewSetMixin(_NestedViewSetMixin): + def get_queryset(self): + """ + catch validation errors if parent_lookup_kwargs have incorrect format + and return 404 + """ + try: + queryset = super().get_queryset() + except ValidationError: + raise Http404 + + return queryset + + class GeoMixin(_GeoMixin): def perform_crs_negotation(self, request): # don't cripple the browsable API... diff --git a/src/objects/api/serializers.py b/src/objects/api/serializers.py index e6f18a5b..0b41b41b 100644 --- a/src/objects/api/serializers.py +++ b/src/objects/api/serializers.py @@ -2,27 +2,154 @@ from django.utils.translation import gettext_lazy as _ import structlog +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from rest_framework.validators import UniqueValidator from rest_framework_gis.serializers import GeometryField +from rest_framework_nested.relations import NestedHyperlinkedRelatedField +from rest_framework_nested.serializers import NestedHyperlinkedModelSerializer +from vng_api_common.utils import get_help_text -from objects.core.models import Object, ObjectRecord, ObjectType, Reference +from objects.core.models import Object, ObjectRecord, ObjectType, ObjectTypeVersion, Reference from objects.token.models import Permission, TokenAuth from objects.utils.serializers import DynamicFieldsMixin from .fields import CachedObjectUrlField, ObjectSlugRelatedField, ObjectTypeField from .utils import merge_patch -from .validators import GeometryValidator, IsImmutableValidator, JsonSchemaValidator +from .validators import ( + GeometryValidator, + IsImmutableValidator, + JsonSchemaValidator, + VersionUpdateValidator, +) logger = structlog.stdlib.get_logger(__name__) +class ObjectTypeVersionSerializer(NestedHyperlinkedModelSerializer): + parent_lookup_kwargs = {"objecttype_uuid": "object_type__uuid"} + + class Meta: + model = ObjectTypeVersion + fields = ( + "url", + "version", + "objectType", + "status", + "jsonSchema", + "createdAt", + "modifiedAt", + "publishedAt", + ) + extra_kwargs = { + # "url": {"lookup_field": "version"}, + "version": {"read_only": True}, + "objectType": { + "source": "object_type", + # "lookup_field": "uuid", + "read_only": True, + }, + "jsonSchema": { + "source": "json_schema", + "validators": [JsonSchemaValidator()], + }, + "createdAt": {"source": "created_at", "read_only": True}, + "modifiedAt": {"source": "modified_at", "read_only": True}, + "publishedAt": {"source": "published_at", "read_only": True}, + } + validators = [VersionUpdateValidator()] + + def validate(self, attrs): + valid_attrs = super().validate(attrs) + + # check parent url + kwargs = self.context["request"].resolver_match.kwargs + if not ObjectType.objects.filter(uuid=kwargs["objecttype_uuid"]).exists(): + msg = _("Objecttype url is invalid") + raise serializers.ValidationError(msg, code="invalid-objecttype") + + return valid_attrs + + def create(self, validated_data): + kwargs = self.context["request"].resolver_match.kwargs + object_type = ObjectType.objects.get(uuid=kwargs["objecttype_uuid"]) + validated_data["object_type"] = object_type + + return super().create(validated_data) + + +@extend_schema_field( + { + "type": "object", + "additionalProperties": {"type": "string"}, + } +) +class LabelsField(serializers.JSONField): + pass + + +class ObjectTypeSerializer(serializers.HyperlinkedModelSerializer): + labels = LabelsField( + required=False, + help_text=get_help_text("core.ObjectType", "labels"), + ) + + versions = NestedHyperlinkedRelatedField( + many=True, + read_only=True, + # lookup_field="version", + view_name="objecttypeversion-detail", + parent_lookup_kwargs={"objecttype_uuid": "object_type__uuid"}, + help_text=_("list of URLs for the OBJECTTYPE versions"), + ) + + class Meta: + model = ObjectType + fields = ( + "url", + "uuid", + "name", + "namePlural", + "description", + "dataClassification", + "maintainerOrganization", + "maintainerDepartment", + "contactPerson", + "contactEmail", + "source", + "updateFrequency", + "providerOrganization", + "documentationUrl", + "labels", + "linkableToZaken", + "createdAt", + "modifiedAt", + "allowGeometry", + "versions", + ) + extra_kwargs = { + # "url": {"lookup_field": "uuid"}, + "uuid": {"validators": [IsImmutableValidator()]}, + "namePlural": {"source": "name_plural"}, + "dataClassification": {"source": "data_classification"}, + "maintainerOrganization": {"source": "maintainer_organization"}, + "maintainerDepartment": {"source": "maintainer_department"}, + "contactPerson": {"source": "contact_person"}, + "contactEmail": {"source": "contact_email"}, + "updateFrequency": {"source": "update_frequency"}, + "providerOrganization": {"source": "provider_organization"}, + "documentationUrl": {"source": "documentation_url"}, + "allowGeometry": {"source": "allow_geometry"}, + "linkableToZaken": {"source": "linkable_to_zaken"}, + "createdAt": {"source": "created_at", "read_only": True}, + "modifiedAt": {"source": "modified_at", "read_only": True}, + } + class ReferenceSerializer(serializers.ModelSerializer): class Meta: model = Reference fields = ["type", "url"] - class ObjectRecordSerializer(serializers.ModelSerializer[ObjectRecord]): correctionFor = ObjectSlugRelatedField( source="correct", diff --git a/src/objects/api/v2/filters.py b/src/objects/api/v2/filters.py index e97b851f..153dc9eb 100644 --- a/src/objects/api/v2/filters.py +++ b/src/objects/api/v2/filters.py @@ -8,10 +8,12 @@ from django_filters import filters from rest_framework import serializers from vng_api_common.filtersets import FilterSet +from vng_api_common.utils import get_help_text from objects.core.models import ObjectRecord, ObjectType from objects.utils.filters import ManyCharFilter, ObjectTypeFilter +from ...core.constants import DataClassificationChoices from ..constants import Operators from ..utils import display_choice_values_for_help_text, string_to_value from ..validators import validate_data_attr, validate_data_attrs @@ -136,6 +138,18 @@ def filter_data_attr_value_part( ) +class ObjectTypeFilterSet(FilterSet): + dataClassification = filters.ChoiceFilter( + field_name="data_classification", + choices=DataClassificationChoices.choices, + help_text=get_help_text("core.ObjectType", "data_classification"), + ) + + class Meta: + model = ObjectType + fields = ("dataClassification",) + + class ObjectRecordFilterForm(forms.Form): def clean(self): cleaned_data = super().clean() diff --git a/src/objects/api/v2/urls.py b/src/objects/api/v2/urls.py index d618a549..0195bc1c 100644 --- a/src/objects/api/v2/urls.py +++ b/src/objects/api/v2/urls.py @@ -3,7 +3,7 @@ from drf_spectacular.views import ( SpectacularRedocView, ) -from rest_framework import routers +from vng_api_common import routers from objects.utils.oas_extensions.views import ( DeprecationRedirectView, @@ -11,9 +11,20 @@ SpectacularYAMLAPIView, ) -from .views import ObjectViewSet, PermissionViewSet +from .views import ( + ObjectTypeVersionViewSet, + ObjectTypeViewSet, + ObjectViewSet, + PermissionViewSet, +) router = routers.DefaultRouter(trailing_slash=False) +router.register( + r"objecttypes", + ObjectTypeViewSet, + [routers.Nested("versions", ObjectTypeVersionViewSet)], +) + router.register(r"objects", ObjectViewSet, basename="object") router.register(r"permissions", PermissionViewSet) diff --git a/src/objects/api/v2/views.py b/src/objects/api/v2/views.py index 3ae11fa5..f6a4f8fc 100644 --- a/src/objects/api/v2/views.py +++ b/src/objects/api/v2/views.py @@ -4,6 +4,7 @@ from django.db import models, transaction from django.urls import reverse from django.utils.dateparse import parse_date +from django.utils.translation import gettext_lazy as _ import structlog from drf_spectacular.utils import ( @@ -17,18 +18,26 @@ 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.exceptions import ValidationError from rest_framework.generics import get_object_or_404 from rest_framework.response import Response +from rest_framework.settings import api_settings from vng_api_common.filters_backend import Backend as FilterBackend from vng_api_common.pagination import DynamicPageSizePagination from vng_api_common.search import SearchMixin +from objects.api.metrics import ( + objecttype_create_counter, + objecttype_delete_counter, + objecttype_update_counter, +) + 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 +from objects.core.constants import ReferenceType, ObjectTypeVersionStatus +from objects.core.models import Object, ObjectRecord, ObjectType, ObjectTypeVersion +from objects.token.models import Permission, TokenAuth +from objects.token.permissions import IsTokenAuthenticated, ObjectTypeBasedPermission from ..filter_backends import OrderingBackend from ..kanalen import KANAAL_OBJECTEN @@ -37,15 +46,24 @@ objects_delete_counter, objects_update_counter, ) -from ..mixins import GeoMixin, ObjectNotificationMixin +from ..mixins import GeoMixin, NestedViewSetMixin, ObjectNotificationMixin from ..serializers import ( HistoryRecordSerializer, ObjectSearchSerializer, ObjectSerializer, + ObjectTypeSerializer, + ObjectTypeVersionSerializer, PermissionSerializer, ) from ..utils import is_date -from .filters import DATA_ATTR_HELP_TEXT, DATA_ATTRS_HELP_TEXT, ObjectRecordFilterSet +from .filters import ( + DATA_ATTR_HELP_TEXT, + DATA_ATTRS_HELP_TEXT, + ObjectRecordFilterSet, + ObjectTypeFilterSet, +) + +logger = structlog.stdlib.get_logger(__name__) # manually override OAS because of "deprecated" attribute data_attrs_parameter = OpenApiParameter( @@ -68,6 +86,148 @@ logger = structlog.stdlib.get_logger(__name__) +@extend_schema_view( + retrieve=extend_schema(operation_id="objecttype_read"), + destroy=extend_schema(operation_id="objecttype_delete"), +) +class ObjectTypeViewSet(viewsets.ModelViewSet): + queryset = ObjectType.objects.prefetch_related("versions").order_by("-pk") + serializer_class = ObjectTypeSerializer + lookup_field = "uuid" + filterset_class = ObjectTypeFilterSet + pagination_class = DynamicPageSizePagination + permission_classes = [IsTokenAuthenticated] + + def perform_create(self, serializer): + super().perform_create(serializer) + obj = serializer.instance + token_auth: TokenAuth = self.request.auth + logger.info( + "objecttype_created", + uuid=str(obj.uuid), + name=obj.name, + token_identifier=token_auth.identifier, + token_application=token_auth.application, + ) + objecttype_create_counter.add(1) + + def perform_update(self, serializer): + super().perform_update(serializer) + obj = serializer.instance + token_auth: TokenAuth = self.request.auth + logger.info( + "objecttype_updated", + uuid=str(obj.uuid), + name=obj.name, + token_identifier=token_auth.identifier, + token_application=token_auth.application, + ) + objecttype_update_counter.add(1) + + def perform_destroy(self, instance): + if instance.versions.exists(): + raise ValidationError( + { + api_settings.NON_FIELD_ERRORS_KEY: [ + _( + "All related versions should be destroyed before destroying the objecttype" + ) + ] + }, + code="pending-versions", + ) + + super().perform_destroy(instance) + token_auth: TokenAuth = self.request.auth + logger.info( + "objecttype_deleted", + uuid=str(instance.uuid), + name=instance.name, + token_identifier=token_auth.identifier, + token_application=token_auth.application, + ) + objecttype_delete_counter.add(1) + + +@extend_schema_view( + retrieve=extend_schema( + operation_id="objecttypeversion_read", + description=_("Retrieve an OBJECTTYPE with the given version."), + ), + list=extend_schema( + operation_id="objecttypeversion_list", + description=_("Retrieve all versions of an OBJECTTYPE"), + ), + create=extend_schema( + operation_id="objecttypeversion_create", + description=_("Create an OBJECTTYPE with the given version."), + ), + destroy=extend_schema( + operation_id="objecttypeversion_delete", + description=_("Destroy the given OBJECTTYPE."), + ), + update=extend_schema( + operation_id="objecttypeversion_update", + description=_("Update an OBJECTTYPE with the given version."), + ), + partial_update=extend_schema( + operation_id="objecttypeversion_partial_update", + description=_("Partially update an OBJECTTYPE with the given version."), + ), +) +class ObjectTypeVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + queryset = ObjectTypeVersion.objects.order_by("object_type", "-version") + serializer_class = ObjectTypeVersionSerializer + lookup_field = "version" + pagination_class = DynamicPageSizePagination + permission_classes = [IsTokenAuthenticated] + + def perform_create(self, serializer): + super().perform_create(serializer) + obj = serializer.instance + token_auth = self.request.auth + logger.info( + "object_version_created", + version=str(obj.version), + objecttype_uuid=str(obj.object_type.uuid), + token_identifier=token_auth.identifier, + token_application=token_auth.application, + ) + + def perform_update(self, serializer): + super().perform_update(serializer) + obj = serializer.instance + token_auth = self.request.auth + logger.info( + "object_version_updated", + version=str(obj.version), + objecttype_uuid=str(obj.object_type.uuid), + token_identifier=token_auth.identifier, + token_application=token_auth.application, + ) + + def perform_destroy(self, instance): + if instance.status != ObjectTypeVersionStatus.draft: + raise ValidationError( + { + api_settings.NON_FIELD_ERRORS_KEY: [ + _("Only draft versions can be destroyed") + ] + }, + code="non-draft-version-destroy", + ) + + super().perform_destroy(instance) + token_auth = self.request.auth + logger.info( + "object_version_deleted", + version=str(instance.version), + objecttype_uuid=str(instance.object_type.uuid), + token_identifier=token_auth.identifier, + token_application=token_auth.application, + ) + + @extend_schema_view( list=extend_schema( description="Retrieve a list of OBJECTs and their actual RECORD. " diff --git a/src/objects/api/validators.py b/src/objects/api/validators.py index 69f394a1..52ca5c76 100644 --- a/src/objects/api/validators.py +++ b/src/objects/api/validators.py @@ -8,10 +8,25 @@ from objects.core.utils import check_objecttype_cached from objects.utils.client import get_objecttypes_client +from ..core.constants import ObjectTypeVersionStatus from .constants import Operators from .utils import merge_patch, string_to_value +class VersionUpdateValidator: + message = _("Only draft versions can be changed") + code = "non-draft-version-update" + requires_context = True + + def __call__(self, attrs, serializer): + instance = getattr(serializer, "instance", None) + if not instance: + return + + if instance.status != ObjectTypeVersionStatus.draft: + raise serializers.ValidationError(self.message, code=self.code) + + class JsonSchemaValidator: code = "invalid-json-schema" requires_context = True diff --git a/src/objects/conf/base.py b/src/objects/conf/base.py index 7a82fc62..8e7d92b0 100644 --- a/src/objects/conf/base.py +++ b/src/objects/conf/base.py @@ -37,6 +37,7 @@ "django.contrib.sites", # External applications. "rest_framework_gis", + "jsonsuit.apps.JSONSuitConfig", # Project applications. "objects.accounts", "objects.setup_configuration", diff --git a/src/objects/core/admin.py b/src/objects/core/admin.py index 9fd84cef..8eaf9784 100644 --- a/src/objects/core/admin.py +++ b/src/objects/core/admin.py @@ -1,54 +1,188 @@ +import json from typing import Sequence from django import forms from django.conf import settings -from django.contrib import admin +from django.contrib import admin, messages from django.contrib.admin import SimpleListFilter from django.contrib.gis.db.models import GeometryField -from django.http import HttpRequest, JsonResponse -from django.urls import path +from django.db import models +from django.http import HttpRequest, HttpResponseRedirect +from django.shortcuts import redirect, render +from django.urls import path, reverse +from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ -import requests import structlog +from jsonsuit.widgets import READONLY_WIDGET_MEDIA_CSS, READONLY_WIDGET_MEDIA_JS from vng_api_common.utils import get_help_text from objects.api.v2.filters import filter_queryset_by_data_attr -from objects.utils.client import get_objecttypes_client -from .models import Object, ObjectRecord, ObjectType +from .constants import ObjectTypeVersionStatus +from .forms import ObjectTypeVersionForm, UrlImportForm +from .models import Object, ObjectRecord, ObjectType, ObjectTypeVersion +from .widgets import JSONSuit logger = structlog.stdlib.get_logger(__name__) +def can_change(obj) -> bool: + if not obj: + return True + + if not obj.last_version: + return True + + if obj.last_version.status == ObjectTypeVersionStatus.draft: + return True + + return False + + +class ObjectTypeVersionInline(admin.StackedInline): + verbose_name_plural = _("last version") + model = ObjectTypeVersion + form = ObjectTypeVersionForm + extra = 0 + max_num = 1 + min_num = 1 + readonly_fields = ("version", "status", "published_at") + formfield_overrides = { + models.JSONField: { + "widget": JSONSuit, + "error_messages": { + "invalid": _("'%(value)s' value must be valid JSON"), + }, + } + } + + def get_queryset(self, request): + queryset = super().get_queryset(request) + parent_id = request.resolver_match.kwargs.get("object_id") + if not parent_id: + return queryset + + last_version = ( + queryset.filter(object_type_id=parent_id).order_by("-version").first() + ) + if not last_version: + return queryset.none() + return queryset.filter(id=last_version.id) + + def has_delete_permission(self, request, obj=None): + return False + + # work around to prettify readonly JSON field + def get_exclude(self, request, obj=None): + if not can_change(obj): + return ("json_schema",) + return super().get_exclude(request, obj) + + def get_readonly_fields(self, request, obj=None): + if not can_change(obj): + local_fields = [field.name for field in self.opts.local_fields] + # work around to prettify readonly JSON field + local_fields.remove("json_schema") + local_fields.append("json_schema_readonly") + return local_fields + + return super().get_readonly_fields(request, obj) + + def json_schema_readonly(self, obj): + return format_html( + '
{}
', + json.dumps(obj.json_schema, indent=2), + ) + + json_schema_readonly.short_description = "JSON schema" + + class Media: + js = READONLY_WIDGET_MEDIA_JS + css = READONLY_WIDGET_MEDIA_CSS + + @admin.register(ObjectType) class ObjectTypeAdmin(admin.ModelAdmin): - list_display = ( - "_name", - "uuid", - ) - readonly_fields = ("_name",) + list_display = ("name", "name_plural", "allow_geometry") + search_fields = ("name", "name_plural", "uuid") + inlines = [ObjectTypeVersionInline] + + change_list_template = "admin/core/objecttype/object_list.html" def get_urls(self): urls = super().get_urls() my_urls = [ path( - "/_versions/", - self.admin_site.admin_view(self.versions_view), - name="objecttype_versions", - ) + "import-from-url/", + self.admin_site.admin_view(self.import_from_url_view), + name="import_from_url", + ), ] return my_urls + urls - def versions_view(self, request, objecttype_id): - versions = [] - if objecttype := self.get_object(request, objecttype_id): - with get_objecttypes_client(objecttype.service) as client: - try: - versions = client.list_objecttype_versions(objecttype.uuid) - except (requests.RequestException, requests.JSONDecodeError) as exc: - logger.exception("objecttypes_api_request_failure", exc_info=exc) - return JsonResponse(versions, safe=False) + def get_readonly_fields(self, request, obj=None): + readonly_fields = super().get_readonly_fields(request, obj) + + if obj: + readonly_fields = ("uuid",) + readonly_fields + + return readonly_fields + + def publish(self, request, obj): + last_version = obj.last_version + last_version.status = ObjectTypeVersionStatus.published + last_version.save() + + msg = format_html( + _("The object type {version} has been published successfully!"), + version=obj.last_version, + ) + self.message_user(request, msg, level=messages.SUCCESS) + + return HttpResponseRedirect(request.path) + + def add_new_version(self, request, obj): + new_version = obj.last_version + new_version.pk = None + new_version.version = new_version.version + 1 + new_version.status = ObjectTypeVersionStatus.draft + new_version.save() + + msg = format_html( + _("The new version {version} has been created successfully!"), + version=new_version, + ) + self.message_user(request, msg, level=messages.SUCCESS) + + return HttpResponseRedirect(request.path) + + def response_change(self, request, obj): + if "_publish" in request.POST: + return self.publish(request, obj) + + if "_newversion" in request.POST: + return self.add_new_version(request, obj) + + return super().response_change(request, obj) + + def import_from_url_view(self, request): + if request.method == "POST": + form = UrlImportForm(request.POST) + if form.is_valid(): + form_json = form.cleaned_data.get("json") + + ObjectType.objects.create_from_schema( + json_schema=form_json, + name_plural=form.data.get("name_plural", "").title(), + ) + return redirect(reverse("admin:core_objecttype_changelist")) + else: + form = UrlImportForm() + + return render( + request, "admin/core/objecttype/object_import_form.html", {"form": form} + ) class ObjectRecordForm(forms.ModelForm): diff --git a/src/objects/core/constants.py b/src/objects/core/constants.py index da95e602..6160d339 100644 --- a/src/objects/core/constants.py +++ b/src/objects/core/constants.py @@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _ -class ObjectVersionStatus(models.TextChoices): +class ObjectTypeVersionStatus(models.TextChoices): published = "published", _("Published") draft = "draft", _("Draft") deprecated = "deprecated", _("Deprecated") diff --git a/src/objects/core/forms.py b/src/objects/core/forms.py new file mode 100644 index 00000000..a8b05fdb --- /dev/null +++ b/src/objects/core/forms.py @@ -0,0 +1,72 @@ +from json.decoder import JSONDecodeError + +from django import forms +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +import requests +from rest_framework import exceptions + +from ..api.validators import JsonSchemaValidator +from .models import ObjectTypeVersion + + +class UrlImportForm(forms.Form): + objecttype_url = forms.URLField( + label="Objecttype URL", + widget=forms.TextInput( + attrs={ + "placeholder": "https://example.com/boom.json", + "size": 100, + } + ), + required=True, + help_text=_("The direct URL for a given objecttype file (JSON)."), + ) + name_plural = forms.CharField( + label=_("Plural name"), + max_length=100, + required=True, + help_text=_("The plural name variant of the objecttype."), + ) + + def clean_objecttype_url(self): + url = self.cleaned_data["objecttype_url"] + + try: + response = requests.get(url) + except requests.exceptions.RequestException: + raise ValidationError("The Objecttype URL does not exist.") + + if response.status_code != requests.codes.ok: + raise ValidationError("Objecttype URL returned non OK status.") + + try: + response_json = response.json() + except JSONDecodeError: + raise ValidationError("Could not parse JSON from Objecttype URL.") + + json_schema_validator = JsonSchemaValidator() + + try: + json_schema_validator(response_json) + except exceptions.ValidationError as e: + raise ValidationError( + f"Invalid JSON schema. {e.detail[0]}.", code=e.detail[0].code + ) + + self.cleaned_data["json"] = response_json + + +class ObjectTypeVersionForm(forms.ModelForm): + class Meta: + model = ObjectTypeVersion + fields = "__all__" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Pass the initial value to the widget, this value is used in case + # the new value is invalid JSON which causes the widget to break + if "json_schema" in self.initial: + self.fields["json_schema"].widget.initial = self.initial["json_schema"] diff --git a/src/objects/core/management/commands/import_objecttypes.py b/src/objects/core/management/commands/import_objecttypes.py index f12b4dba..d7c16be4 100644 --- a/src/objects/core/management/commands/import_objecttypes.py +++ b/src/objects/core/management/commands/import_objecttypes.py @@ -44,7 +44,7 @@ def handle(self, *args, **options): objecttype_versions = client.list_objecttype_versions( objecttype.uuid ) - data = self._parse_objectversion_data( + data = self._parse_objecttypeversion_data( objecttype_versions, objecttype ) self._bulk_create_or_update_objecttype_versions(data) @@ -139,7 +139,7 @@ def _parse_objecttype_data( data.append(ObjectType(**underscoreize(objecttype))) return data - def _parse_objectversion_data( + def _parse_objecttypeversion_data( self, objecttype_versions: list[dict[str, Any]], objecttype ) -> list[ObjectTypeVersion]: data = [] diff --git a/src/objects/core/models.py b/src/objects/core/models.py index d550e960..894b3c4b 100644 --- a/src/objects/core/models.py +++ b/src/objects/core/models.py @@ -19,7 +19,7 @@ from .constants import ( DataClassificationChoices, - ObjectVersionStatus, + ObjectTypeVersionStatus, ReferenceType, UpdateFrequencyChoices, ) @@ -164,6 +164,17 @@ class Meta: def __str__(self): return f"{self.service.label}: {self.name or self._name}" + @property + def last_version(self): + if not self.versions: + return None + + return self.versions.order_by("-version").first() + + @property + def ordered_versions(self): + return self.versions.order_by("-version") + @property def url(self): # zds_client.get_operation_url() can be used here but it increases HTTP overhead @@ -224,8 +235,8 @@ class ObjectTypeVersion(models.Model): status = models.CharField( _("status"), max_length=20, - choices=ObjectVersionStatus.choices, - default=ObjectVersionStatus.draft, + choices=ObjectTypeVersionStatus.choices, + default=ObjectTypeVersionStatus.draft, help_text=_("Status of the object type version"), ) @@ -249,7 +260,7 @@ def save(self, *args, **kwargs): ObjectTypeVersion.objects.get(id=self.id).status if self.id else None ) if ( - self.status == ObjectVersionStatus.published + self.status == ObjectTypeVersionStatus.published and previous_status != self.status ): self.published_at = datetime.date.today() diff --git a/src/objects/core/query.py b/src/objects/core/query.py index 2a5005e5..0bafcfbf 100644 --- a/src/objects/core/query.py +++ b/src/objects/core/query.py @@ -5,11 +5,23 @@ class ObjectTypeQuerySet(models.QuerySet): - def get_by_url(self, url): + def get_by_url(self, url): # TODO remove service = Service.get_service(url) uuid = get_uuid_from_path(url) return self.get(service=service, uuid=uuid) + def create_from_schema(self, json_schema: dict, **kwargs): + object_type_data = { + "name": json_schema.get("title", "").title(), + "description": json_schema.get("description", ""), + } + object_type_data.update(kwargs) + objecttype = self.create(**object_type_data) + + objecttype.versions.create(json_schema=json_schema) + + return objecttype + class ObjectQuerySet(models.QuerySet): def filter_for_date(self, date): diff --git a/src/objects/core/tests/factories.py b/src/objects/core/tests/factories.py index 0dff91a4..2adc5843 100644 --- a/src/objects/core/tests/factories.py +++ b/src/objects/core/tests/factories.py @@ -12,13 +12,12 @@ from ..models import Object, ObjectRecord, ObjectType, ObjectTypeVersion, Reference - class ObjectTypeFactory(factory.django.DjangoModelFactory[ObjectType]): - service = factory.SubFactory(ServiceFactory) - uuid = factory.LazyFunction(uuid.uuid4) + service = factory.SubFactory(ServiceFactory) # TODO remove + uuid = factory.LazyFunction(uuid.uuid4) # TODO remove + _name = factory.Faker("word") # TODO remove name = factory.Faker("word") - _name = factory.LazyAttribute(lambda x: x.name) name_plural = factory.LazyAttribute(lambda x: f"{x.name}s") description = factory.Faker("bs") diff --git a/src/objects/core/widgets.py b/src/objects/core/widgets.py new file mode 100644 index 00000000..400048d0 --- /dev/null +++ b/src/objects/core/widgets.py @@ -0,0 +1,18 @@ +import json + +from jsonsuit.widgets import JSONSuit as _JSONSuit + + +class JSONSuit(_JSONSuit): + initial = dict() + + def render(self, name, value, attrs=None, renderer=None): + if attrs is None: + attrs = {} + try: + json.loads(value) + except ValueError: + # The supplied value is not valid JSON, use the original value as + # a fallback + value = json.dumps(self.initial) + return super().render(name, value, attrs) diff --git a/src/objects/fixtures/demodata.json b/src/objects/fixtures/demodata.json index 4ebd82c8..63d5434e 100644 --- a/src/objects/fixtures/demodata.json +++ b/src/objects/fixtures/demodata.json @@ -900,33 +900,1711 @@ "model": "core.objecttype", "pk": 1, "fields": { - "service": [ - "objecttypen-api" - ], "uuid": "feeaa795-d212-4fa2-bb38-2c34996e5702", - "_name": "Boom" + "name": "Boom", + "name_plural": "Bomen", + "description": "", + "data_classification": "open", + "maintainer_organization": "Gemeente Delft", + "maintainer_department": "", + "contact_person": "Jan Eik", + "contact_email": "", + "source": "", + "update_frequency": "unknown", + "provider_organization": "", + "documentation_url": "", + "labels": {}, + "created_at": "2020-12-01", + "modified_at": "2020-12-01" } }, { "model": "core.objecttype", "pk": 2, "fields": { - "service": [ - "objecttypen-api" - ], "uuid": "3a82fb7f-fc9b-4104-9804-993f639d6d0d", - "_name": "Straatverlichting" + "name": "Straatverlichting", + "name_plural": "Straatverlichting", + "description": "", + "data_classification": "open", + "maintainer_organization": "Maykin Media", + "maintainer_department": "", + "contact_person": "Desiree Lumen", + "contact_email": "", + "source": "", + "update_frequency": "unknown", + "provider_organization": "", + "documentation_url": "", + "labels": {}, + "created_at": "2020-12-01", + "modified_at": "2020-12-01" } }, { "model": "core.objecttype", "pk": 3, "fields": { - "service": [ - "objecttypen-api" - ], "uuid": "ca754b52-3f37-4c49-837c-130e8149e337", - "_name": "Melding" + "name": "Melding", + "name_plural": "Meldingen", + "description": "", + "data_classification": "intern", + "maintainer_organization": "Dimpact", + "maintainer_department": "", + "contact_person": "Ad Alarm", + "contact_email": "", + "source": "", + "update_frequency": "unknown", + "provider_organization": "", + "documentation_url": "", + "labels": {}, + "created_at": "2020-12-01", + "modified_at": "2020-12-01" + } +}, +{ + "model": "core.objecttypeversion", + "pk": 1, + "fields": { + "object_type": 1, + "version": 1, + "created_at": "2020-11-14", + "modified_at": "2020-11-16", + "published_at": "2020-11-16", + "json_schema": { + "type": "object", + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": [ + "diameter" + ], + "properties": { + "diameter": { + "type": "integer", + "description": "Size in cm." + }, + "plantDate": { + "type": "string", + "format": "date", + "description": "Date the tree was planted." + } + } + }, + "status": "published" + } +}, +{ + "model": "core.objecttypeversion", + "pk": 2, + "fields": { + "object_type": 1, + "version": 2, + "created_at": "2020-12-01", + "modified_at": "2020-12-01", + "published_at": "2020-12-01", + "json_schema": { + "$id": "https://objecttypes.vng.cloud/schema.json", + "type": "object", + "title": "Boom", + "$schema": "http://json-schema.org/draft-07/schema", + "default": {}, + "examples": [], + "required": [], + "properties": { + "type": { + "$id": "#/properties/type", + "enum": [ + "Haag", + "Fruitteelt", + "Buitenfitness", + "Betonverharding", + "Dilatatievoegovergang", + "Beheervak - brug", + "Dienstgang", + "Vacumpompstation", + "Putschacht", + "Beregeningspomp", + "Behendigheidstoestel", + "Laadperron", + "Schroefpomp", + "Erfafscheidingsput", + "Waaierdeur", + "Kast", + "Trottoirband", + "Hoogspanningskabel", + "Dubbelkerende afsluiter", + "Spijlenhek", + "Met instroomvoorziening", + "Palen met draad", + "Doelwand", + "Faunatunnel groot", + "Hondenpoepbak", + "Waterloop", + "Aansluitleiding", + "Werk in uitvoering-bord", + "Palen met planken", + "Schutsluis", + "Muur met hek", + "Natuurlijke elementen", + "Informatief verkeersbord", + "Speciale bank", + "Sinusvormige verkeersdrempel", + "Verzamelput", + "Gegraven tunnel", + "Stobbe", + "Boomteelt", + "Verkeersplateau", + "Terreindeel", + "Heesters", + "Afsluiter rioolleiding", + "Sportcombinatietoestel", + "Overgangsconstructie voor integraal kunstwerk", + "Klimtoestel", + "Volleybalset", + "Geleidebarrier", + "Boomrooster", + "Familiegraf", + "Sandwichconstructie", + "Spoor", + "Enkele bak", + "Schommel", + "Eenvoudige picknicktafel", + "Overdekte bank", + "Laagspanningskabel", + "Trottoirkolk", + "Hefdeur", + "Gecombineerde straat-trottoirkolk", + "Spiraal gegolfd stalen duikerbuizen", + "Toldeur", + "Beheervak - tunnel", + "Planken beschoeiing", + "Lozingspunt", + "Faunatunnel klein", + "Atletiekbaan", + "Basketbalbord", + "Boom niet vrij uitgroeiend", + "Drinkwatermeter", + "Struikrozen", + "Boomkratten", + "Tunnelobject", + "Middenspanningskabel", + "Infiltratiekolk", + "Veerooster", + "Nestkast voor zoogdieren", + "Bebakeningselement", + "Blauwe spiegel", + "Fruitboom", + "Heide", + "Stedenbandbord", + "Drukrioleringspomp", + "Solitair gras", + "Thematische picknicktafel", + "Rijrichtingbord", + "Drijvende mat", + "Veldafscheiding", + "Verlichtingsobject", + "Picknicktafel zeshoekig", + "Sensor", + "Septictank", + "Zuigerpomp", + "Planten", + "Bedekt", + "Eigen bouw", + "Grote sproeier", + "Ontstoppingsput", + "Parcours", + "Basket", + "Technische gang", + "Houten beschoeiing", + "Afsluiter beregeningsleiding", + "Centrifugaalpomp", + "Damtafel", + "Fietsbeugel", + "Onderbord", + "Sluiswachterskantoor", + "Toegangspoort", + "Standaard reflector", + "Fitnesstoestel", + "Winterverblijf amfibien", + "Avontuurlijke speelplek", + "Hoog raster", + "Reinigende put", + "Opruimplicht hondenpoep", + "Beheervak - gemaal", + "Signaleringsband", + "Onverhard", + "Balspelterrein", + "Kwelder", + "Wervelput", + "Hoekblok", + "Lamellenvoegovergang", + "Stootband", + "Gemaal in natte opstelling", + "Waterinrichtingsobject", + "Lijnmarkering", + "Zinloos geweld tegel", + "Keermuur met bank", + "Meubilair", + "Bouwspeelplaats", + "JongerenOntmoetingsPlek", + "Greppel", + "Waterspeeltoestel", + "Lozingsput", + "Vrijverval rioolleiding", + "Overbruggingsobject", + "Beheervak - verkeersregelinstallatie", + "Duikelrek fitness", + "Vingervoegovergang", + "Grasland agrarisch", + "Scheiding", + "Rijstrook", + "Puntmarkering", + "Voorrangsbord", + "Puntdeur", + "Steilwand", + "Dubbele bak", + "Skateboardbaan", + "Externe overstortconstructie", + "Schampkant", + "Vluchtgang", + "Matten", + "Jeu de Boules", + "Dwarsgang", + "Natte pompkelder", + "Basketbalpaal", + "Meervoudige voegovergang", + "Onderwaterbeschoeiing", + "Schaaktafel", + "Tafeltennistafel", + "Persluchtpomp", + "Graft", + "Boombank", + "Mechanische transportleiding", + "Oorlogsgraf", + "Boom vrij uitgroeiend", + "Oeverzwaluwenwand", + "Leidingelement", + "V-polder", + "Wildspiegel op voet", + "Drijvende bak", + "Overnamepunt", + "Straatkolk", + "Kabelbaan", + "Zandvlakte", + "Zandspeeltoestel", + "Kunststofverharding", + "Leiboom", + "Duikelrek", + "Hondenrooster", + "Midgetgolfbaan", + "Bebouwde kombord", + "Doorspuitput", + "Knotboom", + "Halfverharding", + "Zinkerput", + "Schuine trottoirband", + "Turntoestel", + "Educatietoestel", + "Verbodsbord", + "Verholen goot", + "Buishek", + "Huisvuilcontainerplaats", + "Geleidehek", + "Schotbalk", + "Pompput", + "Roldeur", + "Klimplant", + "Skatevoorziening", + "Uitneembare brug", + "Put", + "Kruisingsput", + "Dynamische snelheidsindicator", + "Glijbaan", + "Fietscrossbaan", + "Vlakmarkering", + "Waterspeelplaats", + "Hek Verre Veld", + "Keersluis", + "Piramideblok", + "Vegetatieobject", + "Fietsabri", + "Trapeziumvormige verkeersdrempel", + "Bijzondere putconstructie", + "Gras- en kruidachtigen", + "GVC beschoeiing", + "GRIP", + "Rioolput met geleiding", + "Buispaal", + "Mattenvoegovergang", + "Bomengranulaat", + "Fietsenrek", + "Beweegbare brug", + "Bord", + "Installatie", + "Flexibele voegovergang", + "IJsvogelwand", + "Kleine sproeier", + "Wanden dak methode tunnel", + "Groenobject", + "IBA", + "Mast", + "Onbedekt", + "Voorwaarschuwingsbord", + "Rimob", + "Backstop", + "Leiplant", + "Wand", + "Standaard", + "Beluchtingsrooster", + "Buffer", + "Elementenverharding", + "Toegangshekinstallatie", + "Rietland", + "Struiken", + "Interne overstortconstructie", + "Uitlaatpunt", + "Beheervak - sluis", + "Begroeid", + "Overstortput", + "Verkeerstegel", + "Afsluiter gasleiding", + "Stuwput", + "Schampblok", + "Grondwatermeter", + "Doorspoelput", + "Staafmathek", + "Poef", + "Bouwwerk", + "Combinatietoestel", + "Visoverwinteringsplek", + "Skateterrein", + "Voetbalveld", + "Roostergoot", + "Duiventil", + "Atletiekvoorziening", + "Perceelaansluitpunt", + "Zandspeelplaats", + "Boomkorf", + "Gaashek", + "Bouwland", + "Boom", + "Tennisbaan", + "Flespaal", + "Infiltratiebassin", + "Bomenzand", + "Reddingsboei", + "Asbaktegel", + "Kunstwerk", + "Eenzijdig kerende afsluiter", + "Gemengd bos", + "Winterverblijf algemene amfibien en kamsalamder", + "Net", + "Vrijverval transportleiding", + "Weginrichtingsobject", + "FunctioneelGebied", + "Watervogels", + "Basaltblokken", + "Nooduitlaat", + "Opsluitband", + "Straatbank", + "Gemaal in droge opstelling", + "DRIP", + "Duin", + "Rijbaan", + "Ijsbaan", + "Reddingshaak", + "Biofilter", + "Doel", + "Klimklauterparcours", + "Brugwachterskantoor", + "Grondwatermeetpunt", + "Wiptoestel", + "Bodembedekkers", + "Laag raster", + "Geleiderail", + "Boombumper", + "Solitaire heester", + "Gierzwaluwtil", + "Bomengrond", + "Moeras", + "Bushalteband", + "Water over weg", + "Slik", + "Adoptiebak", + "Verdekte put", + "Afsluiter waterleiding", + "Fietsklem", + "Vijzelgemaal", + "Afgezonken tunnel", + "Boomjuk", + "Tennisbaanafrastering", + "Berging", + "Leiding", + "Gekandelaberde boom", + "Boostergemaal", + "Gazonband", + "Fietssleuf", + "Trimbaan", + "Nestkast voor vogels", + "Busvriendelijke verkeersdrempel", + "Draaitoestel", + "Paal", + "Velddrain", + "Verbindingsstuk", + "Binnenterrein", + "Microtunneling,", + "Perkoen", + "Fietssteun", + "Rij-ijzer", + "Motorfietsdrempel", + "Zeecontainer", + "Knikkertegel", + "Wildrooster", + "Schijnvoeg", + "Schroefcentrifugaalpomp", + "Paaltje (Amsterdammertje)", + "Winterverblijf rugstreeppad", + "Bromfietsdrempel", + "Korfbalpaal", + "Opbouwputtunnel", + "Water over water", + "Zinkvoeg", + "Weg", + "Waterobject", + "Fietsenkluis", + "Rood wit paaltje (toegangsbeperking)", + "Vaste brug", + "Houtwal", + "Geleideband", + "Monsternamepunt", + "Wegobject", + "Schoolspeelplaats", + "Groot wild", + "Overkapping", + "Loofbos", + "Thematische bank", + "Handbediende slagboom", + "Vijzelpomp", + "Riooleindgemaal", + "Zitmuur", + "Keermuur met plantenbak", + "Verwijsbord", + "Mechanische rioolleiding", + "Waarschuwingsbord", + "Vormboom", + "Geboorde tunnel", + "Speeltuin", + "Sociaal spel", + "Speelkuil", + "Watervlakte", + "Draaiende reflector", + "Naaldbos", + "Bak", + "Omleidingsbord", + "Spuisluis", + "Vluchtdeur", + "Boombunker", + "Speelplek", + "Verborgen voegovergang", + "Grasland overig", + "Asfaltverharding", + "Voegovergang met of zonder balken en randprofielen met afdichtingrubbers", + "Elektrische slagboom", + "Bosplantsoen", + "Verzameldrain", + "Haltetegel", + "Inspectieput", + "Boomkrans", + "Parkeerbord", + "Winterverblijf slangen", + "Sierhek", + "Snelheidsbord", + "Geallieerdengraf", + "Fietstrommel" + ], + "type": "string", + "title": "Type", + "examples": [ + "Haag" + ], + "description": "Typering van het beheerobject." + }, + "kiemjaar": { + "$id": "#/properties/kiemjaar", + "type": "integer", + "title": "Kiemjaar", + "examples": [], + "description": "Kiemjaar van de boom.\nEenheid: Jaartal" + }, + "leeftijd": { + "$id": "#/properties/leeftijd", + "type": "integer", + "title": "Leeftijd", + "examples": [], + "description": "Leeftijd van het beheerobject in jaren.\nEenheid: Aantal" + }, + "typeplus": { + "$id": "#/properties/typeplus", + "enum": [ + "Grind Rail", + "Overhead Ladder", + "Draaiende stoeltjes (type A)", + "Klimladder", + "Steeple-chase waterbak", + "Dubbele draaibrug", + "Neststeen voor gierzwaluw", + "Drievoudig duikelrek", + "Spoelleiding", + "Waterrad", + "Natuursteen", + "Hoogspringbak", + "Twist en Wobble", + "Squat en Shoulder Press en Lat Pull Down", + "Vacumpompstation", + "Minidoel", + "Roll-In Ramp", + "Ramp", + "Zandbak", + "Mini Box", + "Step block", + "Combinatie - Peuter", + "Type 4 - Meer assen - 1 richting", + "Bootcamp Box en Gear", + "Evenwichtsplateau op veren", + "Pull up bars", + "Sierbestrating", + "Driekhoeksmarkering", + "Infiltratiegreppel", + "Balance beam", + "Speelhuis", + "Nestkast bosuil", + "Nestkast koolmees", + "Type 2 - Rotatie op meerdere assen", + "Triple Bars", + "Betonelement", + "Rietvegetatie", + "Zandtransporttoestel", + "Suspension trainer", + "Cross Trainer", + "Fijne sierheester", + "Botanische rozen", + "Street Workout, Parkour", + "Parkeervak", + "Ruw gras", + "Hellingklimmer", + "Log hop", + "Nestkast boomklever", + "Kliedertafel", + "Chest Press en Horizontal Row", + "Kunstgras", + "Verspringbak", + "Heesters", + "Split Quarter Pipe", + "Ophaalbrug", + "Hurdles", + "Loopbrug", + "Opdrukken", + "Tafeltennistafel rond", + "Lo-Box", + "Hobbelbrug", + "Klaphek", + "Pontje", + "Klimpaal", + "Speelschip", + "Speeltrein", + "Bodembedekkende heesters", + "Overgangsstuk", + "Perceelaansluitleiding", + "Rek- en strekbrug", + "Onderbroken brede streep", + "Weide", + "Body Flexer", + "Ven", + "Gebogen evenwichtsbalk", + "Ballentrechter", + "Behendigheidsparcours", + "Midi Ramp", + "Vertikale ladder", + "Leunhek", + "Wateremmer", + "Nestkast grote bonte specht", + "Bandenloop", + "Gewone normale vaste brug", + "Kogelstotenbak", + "Cultuurrozen", + "Start Box", + "Multi net", + "Zandspeelhuis", + "Loopton", + "Upperbody Trainer - Free Runner - Body Flexer", + "Grasveld", + "Angle Box", + "Struikvormers", + "Type 3B - Meerpunts - Meerdere richtingen", + "Toroveld", + "Verbeterde overstortput", + "Bergingsleiding", + "Wobble en Step", + "Y-stuk", + "Core twist", + "Vast", + "Dubbele taludkabelbaan", + "Wijngaarden", + "Vollegrondsteelt", + "Zonemarkering", + "Hangelduo", + "Instructiebord", + "Big Wedge", + "Drijvende brug", + "Getalmarkering", + "Duo ab - bench en ladder", + "Free Runner - Cross Trainer - Power bike", + "Doorgetrokken en dubbele smalle strepen", + "Nestkast pimpelmees", + "Liggerbrug", + "Step", + "Enterladder", + "Tafeltennistafel vierkant", + "Nestkast boomkruiper", + "Zesvoudig duikelrek", + "Klauterparcours", + "Doorgetrokken brede streep", + "Square Pull Up Station", + "Planten", + "Enkel duikelrek", + "Stapstenen", + "Nestkast zwarte roodstaart", + "Tafelvoetbaltafel", + "Taludglijbaan - type 2", + "Buik- en rugsteun", + "Zand", + "Schuifhek", + "Jump pod", + "Mobile bar", + "Fitnesstoestel", + "Hefbrug", + "Persleiding", + "Parkeerplaats", + "Infiltratieriool", + "Beek", + "Box Ramp", + "Over Under", + "Upright Row en Press Down", + "Natte heide", + "Afgekruist vlak", + "Nestkast roodborst", + "Zandkraan", + "Hexagon Pull Up Station", + "Verkeersdruppel", + "Bootcamp & Circuit Training", + "Zandspeeltafel", + "Klimrek", + "Type 2B - Enkelpunts - Meerdere richtingen", + "Ruimtenet", + "Frame", + "Brug", + "Monkey bar", + "Trapschot", + "Stappalen", + "Type 5 - Zweefwip", + "Verplaatsbaar", + "Vleermuiskast rosse vleermuis", + "Watertappunt", + "Wobble en Swing en Step en Twist", + "Zigzag-markering", + "Kunststof vloer", + "Rolverbinding", + "Dubbelstaafmathek", + "Griend en hakhout", + "Verloopstuk", + "Zwarte grond", + "Tractorband", + "Klapbrug", + "Mini Ramp", + "Jurytrap", + "Handrail Box", + "Kooi", + "Push up bars", + "Pijlmarkering", + "Kanaal", + "Bloemrijk gras", + "Coping", + "Kruipbuis", + "Gecombineerde glijbaan - type 1", + "Ster klim-duikelrek combinatie", + "Double Chest Press", + "Speelauto", + "Grondwaterpomp", + "Fietsparkeervak", + "Spine", + "Type 3 - Rotatie om 1 punt", + "Pannaveld", + "Twist en Swing", + "Nestkast Marter", + "Corner Ramp", + "Netbrug oversteek", + "Pants Driveway", + "Verstopschotten", + "Zandverstuiving", + "Curved Grind Rail", + "Dip Bench", + "Hangtouwen", + "Vakwerkbrug", + "Vijver", + "Beachvolleybalveld", + "Nestkast gierzwaluw", + "Decline Bench", + "Duo pull up bar en ladder", + "Dakboom", + "Enterrek", + "Betonstraatstenen", + "Molens op spoor met voet of hand aangedreven (type D)", + "Gaybrapad", + "Nestkast steenuil", + "Triple Ramp Grinder", + "Vleermuiskast gewone dwergvleermuis", + "Wall with Net", + "Klimgordijn", + "Sit up bench - Power Bike", + "Grove sierheester", + "Open duinvegetatie", + "Externe overstortput", + "Laagstam boomgaarden", + "Crank", + "Droge heide", + "Hoogstam", + "Wiebelbrug", + "Evenwichtsbalk", + "Dichte deklagen", + "Plasberm", + "Stammentrap", + "Voetbaldoelnet", + "Thematische basketbalpaal", + "Klimmuur/ladder combi", + "Trekvaste koppeling", + "Klassieke draaimolen met meedraaiende vloer (type B)", + "Hinkelbaan", + "Quad Box", + "Dubbele basculebrug", + "Verkeersbord", + "Hemelwaterriool", + "Side Panel", + "Turnbrug", + "Lijnvormige haag", + "Fietssymbool", + "Fun box", + "Upperbody Trainer", + "Bochtstuk", + "Enkelvoudige kabelbaan", + "Fly box", + "Bench", + "Platform", + "Pirouette", + "Suspension Trainer, Parallel Bars & Magnetic Bells Link", + "Vogelvide", + "Los", + "Jump Box", + "Speelpaneel", + "Speelplatform", + "Basketbalterrein", + "Valdempend gras", + "Rolbrug", + "Volleybalveld", + "Discuskooi", + "Flensverbinding", + "Double Turbo Challenge", + "Gras- en kruidachtigen", + "Wobble en Swing", + "Ollie Jump", + "Parallel bar", + "Type 2A - Enkelpunts - 1 richting", + "Combi Step", + "Rondobollen", + "Cross Training, Street Workout 130m_", + "Balanceernet", + "Heesterrozen", + "Poel", + "Metaal", + "Praatpaal", + "Bomen en struikvormers", + "Rodeo stier", + "Vrijstaande glijbaan - type 2", + "Fitness Bike", + "Verdrijfstrepen", + "Ballenpaal", + "Basket en doel", + "Meerdelig duikelrek", + "Hoogstam boomgaarden", + "Small Wedge", + "Natuurlijke oeverzwaluwwand", + "Rivier", + "Chill schijf", + "Telefoonpaal", + "Thematisch evenwichtsplateau op veren", + "Bodembedekkende rozen", + "Crosstrainer", + "Waterwip", + "Bokspringpaal", + "Flex Wheel - Body Flexer", + "Magnetic bells, suspension trainer en multi net link", + "Blokboom", + "Workout combination", + "Boomstam", + "Nestkast spreeuw", + "Hand Bike", + "Stretch Bar", + "Circuit Training", + "Steunbrug", + "Magnetic bells", + "Nestkast Eekhoorn", + "Tuibrug", + "Sit up bench", + "Trapoefenwand", + "Plas", + "Loopvlonder", + "Open grond", + "Lasverbinding", + "Drievoudig duikelrek gebogen", + "Enkelvoudige taludkabelbaan", + "Boogbrug", + "Cross Training, Circuit Training, Bootcamp, Street Workout 256m_", + "Gesloten duinvegetatie", + "Type 1 - Wip - 1 richting", + "Vaste planten", + "Bedrijfsaansluitleiding", + "Ruigte", + "Twist en Step", + "Grind Bench", + "Type 6 - Schommelwip met enkelvoudige hoge as", + "Geknipte boom", + "Klimnet met duikelrekken", + "Enkelstaafmathek", + "Nestkast bonte vliegenvanger", + "Kolkaansluitleiding", + "Gazon", + "Pompunit", + "Flat Bank", + "Drukleiding", + "Overige markering", + "Klimwand", + "T-stuk", + "Vouwhek", + "Double Overhead Ladder", + "Ongewapend verdeuveld beton", + "Bolboom", + "Zandgraver", + "Pleinplakker", + "Grind Box", + "Draaihek", + "Transportrioolleiding", + "Hout", + "Trainingsdoeltje", + "Pull up bars, parallel bars & multi net link", + "Voethek", + "Kunstmatige oeverzwaluwwand", + "Taludglijbaan - type 1", + "Roll-off Ramp", + "Glas", + "Draaiende evenwichtsbalk", + "Schudzeef", + "Strip", + "Boomvormers", + "Flat Bank with Platform", + "Braakliggend", + "Bron", + "Voetbaldoel", + "Ongewapend nietverdeuveld beton", + "Haven", + "Tuinbouwgrond", + "Supernova", + "Puzzelbord", + "Zoab en open deklagen", + "Ruig gras", + "Zandstransportband", + "Archimedesspiraal", + "Container", + "Zinker", + "Net", + "Type 4 - Contactschommel", + "Glijverbinding", + "Combinatie van een smalle doorgetrokken en een smalle onderbroken streep", + "High rotator", + "Horden", + "Doel P-model", + "Schraalgrasland", + "Ingemetselde nestkast", + "Turnparcours", + "JOP", + "Plaatbrug", + "Flex Wheel", + "Bollenteelt", + "Chin up", + "Ringenrek met balanceertouw", + "Pendelwaag", + "Hang- en zweefmolens (type C)", + "Street Spine", + "Balansvorm", + "Straatbaksteen", + "Hellende enterladder", + "Cladding", + "Enkelvoudige platformkabelbaan", + "Evenwichtsparcours", + "Tegels", + "Vacumleiding", + "Bodembedekkende vaste planten", + "Ollie Hurdle", + "Volcano", + "Bodembedekkers", + "Touwbalans", + "Speeltafel", + "Touwbrug", + "Talud verkeersdrempel", + "Touwduikelrek", + "Gracht", + "Combi", + "Gecombineerde glijbaan - type 2", + "Quarterpipe", + "Lijmverbinding", + "Zee", + "Sloot", + "Tuinachtige grond", + "Vuilwaterriool", + "Kogelslingerkooi", + "Half Pipe", + "Draaibrug", + "Woordmarkering", + "Dubbele platformkabelbaan", + "Nestkast winterkoning", + "Wiebelplaat", + "Akkerbouw", + "Frame klimtoestel", + "Body Flexer - Upperbody Trainer", + "Looptouw", + "Helmgras", + "Hink-stapspringbak", + "Speelboot", + "Springkussen", + "Geschoren boom", + "Stuwrioolleiding", + "Nestkast torenvalk", + "Dip - bar", + "Trampoline", + "Strand en strandwal", + "Duikerbrug", + "Interne overstortput", + "Ladder", + "Frame & net", + "Voorwaarschuwingsdriehoek", + "Drievoudig duikelrek zigzag", + "Free Runner - Cross Trainer", + "Gewapend beton", + "Bootcamp Base", + "Samenhangend", + "Zwevende evenwichtsbalk", + "Oppervlakbehandelingen", + "Bergbezinkleiding", + "Rioolstreng", + "Flat Ramp", + "Take Off Ramp", + "Goot", + "Onderbroken smalle streep", + "Basculebrug", + "Optrekken", + "Panel", + "Steps", + "Pull up Station", + "Combinatie - Kleuter", + "Monkey bar extended", + "Dubbele ophaalbrug", + "Shaped Grind Rail", + "Parallel bars", + "Push up bars met paal", + "Kindertafel", + "Laagstam", + "Verspring- en hinkstapspringbak", + "Draaischijf (type E)", + "Piramidevorm", + "Touw tornado", + "ZinloosGeweldMarkering", + "Step en Swing", + "Vrijstaande glijbaan - type 1", + "Jongerenbank", + "Slinger-klim-entercombi", + "Vormhaag", + "Hangbrug", + "Speelspoor", + "Speelstoel en tafel", + "Nestkast huismus", + "Springplank", + "Moerasvegetatie", + "Type 1 - Rotatie om 1 as", + "Polsstokhoogspringbak", + "Draaimolen", + "Overstortleiding", + "Incline Press", + "Boter-kaas-eieren", + "Palenwoud", + "Waterpomp", + "Klimschans", + "Vlot", + "Dubbele kabelbaan", + "Rear Panel", + "Gemengd riool", + "Hockeydoel", + "Free Runner", + "Planter for Steps", + "Waterglijbaan", + "Jump Ramp", + "Pyramid", + "Combinatie - Kind", + "Doorgetrokken smalle streep", + "Wisselperken", + "Natuurlijke grasvegetatie", + "Wall Ride", + "Blokhaag", + "Meer", + "Draadcircus", + "Puntstukken en witte vlakken", + "Klein fruit", + "Power Bike", + "Type 3A - Meerpunts - 1 richting", + "Stammenstapel", + "Steunsprong", + "Wiebelloop", + "Zitpaal", + "Cross & Circuit Training" + ], + "type": "string", + "title": "TypePlus", + "examples": [ + "Grind Rail" + ], + "description": "Nadere typering van het type beheerobject." + }, + "verplant": { + "$id": "#/properties/verplant", + "type": "boolean", + "title": "Verplant", + "examples": [], + "description": "Aanduidig of het groen- of vegetatieobject verplant is." + }, + "boombeeld": { + "$id": "#/properties/boombeeld", + "enum": [ + "Niet van toepassing", + "Verwaarloosd boombeeld", + "Aanvaard boombeeld", + "Achterstallig boombeeld", + "Boombeeld regulier (HB)", + "Niet te beoordelen" + ], + "type": "string", + "title": "Boombeeld", + "examples": [ + "Niet van toepassing" + ], + "description": "Onderhoudssituatie van de boom." + }, + "boomgroep": { + "$id": "#/properties/boomgroep", + "enum": [ + "Laanboom", + "Boomweide", + "Solitaire boom" + ], + "type": "string", + "title": "Boomgroep", + "examples": [ + "Laanboom" + ], + "description": "Aanduiding of de boom onderdeel is van een boomgroep." + }, + "groeifase": { + "$id": "#/properties/groeifase", + "enum": [ + "Jeugdfase", + "Volwassen fase", + "Eindfase", + "Aanlegfase", + "Onbekend", + "Niet te beoordelen" + ], + "type": "string", + "title": "Groeifase", + "examples": [ + "Jeugdfase" + ], + "description": "Aanduiding van de groeifase van een boom.\nToelichting: Er is geen volledige eenduidigheid over de indeling, maar over het algemeen worden bij een boom zon 4 groeifasen onderscheiden. Het onderscheid is gebaseerd op de verschillen in beheermaatregelen." + }, + "snoeifase": { + "$id": "#/properties/snoeifase", + "enum": [ + "Begeleidingssnoeifase", + "Onbekend", + "Niet van toepassing", + "Onderhoudssnoeifase" + ], + "type": "string", + "title": "Snoeifase", + "examples": [ + "Begeleidingssnoeifase" + ], + "description": "Aanduiding van de snoeifase van de boom." + }, + "boomspiegel": { + "$id": "#/properties/boomspiegel", + "type": "string", + "title": "Boomspiegel", + "examples": [], + "description": "Wanneer een boomspiegel aanwezig is, wordt het GUID van het beheerobject Boomspiegel gekoppeld aan het object Boom.\nToelichting: Boomspiegel: afgebakend oppervlak rondom de stam van een boom, dat niet is ingeplant." + }, + "kroonvolume": { + "$id": "#/properties/kroonvolume", + "type": "integer", + "title": "Kroonvolume", + "examples": [], + "description": "Volume van de boomkroon in kubieke meters\nEenheid: m3" + }, + "meerstammig": { + "$id": "#/properties/meerstammig", + "type": "boolean", + "title": "Meerstammig", + "examples": [], + "description": "Aanduiding voor meerstammigheid bij een Boom" + }, + "transponder": { + "$id": "#/properties/transponder", + "type": "string", + "title": "Transponder", + "examples": [], + "description": "Nummer of identificatie van een transponder op een beheerobject." + }, + "vrijetakval": { + "$id": "#/properties/vrijetakval", + "enum": [ + "Onbekend", + "Geen vrije takval mogelijk", + "Vrije takval mogelijk" + ], + "type": "string", + "title": "VrijeTakval", + "examples": [ + "Onbekend" + ], + "description": "Aanduiding of vrije takval is toegestaan." + }, + "stamdiameter": { + "$id": "#/properties/stamdiameter", + "type": "integer", + "title": "Stamdiameter", + "examples": [], + "description": "Aanduiding voor de diameter van de stam.\nEenheid: cm" + }, + "takvrijestam": { + "$id": "#/properties/takvrijestam", + "enum": [ + "0 m.", + "Anders, namelijk", + "4 m.", + "2 m.", + "8 m.", + "6 m.", + "Onbekend", + "Niet te beoordelen" + ], + "type": "string", + "title": "TakvrijeStam", + "examples": [ + "0 m." + ], + "description": "De benodigde takvrije stam in het eindbeeld, gemeten vanaf maaiveld tot aan de onderste gesteltak.\nEenheid: m\nToelichting: Takvrije stam: gedeelte van de stam, gemeten vanaf maaiveld tot aan eerste gesteltak. Eindbeeld: vorm van een boom in volgroeide staat, omschreven door middel van takvrije zone en/of takvrije stam. Als synoniem wordt vaak gebruikt de opkroonhoogte: de verticaal gemeten vrije hoogte tussen maaiveld en kroon van de boom." + }, + "verplantbaar": { + "$id": "#/properties/verplantbaar", + "type": "boolean", + "title": "Verplantbaar", + "examples": [], + "description": "Aanduiding of de boom verplant kan worden." + }, + "beleidsstatus": { + "$id": "#/properties/beleidsstatus", + "enum": [ + "Structuurbepalend/hoofd(groen/bomen)structuur", + "Geen specifieke status-verkorte omloop (tot ca. 20 jaar) en bomen 3e grootte", + "Beschermwaardig/monumentaal", + "Geen specifieke status-functionele laan- en parkbomen" + ], + "type": "string", + "title": "Beleidsstatus", + "examples": [ + "Structuurbepalend/hoofd(groen/bomen)structuur" + ], + "description": "Beleidsstatus is een functiecategorie bomen conform de richtlijnen NVTB, t.b.v. de bepaling van de monetaire waarde." + }, + "boombeschermer": { + "$id": "#/properties/boombeschermer", + "type": "string", + "title": "Boombeschermer", + "examples": [], + "description": "Wanneer een boombeschermer aanwezig is, wordt het GUID van het beheerobject Boombeschermer gekoppeld aan het object Boom.\nToelichting: Constructie, meestal van metaal, rondom het onderste gedeelte van de stam, bedoeld ter bescherming van de stam." + }, + "herplantplicht": { + "$id": "#/properties/herplantplicht", + "type": "boolean", + "title": "Herplantplicht", + "examples": [], + "description": "Aanduiding of er in het kader van de Wet Natuurbescherming sprake is van een herplantplicht." + }, + "feestverlichting": { + "$id": "#/properties/feestverlichting", + "type": "string", + "title": "Feestverlichting", + "examples": [], + "description": "Wanneer Feestverlichting aanwezig is, wordt het GUID van het beheerobject Feestverlichting gekoppeld aan het gekoppelde beheerobject." + }, + "beoogdeomlooptijd": { + "$id": "#/properties/beoogdeomlooptijd", + "enum": [ + "75-100 jaar", + "30-50 jaar", + ">200 jaar", + "50-75 jaar", + "20-30 jaar", + "100-150 jaar", + "< 10 jaar", + "Onbekend", + "10-20 jaar", + "150-200 jaar" + ], + "type": "string", + "title": "BeoogdeOmlooptijd", + "examples": [ + "75-100 jaar" + ], + "description": "De potentieel haalbare omlooptijd, in relatie tot de standplaats (schatting)." + }, + "boomhoogteactueel": { + "$id": "#/properties/boomhoogteactueel", + "type": "integer", + "title": "BoomhoogteActueel", + "examples": [], + "description": "Hoogte van de boom in meters.\nEenheid: m" + }, + "controlefrequentie": { + "$id": "#/properties/controlefrequentie", + "enum": [ + "TODO" + ], + "type": "string", + "title": "Controlefrequentie", + "examples": [ + "TODO" + ], + "description": "Aanduiding van de frequentie van de controle van het beheerobject.\nToelichting: \"De frequentie van de boomveiligheidscontrole. Dit is de periodieke visuele controle van een boom in het kader van de zorgplicht (voortkomend uit artikel 6:162 van het BW) ten behoeve van het vaststellen van een (potentieel toekomstig) risico dat de boom vormt voor zijn omgeving. \nDe term boomveiligheidscontrole heeft betrekking op het gehele proces om in het veld de benodigde gegevens te verkrijgen voor het logboek, zoals beschreven in de CROW Richtlijn Boomveiligheidsregistratie.\n\"" + }, + "stamdiameterklasse": { + "$id": "#/properties/stamdiameterklasse", + "enum": [ + "TODO" + ], + "type": "string", + "title": "Stamdiameterklasse", + "examples": [ + "TODO" + ], + "description": "Aanduiding van de diameter van de stam in diameterklassen." + }, + "vrijedoorrijhoogte": { + "$id": "#/properties/vrijedoorrijhoogte", + "enum": [ + "4,5 m. en groter", + "6,5 m. en groter", + "0 m.", + "2,5 m. en groter", + "Onbekend" + ], + "type": "string", + "title": "VrijeDoorrijhoogte", + "examples": [ + "4,5 m. en groter" + ], + "description": "De benodigde vrije ruimte tussen de weg of het fietspad, en de onderkant van de boomkroon of van een bouwwerk boven de weg, zoals een viaduct of tunnel.\nEenheid: m\nToelichting: Takvrije zone boven het wegdek en onder de kroon waar bestuurders van voertuigen vrije doorgang genieten tot een hoogte zoals is bepaald door de wegbeheerder." + }, + "boomboomvoorziening": { + "$id": "#/properties/boomboomvoorziening", + "enum": [ + "TODO" + ], + "type": "string", + "title": "BoomBoomvoorziening", + "examples": [ + "TODO" + ], + "description": "Mogelijkheid om 1 of meerdere boomvoorzieningen bij een boom te registreren." + }, + "monetaireboomwaarde": { + "$id": "#/properties/monetaireboomwaarde", + "type": "number", + "title": "MonetaireBoomwaarde", + "examples": [], + "description": "Monetaire waarde volgens richtlijnen NVTB.\nEenheid: " + }, + "takvrijezoneprimair": { + "$id": "#/properties/takvrijezoneprimair", + "type": "integer", + "title": "TakvrijeZonePrimair", + "examples": [], + "description": "De benodigde takvrije ruimte tussen de weg of het fietspad en de onderkant van de boomkroon (eindbeeld van de boom). Als aan beide zijden van de boom een weg en een fietspad ligt, wordt de takvrije ruimte boven de weg aangeduid met primair, en de takvrijke ruimte boven het fietspad met secundair.\nEenheid: m\nToelichting: Takvrije zone: vrije ruimte ten behoeve van verkeer of andere omgevingsfactoren." + }, + "boomveiligheidsklasse": { + "$id": "#/properties/boomveiligheidsklasse", + "enum": [ + "Niet te beoordelen", + "Boom zonder gebreken", + "Onbekend", + "Attentieboom", + "Risicoboom" + ], + "type": "string", + "title": "Boomveiligheidsklasse", + "examples": [ + "Niet te beoordelen" + ], + "description": "Aanduiding van de veiligheid van de boom, ingedeeld in vaste klassen.\nToelichting: Voor bosplantsoen met boomvormers is de mate van veiligheid van het beheerobject voor de omgeving relevant. Hiervoor is nog geen landelijk vastgestelde classificatie. Boomveiligheid: Aanduiding voor de veiligheid van personen, dieren, objecten en goederen in de nabijheid van een boom." + }, + "groeiplaatsinrichting": { + "$id": "#/properties/groeiplaatsinrichting", + "type": "string", + "title": "Groeiplaatsinrichting", + "examples": [], + "description": "Wanneer een groeiplaatsinrichting aanwezig is, wordt het GUID van het beheerobject Groeiplaatsinrichting gekoppeld aan het object Boom" + }, + "takvrijezonesecundair": { + "$id": "#/properties/takvrijezonesecundair", + "type": "integer", + "title": "TakvrijeZoneSecundair", + "examples": [], + "description": "De benodigde takvrije ruimte tussen het fietspad en de onderkant van de boomkroon (eindbeeld van de boom). Als aan beide zijden van de boom een weg en een fietspad ligt, wordt de takvrije ruimte boven de weg aangeduid met primair, en de takvrijke ruimte boven het fietspad met secundair.\nEenheid: m\nToelichting: Takvrije zone: vrije ruimte ten behoeve van verkeer of andere omgevingsfactoren." + }, + "typebeschermingsstatus": { + "$id": "#/properties/typebeschermingsstatus", + "enum": [ + "Monumentale boom", + "Geen beschermingsstatus", + "Rust - en of verblijfplaats fauna" + ], + "type": "string", + "title": "TypeBeschermingsstatus", + "examples": [ + "Monumentale boom" + ], + "description": "Aanduiding voor de speciale status van de boom." + }, + "typevermeerderingsvorm": { + "$id": "#/properties/typevermeerderingsvorm", + "enum": [ + "Veredeld", + "Eigen wortel", + "Onbekend", + "Gent", + "Gezaaid" + ], + "type": "string", + "title": "TypeVermeerderingsvorm", + "examples": [ + "Veredeld" + ], + "description": "Wijze waarop de plant of boom is vermeerderd." + }, + "boomhoogteklasseactueel": { + "$id": "#/properties/boomhoogteklasseactueel", + "enum": [ + "TODO" + ], + "type": "string", + "title": "BoomhoogteklasseActueel", + "examples": [ + "TODO" + ], + "description": "Aanduiding van de boomhoogte in meters ingedeeld in vaste klassen.\nToelichting: Boomhoogte in meters, ingedeeld in vaste klassen, gemeten vanaf het maaiveld tot de top van de boom, bij vormbomen gemeten tot de hoogste knot of de gewenste hoogte (Bron: RAW)" + }, + "takvrijeruimtetotgebouw": { + "$id": "#/properties/takvrijeruimtetotgebouw", + "type": "integer", + "title": "TakvrijeRuimteTotGebouw", + "examples": [], + "description": "De benodigde takvrije ruimte tussen het gebouw en de zijkant van de boom.\nEenheid: m" + }, + "boomhoogteklasseeindbeeld": { + "$id": "#/properties/boomhoogteklasseeindbeeld", + "enum": [ + "TODO" + ], + "type": "string", + "title": "BoomhoogteklasseEindbeeld", + "examples": [ + "TODO" + ], + "description": "Aanduiding van de boomhoogte van het eindbeeld, in meters ingedeeld in vaste klassen.\nToelichting: Boomhoogte in meters, ingedeeld in vaste klassen, gemeten vanaf het maaiveld tot de top van de boom, bij vormbomen gemeten tot de hoogste knot of de gewenste hoogte (Bron: RAW)" + }, + "typeomgevingsrisicoklasse": { + "$id": "#/properties/typeomgevingsrisicoklasse", + "enum": [ + "Hoog", + "Gemiddeld", + "Laag", + "Onbekend", + "Geen" + ], + "type": "string", + "title": "TypeOmgevingsrisicoklasse", + "examples": [ + "Hoog" + ], + "description": "Aanduiding van het omgevingsrisico van het beheerobject.\nToelichting: Classificering voor de intensiteit van het gebruik van de omgeving van een boom en daarmee de mate waarin de omgeving van de boom risicoverhogend is voor eventuele schade bij stambreuk, takbreuk of instabiliteit. Aanvullende informatie: vast te stellen door de boomeigenaar." + }, + "vrijedoorrijhoogteprimair": { + "$id": "#/properties/vrijedoorrijhoogteprimair", + "enum": [ + "4,5 m. en groter", + "6,5 m. en groter", + "0 m.", + "2,5 m. en groter", + "Onbekend" + ], + "type": "string", + "title": "VrijeDoorrijhoogtePrimair", + "examples": [ + "4,5 m. en groter" + ], + "description": "De benodigde vrije ruimte tussen de weg of het fietspad, en de onderkant van de boomkroon of van een bouwwerk boven de weg, zoals een viaduct. Als aan beide zijden van de boom een weg en een fietspad ligt, wordt de vrije doorrijhoogte boven de weg aangeduid met primair, en de takvrije ruimte boven het fietspad met secundair.\nEenheid: m" + }, + "kroondiameterklasseactueel": { + "$id": "#/properties/kroondiameterklasseactueel", + "enum": [ + "TODO" + ], + "type": "string", + "title": "KroondiameterklasseActueel", + "examples": [ + "TODO" + ], + "description": "Diameter van de kroon van de boom in meters ingedeeld in vaste klassen." + }, + "vrijedoorrijhoogtesecundair": { + "$id": "#/properties/vrijedoorrijhoogtesecundair", + "enum": [ + "4,5 m. en groter", + "6,5 m. en groter", + "0 m.", + "2,5 m. en groter", + "Onbekend" + ], + "type": "string", + "title": "VrijeDoorrijhoogteSecundair", + "examples": [ + "4,5 m. en groter" + ], + "description": "De benodigde vrije ruimte tussen de weg of het fietspad, en de onderkant van de boomkroon of van een bouwwerk boven de weg, zoals een viaduct. Als aan beide zijden van de boom een weg en een fietspad ligt, wordt de vrije doorrijhoogte boven de weg aangeduid met primair, en de takvrije ruimte boven het fietspad met secundair.\nEenheid: m" + }, + "kroondiameterklasseeindbeeld": { + "$id": "#/properties/kroondiameterklasseeindbeeld", + "enum": [ + "TODO" + ], + "type": "string", + "title": "KroondiameterklasseEindbeeld", + "examples": [ + "TODO" + ], + "description": "Diameter van de kroon van het eindbeeld van de boom in meters ingedeeld in vaste klassen." + }, + "boomtypebeschermingsstatusplus": { + "$id": "#/properties/boomtypebeschermingsstatusplus", + "enum": [ + "TODO" + ], + "type": "string", + "title": "BoomTypeBeschermingsstatusPlus", + "examples": [ + "TODO" + ], + "description": "Nadere aanduiding voor de speciale status van de boom." + } + }, + "description": "Een houtachtig gewas (loofboom of conifeer) met een wortelgestel en een enkele, stevige, houtige stam, die zich boven de grond vertakt.\nToelichting: Een houtachtig gewas (loofboom of conifeer) met een wortelgestel en een enkele, stevige, houtige stam, die zich boven de grond vertakt.", + "additionalProperties": false + }, + "status": "published" + } +}, +{ + "model": "core.objecttypeversion", + "pk": 3, + "fields": { + "object_type": 2, + "version": 1, + "created_at": "2020-12-01", + "modified_at": "2020-12-01", + "published_at": "2020-09-28", + "json_schema": { + "type": "object", + "title": "Straatverlichting", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": [ + "id_nummer" + ], + "properties": { + "id_nummer": { + "type": "integer", + "title": "ID-nummer", + "description": "Identificatienummer" + } + } + }, + "status": "published" + } +}, +{ + "model": "core.objecttypeversion", + "pk": 4, + "fields": { + "object_type": 3, + "version": 1, + "created_at": "2020-12-01", + "modified_at": "2020-12-01", + "published_at": "2020-10-02", + "json_schema": { + "type": "object", + "title": "Melding", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": [ + "description" + ], + "properties": { + "description": { + "type": "string", + "description": "Explanation what happened" + } + } + }, + "status": "published" + } +}, +{ + "model": "core.objecttypeversion", + "pk": 5, + "fields": { + "object_type": 3, + "version": 2, + "created_at": "2020-11-12", + "modified_at": "2020-11-27", + "published_at": "2020-11-27", + "json_schema": { + "type": "object", + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": [ + "description" + ], + "properties": { + "description": { + "type": "string", + "description": "Explanation what happened" + } + } + }, + "status": "published" + } +}, +{ + "model": "core.objecttypeversion", + "pk": 6, + "fields": { + "object_type": 2, + "version": 2, + "created_at": "2020-11-13", + "modified_at": "2020-11-27", + "published_at": null, + "json_schema": { + "type": "object", + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "height": { + "type": "integer", + "description": "height in meters" + } + } + }, + "status": "draft" + } +}, +{ + "model": "core.objecttypeversion", + "pk": 9, + "fields": { + "object_type": 3, + "version": 3, + "created_at": "2020-11-27", + "modified_at": "2020-11-27", + "published_at": "2020-11-27", + "json_schema": { + "type": "object", + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": [ + "description" + ], + "properties": { + "description": { + "type": "string", + "description": "Explanation what happened" + } + } + }, + "status": "draft" + } +}, +{ + "model": "core.objecttypeversion", + "pk": 10, + "fields": { + "object_type": 1, + "version": 3, + "created_at": "2021-01-12", + "modified_at": "2021-01-12", + "published_at": null, + "json_schema": { + "type": "object", + "title": "Monument", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": [ + "naam", + "kunstenaarsNaam" + ], + "properties": { + "bijschrift": { + "type": "string", + "description": "Bijschrift die de context van het monument verduidelijkt." + }, + "kunstenaar": { + "type": "string", + "format": "url", + "description": "URL naar de Natuurlijk Persoon in de BRP." + }, + "opleverdatum": { + "type": "string", + "format": "date", + "description": "Datum waarop het monument is onthuld." + }, + "kunstenaarsNaam": { + "type": "string", + "description": "Voor en achternaam van de kunstenaar." + } + } + }, + "status": "draft" } } ] diff --git a/src/objects/templates/admin/core/objecttype/object_history.html b/src/objects/templates/admin/core/objecttype/object_history.html new file mode 100644 index 00000000..bee875f1 --- /dev/null +++ b/src/objects/templates/admin/core/objecttype/object_history.html @@ -0,0 +1,35 @@ +{% extends "admin/object_history.html" %} +{% load i18n admin_urls %} + + +{% block content %} +
+
+ + + + + + + + + + + + + {% for version in object.ordered_versions %} + + + + + + + + + {% endfor %} + +
{% trans 'Version' %}{% trans 'Status' %}{% trans 'Created at' %}{% trans 'Modified at' %}{% trans 'Published at' %}{% trans 'JSON schema' %}
{{ version.version }}{{ version.get_status_display }}{{ version.created_at }}{{ version.modified_at }}{{ version.published_at }}{{ version.json_schema }}
+ +
+
+{% endblock %} diff --git a/src/objects/templates/admin/core/objecttype/object_import_form.html b/src/objects/templates/admin/core/objecttype/object_import_form.html new file mode 100644 index 00000000..709b9170 --- /dev/null +++ b/src/objects/templates/admin/core/objecttype/object_import_form.html @@ -0,0 +1,37 @@ +{% extends 'admin/change_form.html' %} +{% load i18n admin_urls static %} + +{% block title %} {% trans "Import objecttype" %} {{ block.super }} {% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

{% trans 'Import from URL' %}

+
+
+ {% csrf_token %} +
+ {% for field in form %} +
+ {{ field.errors }} + + {{ field }} + {% if field.field.help_text %}  +
{{ field.field.help_text }}
+ {% endif %} +
+ {% endfor %} +
+
+ +
+
+
+
+{% endblock %} diff --git a/src/objects/templates/admin/core/objecttype/object_list.html b/src/objects/templates/admin/core/objecttype/object_list.html new file mode 100644 index 00000000..86ff2d1b --- /dev/null +++ b/src/objects/templates/admin/core/objecttype/object_list.html @@ -0,0 +1,11 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls %} + +{% block object-tools-items %} +
  • + + {% trans 'Import from URL' %} + +
  • + {{ block.super }} +{% endblock %} diff --git a/src/objects/templates/admin/core/objecttype/submit_line.html b/src/objects/templates/admin/core/objecttype/submit_line.html new file mode 100644 index 00000000..dec5bd47 --- /dev/null +++ b/src/objects/templates/admin/core/objecttype/submit_line.html @@ -0,0 +1,14 @@ +{% extends 'admin/submit_line.html' %} +{% load i18n %} + +{% block submit-row %} + {{ block.super }} + + {% if original.last_version.status == 'draft' %} + + + {% elif original.last_version.status == 'published' %} + + {% endif %} + +{% endblock %} diff --git a/src/objects/tests/admin/test_core_views.py b/src/objects/tests/admin/test_core_views.py index ea8ad676..deef07f2 100644 --- a/src/objects/tests/admin/test_core_views.py +++ b/src/objects/tests/admin/test_core_views.py @@ -1,3 +1,5 @@ +from unittest import skip + from django.urls import reverse import requests_mock @@ -13,6 +15,7 @@ @disable_admin_mfa() @requests_mock.Mocker() +@skip("outdated") # TODO view was removed class ObjectTypeAdminVersionsTests(WebTest): def test_valid_response_view(self, m): objecttypes_api = "https://example.com/objecttypes/v1/" diff --git a/src/objects/tests/admin/test_objecttype_admin.py b/src/objects/tests/admin/test_objecttype_admin.py new file mode 100644 index 00000000..c9bc5ad1 --- /dev/null +++ b/src/objects/tests/admin/test_objecttype_admin.py @@ -0,0 +1,465 @@ +import json +from datetime import date + +from django.urls import reverse, reverse_lazy + +import requests_mock +from django_webtest import WebTest +from freezegun import freeze_time +from maykin_2fa.test import disable_admin_mfa + +from objects.accounts.tests.factories import SuperUserFactory +from objects.core.constants import ( + DataClassificationChoices, + ObjectTypeVersionStatus, + UpdateFrequencyChoices, +) +from objects.core.models import ObjectType +from objects.core.tests.factories import ObjectTypeFactory, ObjectTypeVersionFactory + +JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Tree", + "description": ( + "A woody plant (deciduous or coniferous) with a root system and a " + "single, sturdy, woody stem, branching above the ground." + ), + "required": ["diameter"], + "properties": {"diameter": {"type": "integer", "description": "size in cm."}}, +} + + +@freeze_time("2020-01-01") +@disable_admin_mfa() +class AdminAddTests(WebTest): + url = reverse_lazy("admin:core_objecttype_add") + import_from_url = reverse_lazy("admin:import_from_url") + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.user = SuperUserFactory.create() + + def setUp(self) -> None: + super().setUp() + + self.app.set_user(self.user) + + def test_create_objecttype_success(self): + get_response = self.app.get(self.url) + + form = get_response.forms[1] + form["name"] = "boom" + form["name_plural"] = "bomen" + form["description"] = "some object type description" + form["data_classification"] = DataClassificationChoices.intern + form["maintainer_organization"] = "tree municipality" + form["maintainer_department"] = "object types department" + form["contact_person"] = "John Smith" + form["contact_email"] = "John.Smith@objecttypes.nl" + form["source"] = "tree system" + form["update_frequency"] = UpdateFrequencyChoices.monthly + form["provider_organization"] = "tree provider" + form["documentation_url"] = "http://example.com/doc/trees" + form["labels"] = json.dumps({"key1": "value1"}) + form["versions-0-json_schema"] = json.dumps(JSON_SCHEMA) + + response = form.submit() + + # redirect on successful create, 200 on validation errors, 500 on db errors + self.assertEqual(response.status_code, 302) + self.assertEqual(ObjectType.objects.count(), 1) + + object_type = ObjectType.objects.get() + + self.assertEqual(object_type.name, "boom") + self.assertEqual(object_type.name_plural, "bomen") + self.assertEqual(object_type.description, "some object type description") + self.assertEqual( + object_type.data_classification, DataClassificationChoices.intern + ) + self.assertEqual(object_type.maintainer_organization, "tree municipality") + self.assertEqual(object_type.maintainer_department, "object types department") + self.assertEqual(object_type.contact_person, "John Smith") + self.assertEqual(object_type.contact_email, "John.Smith@objecttypes.nl") + self.assertEqual(object_type.source, "tree system") + self.assertEqual(object_type.update_frequency, UpdateFrequencyChoices.monthly) + self.assertEqual(object_type.provider_organization, "tree provider") + self.assertEqual(object_type.documentation_url, "http://example.com/doc/trees") + self.assertEqual(object_type.labels, {"key1": "value1"}) + self.assertEqual(object_type.created_at, date(2020, 1, 1)) + self.assertEqual(object_type.modified_at, date(2020, 1, 1)) + self.assertEqual(object_type.versions.count(), 1) + + object_version = object_type.last_version + + self.assertEqual(object_version.version, 1) + self.assertEqual(object_version.json_schema, JSON_SCHEMA) + self.assertEqual(object_version.status, ObjectTypeVersionStatus.draft) + self.assertEqual(object_version.created_at, date(2020, 1, 1)) + self.assertEqual(object_version.modified_at, date(2020, 1, 1)) + self.assertIsNone(object_version.published_at) + + def test_create_objecttype_invalid_json_schema(self): + get_response = self.app.get(self.url) + + form = get_response.forms[1] + form["name"] = "boom" + form["name_plural"] = "bomen" + form["description"] = "some object type description" + form["data_classification"] = DataClassificationChoices.intern + form["maintainer_organization"] = "tree municipality" + form["maintainer_department"] = "object types department" + form["contact_person"] = "John Smith" + form["contact_email"] = "John.Smith@objecttypes.nl" + form["source"] = "tree system" + form["update_frequency"] = UpdateFrequencyChoices.monthly + form["provider_organization"] = "tree provider" + form["documentation_url"] = "http://example.com/doc/trees" + form["labels"] = json.dumps({"key1": "value1"}) + form["versions-0-json_schema"] = "{}{" + + response = form.submit() + + # redirect on successful create, 200 on validation errors, 500 on db errors + self.assertEqual(response.status_code, 200) + self.assertEqual(ObjectType.objects.count(), 0) + + error_list = response.html.find("ul", {"class": "errorlist"}) + + # The submitted value is shown in the error + self.assertIn("{}{", error_list.text) + + json_schema_field = response.forms[1].fields["versions-0-json_schema"][0] + + # Verify that the value of the JSON schema field is the fallback value + self.assertEqual(json_schema_field.value, "{}") + + def test_create_objecttype_without_version_fail(self): + get_response = self.app.get(self.url) + + form = get_response.forms[1] + form["name"] = "boom" + form["name_plural"] = "bomen" + form["description"] = "some object type description" + form["maintainer_organization"] = "tree municipality" + + response = form.submit() + + self.assertEqual(response.status_code, 200) + self.assertEqual(ObjectType.objects.count(), 0) + + def test_create_objecttype_with_invalid_json_schema(self): + get_response = self.app.get(self.url) + + form = get_response.forms[1] + form["name"] = "boom" + form["name_plural"] = "bomen" + form["description"] = "some object type description" + form["maintainer_organization"] = "tree municipality" + form["versions-0-json_schema"] = json.dumps( + { + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "any", + } + ) + + response = form.submit() + + self.assertEqual(response.status_code, 200) + self.assertEqual(ObjectType.objects.count(), 0) + + @requests_mock.Mocker() + def test_create_objecttype_from_url(self, m): + get_response = self.app.get(self.import_from_url) + + m.get("https://example.com/tree.json", json=JSON_SCHEMA) + + form = get_response.form + form["objecttype_url"] = "https://example.com/tree.json" + form["name_plural"] = "Trees" + + response = form.submit() + + self.assertEqual(response.status_code, 302) + self.assertEqual(ObjectType.objects.count(), 1) + + object_type = ObjectType.objects.get() + + self.assertEqual(object_type.name, "Tree") + self.assertEqual(object_type.name_plural, "Trees") + self.assertEqual( + object_type.description, + "A woody plant (deciduous or coniferous) with a root system and a single, sturdy, woody stem, branching " + "above the ground.", + ) + self.assertEqual( + object_type.data_classification, DataClassificationChoices.open + ) + self.assertEqual(object_type.created_at, date(2020, 1, 1)) + self.assertEqual(object_type.modified_at, date(2020, 1, 1)) + self.assertEqual(object_type.versions.count(), 1) + + object_version = object_type.last_version + + self.assertEqual(object_version.version, 1) + self.assertEqual(object_version.status, ObjectTypeVersionStatus.draft) + self.assertEqual(object_version.created_at, date(2020, 1, 1)) + self.assertEqual(object_version.modified_at, date(2020, 1, 1)) + self.assertIsNone(object_version.published_at) + self.assertEqual(object_version.json_schema, JSON_SCHEMA) + + @requests_mock.Mocker() + def test_create_objecttype_from_url_with_invalid_schema(self, m): + get_response = self.app.get(self.import_from_url) + + m.get( + "https://example.com/tree.json", + json={ + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "any", + }, + ) + + form = get_response.form + form["objecttype_url"] = "https://example.com/tree.json" + form["name_plural"] = "bomen" + + response = form.submit() + + self.assertIn("Invalid JSON schema.", response.text) + self.assertEqual(response.status_code, 200) + self.assertEqual(ObjectType.objects.count(), 0) + + def test_create_objecttype_from_url_with_nonexistent_url(self): + get_response = self.app.get(self.import_from_url) + + form = get_response.form + form["objecttype_url"] = "https://random-url123.com" + form["name_plural"] = "bomen" + + response = form.submit() + + self.assertIn("The Objecttype URL does not exist.", response.text) + self.assertEqual(response.status_code, 200) + self.assertEqual(ObjectType.objects.count(), 0) + + +@disable_admin_mfa() +class AdminDetailTests(WebTest): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.user = SuperUserFactory.create() + + def setUp(self) -> None: + super().setUp() + + self.app.set_user(self.user) + + def test_display_successfully_without_versions(self): + object_type = ObjectTypeFactory.create() + url = reverse("admin:core_objecttype_change", args=[object_type.id]) + + get_response = self.app.get(url) + + self.assertEqual(get_response.status_code, 200) + + form = get_response.forms[1] + + self.assertEqual(form["versions-0-id"].value, "") + + def test_display_only_last_version(self): + object_type = ObjectTypeFactory.create() + ObjectTypeVersionFactory.create_batch(3, object_type=object_type) + url = reverse("admin:core_objecttype_change", args=[object_type.id]) + + get_response = self.app.get(url) + form = get_response.forms[1] + + self.assertEqual(int(form["versions-TOTAL_FORMS"].value), 1) + self.assertEqual(int(form["versions-0-id"].value), object_type.last_version.id) + + def test_update_draft(self): + old_schema = { + "type": "object", + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "diameter": {"type": "integer", "description": "size in cm."} + }, + } + object_type = ObjectTypeFactory.create() + object_version = ObjectTypeVersionFactory.create( + object_type=object_type, json_schema=old_schema + ) + url = reverse("admin:core_objecttype_change", args=[object_type.id]) + + get_response = self.app.get(url) + + save_button = get_response.html.find("input", {"name": "_save"}) + self.assertIsNotNone(save_button) + + form = get_response.forms[1] + form["versions-0-json_schema"] = json.dumps(JSON_SCHEMA) + response = form.submit() + + self.assertEqual(response.status_code, 302) + + object_version.refresh_from_db() + self.assertEqual(object_version.json_schema, JSON_SCHEMA) + + def test_update_draft_invalid_json_schema(self): + old_schema = { + "type": "object", + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "diameter": {"type": "integer", "description": "size in cm."} + }, + } + object_type = ObjectTypeFactory.create() + object_version = ObjectTypeVersionFactory.create( + object_type=object_type, json_schema=old_schema + ) + url = reverse("admin:core_objecttype_change", args=[object_type.id]) + + get_response = self.app.get(url) + + save_button = get_response.html.find("input", {"name": "_save"}) + self.assertIsNotNone(save_button) + + form = get_response.forms[1] + form["versions-0-json_schema"] = "{}{" + response = form.submit() + + self.assertEqual(response.status_code, 200) + + object_version.refresh_from_db() + self.assertEqual(object_version.json_schema, old_schema) + + error_list = response.html.find("ul", {"class": "errorlist"}) + + # The submitted value is shown in the error + self.assertIn("{}{", error_list.text) + + json_schema_field = response.forms[1].fields["versions-0-json_schema"][0] + + # Verify that the value of the JSON schema field is the fallback value + self.assertEqual(json_schema_field.value, json.dumps(old_schema)) + + def test_update_draft_save_button(self): + object_type = ObjectTypeFactory.create() + ObjectTypeVersionFactory.create( + object_type=object_type, status=ObjectTypeVersionStatus.draft + ) + url = reverse("admin:core_objecttype_change", args=[object_type.id]) + + get_response = self.app.get(url) + + save_button = get_response.html.find("input", {"name": "_save"}) + self.assertIsNotNone(save_button) + + def test_update_published_save_button(self): + object_type = ObjectTypeFactory.create() + ObjectTypeVersionFactory.create( + object_type=object_type, status=ObjectTypeVersionStatus.published + ) + url = reverse("admin:core_objecttype_change", args=[object_type.id]) + + get_response = self.app.get(url) + + save_button = get_response.html.find("input", {"name": "_save"}) + self.assertIsNotNone(save_button) + + def test_publish_draft(self): + object_type = ObjectTypeFactory.create() + object_version = ObjectTypeVersionFactory.create(object_type=object_type) + url = reverse("admin:core_objecttype_change", args=[object_type.id]) + + get_response = self.app.get(url) + + publish_button = get_response.html.find("input", {"name": "_publish"}) + self.assertIsNotNone(publish_button) + + form = get_response.forms[1] + response = form.submit("_publish") + + self.assertEqual(response.status_code, 302) + + object_version.refresh_from_db() + self.assertEqual(object_version.status, ObjectTypeVersionStatus.published) + self.assertEqual(object_version.published_at, date.today()) + + def test_publish_published_no_button(self): + object_type = ObjectTypeFactory.create() + ObjectTypeVersionFactory.create( + object_type=object_type, status=ObjectTypeVersionStatus.published + ) + url = reverse("admin:core_objecttype_change", args=[object_type.id]) + + get_response = self.app.get(url) + + publish_button = get_response.html.find("input", {"name": "_publish"}) + self.assertIsNone(publish_button) + + def test_new_version_draft_no_button(self): + object_type = ObjectTypeFactory.create() + ObjectTypeVersionFactory.create(object_type=object_type) + url = reverse("admin:core_objecttype_change", args=[object_type.id]) + + get_response = self.app.get(url) + + new_version_button = get_response.html.find("input", {"name": "_newversion"}) + self.assertIsNone(new_version_button) + + @freeze_time("2020-02-02") + def test_new_version_published(self): + object_type = ObjectTypeFactory.create() + object_version = ObjectTypeVersionFactory.create( + object_type=object_type, status=ObjectTypeVersionStatus.published + ) + url = reverse("admin:core_objecttype_change", args=[object_type.id]) + + get_response = self.app.get(url) + + new_version_button = get_response.html.find("input", {"name": "_newversion"}) + self.assertIsNotNone(new_version_button) + + form = get_response.forms[1] + response = form.submit("_newversion") + + self.assertEqual(response.status_code, 302) + + object_type.refresh_from_db() + self.assertEqual(object_type.versions.count(), 2) + + last_version = object_type.last_version + self.assertNotEqual(last_version, object_version) + self.assertEqual(last_version.version, object_version.version + 1) + self.assertEqual(last_version.json_schema, object_version.json_schema) + self.assertEqual(last_version.status, ObjectTypeVersionStatus.draft) + + def test_display_all_versions_in_history(self): + object_type = ObjectTypeFactory.create() + ObjectTypeVersionFactory.create_batch(3, object_type=object_type) + url = reverse("admin:core_objecttype_history", args=[object_type.id]) + + response = self.app.get(url) + object_type = response.context["object"] + + self.assertEqual(response.status_code, 200) + + table = response.html.find(id="change-history") + table_rows = table.tbody.find_all("tr") + + self.assertEqual(len(table_rows), 3) + + for object_version, row in zip(object_type.ordered_versions, table_rows): + row_version = row.find("td") + self.assertEqual(int(row_version.text), object_version.version) diff --git a/src/objects/tests/test_objectversion_generate.py b/src/objects/tests/test_objectversion_generate.py new file mode 100644 index 00000000..49a7c52f --- /dev/null +++ b/src/objects/tests/test_objectversion_generate.py @@ -0,0 +1,33 @@ +from django.test import TestCase + +from objects.core.models import ObjectTypeVersion +from objects.core.tests.factories import ObjectTypeFactory, ObjectTypeVersionFactory + +JSON_SCHEMA = { + "type": "object", + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": ["diameter"], + "properties": {"diameter": {"type": "integer", "description": "size in cm."}}, +} + + +class GenerateVersionTests(TestCase): + def test_generate_version_for_new_objecttype(self): + object_type = ObjectTypeFactory.create() + + object_version = ObjectTypeVersion.objects.create( + json_schema=JSON_SCHEMA, object_type=object_type + ) + + self.assertEqual(object_version.version, 1) + + def test_generate_version_for_objecttype_with_existed_version(self): + object_type = ObjectTypeFactory.create() + ObjectTypeVersionFactory.create(object_type=object_type, version=1) + + object_version = ObjectTypeVersion.objects.create( + json_schema=JSON_SCHEMA, object_type=object_type + ) + + self.assertEqual(object_version.version, 2) diff --git a/src/objects/tests/test_widgets.py b/src/objects/tests/test_widgets.py new file mode 100644 index 00000000..ca5f7915 --- /dev/null +++ b/src/objects/tests/test_widgets.py @@ -0,0 +1,44 @@ +from django.test import TestCase + +from bs4 import BeautifulSoup + +from objects.core.widgets import JSONSuit + + +class JSONSuitTestCase(TestCase): + def test_render_valid_json_schema(self): + widget = JSONSuit() + + widget.initial = {"foo": "bar"} + + rendered = widget.render("field_name", '{"bar": "foo"}') + soup = BeautifulSoup(rendered, "html.parser") + + textarea = soup.find("textarea") + self.assertEqual( + textarea.text.strip(), soup.find("code").attrs["data-raw"], '{"bar": "foo"}' + ) + + def test_render_invalid_json_schema_fallback(self): + widget = JSONSuit() + + rendered = widget.render("field_name", "{}{") + soup = BeautifulSoup(rendered, "html.parser") + + textarea = soup.find("textarea") + self.assertEqual( + textarea.text.strip(), soup.find("code").attrs["data-raw"], "{}" + ) + + def test_render_invalid_json_schema_initial(self): + widget = JSONSuit() + + widget.initial = {"foo": "bar"} + + rendered = widget.render("field_name", "{}{") + soup = BeautifulSoup(rendered, "html.parser") + + textarea = soup.find("textarea") + self.assertEqual( + textarea.text.strip(), soup.find("code").attrs["data-raw"], '{"foo": "bar"}' + ) diff --git a/src/objects/tests/v2/test_auth.py b/src/objects/tests/v2/test_auth.py index 089172c6..9f75371b 100644 --- a/src/objects/tests/v2/test_auth.py +++ b/src/objects/tests/v2/test_auth.py @@ -15,6 +15,7 @@ from objects.token.tests.factories import PermissionFactory, TokenAuthFactory from objects.utils.test import TokenAuthMixin +from ...token.models import TokenAuth from ..constants import GEO_WRITE_KWARGS, POLYGON_AMSTERDAM_CENTRUM from ..utils import mock_objecttype, mock_objecttype_version, mock_service_oas_get from .utils import reverse, reverse_lazy @@ -482,3 +483,36 @@ def test_destroy_superuser(self): response = self.client.delete(url) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + +class ObjectTypeAuthTests(APITestCase): + def setUp(self) -> None: + object_type = ObjectTypeFactory.create() + self.urls = [ + reverse("objecttype-list"), + reverse("objecttype-detail", args=[object_type.uuid]), + ] + + def test_non_auth(self): + for url in self.urls: + with self.subTest(url=url): + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_invalid_token(self): + TokenAuth.objects.create(contact_person="John Smith", email="smith@bomen.nl") + for url in self.urls: + with self.subTest(url=url): + response = self.client.get(url, HTTP_AUTHORIZATION="Token 12345") + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_valid_token(self): + token_auth = TokenAuth.objects.create( + contact_person="John Smith", email="smith@bomen.nl" + ) + for url in self.urls: + with self.subTest(url=url): + response = self.client.get( + url, HTTP_AUTHORIZATION=f"Token {token_auth.token}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/src/objects/tests/v2/test_filters.py b/src/objects/tests/v2/test_filters.py index 61f59faa..842df3a8 100644 --- a/src/objects/tests/v2/test_filters.py +++ b/src/objects/tests/v2/test_filters.py @@ -17,6 +17,7 @@ from objects.token.tests.factories import PermissionFactory from objects.utils.test import TokenAuthMixin +from ...core.constants import DataClassificationChoices from .utils import reverse, reverse_lazy OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" @@ -1084,3 +1085,27 @@ def test_filter_unkown_version(self): data = response.json()["results"] self.assertEqual(len(data), 0) + + +class FilterTests(TokenAuthMixin, APITestCase): + url = reverse_lazy("objecttype-list") + + def test_filter_public_data(self): + object_type_1 = ObjectTypeFactory.create( + data_classification=DataClassificationChoices.open + ) + ObjectTypeFactory.create(data_classification=DataClassificationChoices.intern) + + response = self.client.get( + self.url, {"dataClassification": DataClassificationChoices.open} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + + self.assertEqual(len(data), 1) + self.assertEqual( + data[0]["url"], + f"http://testserver{reverse('objecttype-detail', args=[object_type_1.uuid])}", + ) diff --git a/src/objects/tests/v2/test_metrics.py b/src/objects/tests/v2/test_metrics.py index e70c1239..9340f07a 100644 --- a/src/objects/tests/v2/test_metrics.py +++ b/src/objects/tests/v2/test_metrics.py @@ -2,12 +2,16 @@ import requests_mock from freezegun import freeze_time +from rest_framework import status from rest_framework.test import APITestCase from objects.api.metrics import ( objects_create_counter, objects_delete_counter, objects_update_counter, + objecttype_create_counter, + objecttype_delete_counter, + objecttype_update_counter, ) from objects.core.tests.factories import ( ObjectFactory, @@ -109,3 +113,41 @@ def test_objects_delete_counter(self, m, mock_add: MagicMock): self.assertEqual(response.status_code, 204) mock_add.assert_called_once_with(1) + + +@freeze_time("2020-01-01") +class ObjectTypeMetricTests(TokenAuthMixin, APITestCase): + @patch.object(objecttype_create_counter, "add", wraps=objecttype_create_counter.add) + def test_objecttype_create_counter(self, mock_add: MagicMock): + url = reverse("objecttype-list") + + response = self.client.post( + url, + { + "name": "Boom", + "namePlural": "Bomen", + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + mock_add.assert_called_once_with(1) + + @patch.object(objecttype_update_counter, "add", wraps=objecttype_update_counter.add) + def test_objecttype_update_counter(self, mock_add: MagicMock): + obj = ObjectTypeFactory.create() + url = reverse("objecttype-detail", args=[obj.uuid]) + + response = self.client.patch(url, {"name": "test"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + mock_add.assert_called_once_with(1) + + @patch.object(objecttype_delete_counter, "add", wraps=objecttype_delete_counter.add) + def test_objecttype_delete_counter(self, mock_add: MagicMock): + obj = ObjectTypeFactory.create() + url = reverse("objecttype-detail", args=[obj.uuid]) + + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + mock_add.assert_called_once_with(1) diff --git a/src/objects/tests/v2/test_objecttype_api.py b/src/objects/tests/v2/test_objecttype_api.py new file mode 100644 index 00000000..e7077dbb --- /dev/null +++ b/src/objects/tests/v2/test_objecttype_api.py @@ -0,0 +1,160 @@ +from datetime import date + +from freezegun import freeze_time +from rest_framework import status +from rest_framework.test import APITestCase + +from objects.core.constants import DataClassificationChoices, UpdateFrequencyChoices +from objects.core.models import ObjectType +from objects.core.tests.factories import ObjectTypeFactory, ObjectTypeVersionFactory +from objects.utils.test import TokenAuthMixin + +from .utils import reverse + + +@freeze_time("2020-01-01") +class ObjectTypeAPITests(TokenAuthMixin, APITestCase): + def test_get_objecttypes(self): + object_type = ObjectTypeFactory.create() + object_version = ObjectTypeVersionFactory.create(object_type=object_type) + url = reverse("objecttype-detail", args=[object_type.uuid]) + + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.json(), + { + "url": f"http://testserver{url}", + "uuid": str(object_type.uuid), + "name": object_type.name, + "namePlural": object_type.name_plural, + "description": object_type.description, + "dataClassification": object_type.data_classification, + "maintainerOrganization": object_type.maintainer_organization, + "maintainerDepartment": object_type.maintainer_department, + "contactPerson": object_type.contact_person, + "contactEmail": object_type.contact_email, + "source": object_type.source, + "updateFrequency": object_type.update_frequency, + "providerOrganization": object_type.provider_organization, + "documentationUrl": object_type.documentation_url, + "labels": object_type.labels, + "linkableToZaken": False, + "createdAt": "2020-01-01", + "modifiedAt": "2020-01-01", + "allowGeometry": object_type.allow_geometry, + "versions": [ + "http://testserver{url}".format( + url=reverse( + "objecttypeversion-detail", + args=[object_type.uuid, object_version.version], + ) + ), + ], + }, + ) + + def test_get_objecttypes_with_versions(self): + object_types = ObjectTypeFactory.create_batch(2) + object_versions = [ + ObjectTypeVersionFactory.create(object_type=object_type) + for object_type in object_types + ] + for i, object_type in enumerate(object_types): + with self.subTest(object_type=object_type): + url = reverse("objecttype-detail", args=[object_type.uuid]) + + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json() + self.assertEqual(len(data["versions"]), 1) + self.assertEqual( + data["versions"], + [ + "http://testserver{url}".format( + url=reverse( + "objecttypeversion-detail", + args=[object_type.uuid, object_versions[i].version], + ) + ), + ], + ) + + def test_create_objecttype(self): + url = reverse("objecttype-list") + data = { + "name": "boom", + "namePlural": "bomen", + "description": "tree type description", + "dataClassification": DataClassificationChoices.intern, + "maintainerOrganization": "tree municipality", + "maintainerDepartment": "object types department", + "contactPerson": "John Smith", + "contactEmail": "John.Smith@objecttypes.nl", + "source": "tree system", + "updateFrequency": UpdateFrequencyChoices.monthly, + "providerOrganization": "tree provider", + "documentationUrl": "http://example.com/doc/trees", + "labels": {"key1": "value1"}, + } + + response = self.client.post(url, data) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(ObjectType.objects.count(), 1) + + object_type = ObjectType.objects.get() + + self.assertEqual(object_type.name, "boom") + self.assertEqual(object_type.name_plural, "bomen") + self.assertEqual(object_type.description, "tree type description") + self.assertEqual( + object_type.data_classification, DataClassificationChoices.intern + ) + self.assertEqual(object_type.maintainer_organization, "tree municipality") + self.assertEqual(object_type.maintainer_department, "object types department") + self.assertEqual(object_type.contact_person, "John Smith") + self.assertEqual(object_type.contact_email, "John.Smith@objecttypes.nl") + self.assertEqual(object_type.source, "tree system") + self.assertFalse(object_type.linkable_to_zaken) + self.assertEqual(object_type.update_frequency, UpdateFrequencyChoices.monthly) + self.assertEqual(object_type.provider_organization, "tree provider") + self.assertEqual(object_type.documentation_url, "http://example.com/doc/trees") + self.assertEqual(object_type.labels, {"key1": "value1"}) + self.assertEqual(object_type.created_at, date(2020, 1, 1)) + self.assertEqual(object_type.modified_at, date(2020, 1, 1)) + + def test_update_objecttype(self): + object_type = ObjectTypeFactory.create( + data_classification=DataClassificationChoices.intern + ) + url = reverse("objecttype-detail", args=[object_type.uuid]) + + response = self.client.patch( + url, + { + "dataClassification": DataClassificationChoices.open, + "linkableToZaken": True, + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + object_type.refresh_from_db() + + self.assertEqual( + object_type.data_classification, DataClassificationChoices.open + ) + self.assertTrue(object_type.linkable_to_zaken) + + def test_delete_objecttype(self): + object_type = ObjectTypeFactory.create() + url = reverse("objecttype-detail", args=[object_type.uuid]) + + response = self.client.delete(url) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(ObjectType.objects.count(), 0) diff --git a/src/objects/tests/v2/test_objecttypeversion_api.py b/src/objects/tests/v2/test_objecttypeversion_api.py new file mode 100644 index 00000000..160ad347 --- /dev/null +++ b/src/objects/tests/v2/test_objecttypeversion_api.py @@ -0,0 +1,134 @@ +from datetime import date + +from freezegun import freeze_time +from rest_framework import status +from rest_framework.test import APITestCase + +from objects.core.constants import ObjectTypeVersionStatus +from objects.core.models import ObjectTypeVersion +from objects.core.tests.factories import ObjectTypeFactory, ObjectTypeVersionFactory +from objects.utils.test import TokenAuthMixin + +from .utils import reverse + +JSON_SCHEMA = { + "type": "object", + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": ["diameter"], + "properties": {"diameter": {"type": "integer", "description": "size in cm."}}, +} + + +@freeze_time("2020-01-01") +class ObjectTypeVersionAPITests(TokenAuthMixin, APITestCase): + def test_get_versions(self): + object_type = ObjectTypeFactory.create() + object_version = ObjectTypeVersionFactory.create(object_type=object_type) + url = reverse("objecttypeversion-list", args=[object_type.uuid]) + + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.json(), + { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "url": "http://testserver{url}".format( + url=reverse( + "objecttypeversion-detail", + args=[object_type.uuid, object_version.version], + ), + ), + "version": object_version.version, + "objectType": "http://testserver{url}".format( + url=reverse( + "objecttype-detail", + args=[object_version.object_type.uuid], + ) + ), + "status": object_version.status, + "createdAt": "2020-01-01", + "modifiedAt": "2020-01-01", + "publishedAt": None, + "jsonSchema": JSON_SCHEMA, + } + ], + }, + ) + + def test_get_versions_incorrect_format_uuid(self): + """ + Regression test for https://github.com/maykinmedia/objects-api/issues/361 + """ + url = reverse("objecttypeversion-list", args=["aaa"]) + + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_create_version(self): + object_type = ObjectTypeFactory.create() + data = {"jsonSchema": JSON_SCHEMA, "status": ObjectTypeVersionStatus.published} + url = reverse("objecttypeversion-list", args=[object_type.uuid]) + + response = self.client.post(url, data) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(ObjectTypeVersion.objects.count(), 1) + + object_version = ObjectTypeVersion.objects.get() + + self.assertEqual(object_version.object_type, object_type) + self.assertEqual(object_version.json_schema, JSON_SCHEMA) + self.assertEqual(object_version.version, 1) + self.assertEqual(object_version.status, ObjectTypeVersionStatus.published) + self.assertEqual(object_version.created_at, date(2020, 1, 1)) + self.assertEqual(object_version.modified_at, date(2020, 1, 1)) + self.assertEqual(object_version.published_at, date(2020, 1, 1)) + + def test_update_version(self): + object_type = ObjectTypeFactory.create() + object_version = ObjectTypeVersionFactory.create(object_type=object_type) + url = reverse( + "objecttypeversion-detail", args=[object_type.uuid, object_version.version] + ) + new_json_schema = { + "type": "object", + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": ["diameter"], + "properties": {"diameter": {"type": "number"}}, + } + + response = self.client.put( + url, + { + "jsonSchema": new_json_schema, + "status": ObjectTypeVersionStatus.published, + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + object_version.refresh_from_db() + + self.assertEqual(object_version.json_schema, new_json_schema) + self.assertEqual(object_version.status, ObjectTypeVersionStatus.published) + self.assertEqual(object_version.published_at, date(2020, 1, 1)) + + def test_delete_version(self): + object_type = ObjectTypeFactory.create() + object_version = ObjectTypeVersionFactory.create(object_type=object_type) + url = reverse( + "objecttypeversion-detail", args=[object_type.uuid, object_version.version] + ) + + response = self.client.delete(url) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(ObjectTypeVersion.objects.count(), 0) diff --git a/src/objects/tests/v2/test_validation.py b/src/objects/tests/v2/test_validation.py index cfe22645..326618b8 100644 --- a/src/objects/tests/v2/test_validation.py +++ b/src/objects/tests/v2/test_validation.py @@ -10,11 +10,16 @@ from rest_framework.test import APITestCase from objects.core.models import Object -from objects.core.tests.factories import ObjectRecordFactory, ObjectTypeFactory +from objects.core.tests.factories import ( + ObjectRecordFactory, + ObjectTypeFactory, + ObjectTypeVersionFactory, +) from objects.token.constants import PermissionModes from objects.token.tests.factories import PermissionFactory from objects.utils.test import ClearCachesMixin, TokenAuthMixin +from ...core.constants import ObjectTypeVersionStatus from ..constants import GEO_WRITE_KWARGS from ..utils import mock_objecttype, mock_objecttype_version, mock_service_oas_get from .utils import reverse @@ -563,3 +568,113 @@ def test_create_object_with_duplicate_uuid_returns_400(self, m): self.assertEqual( response.json()["uuid"], ["An object with this UUID already exists."] ) + + # TODO from objecttypes + def test_patch_objecttype_with_uuid_fail(self): + object_type = ObjectTypeFactory.create() + url = reverse("objecttype-detail", args=[object_type.uuid]) + + response = self.client.patch(url, {"uuid": uuid.uuid4()}) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + data = response.json() + self.assertEqual(data["uuid"], ["This field can't be changed"]) + + def test_delete_objecttype_with_versions_fail(self): + object_type = ObjectTypeFactory.create() + ObjectTypeVersionFactory.create(object_type=object_type) + url = reverse("objecttype-detail", args=[object_type.uuid]) + + response = self.client.delete(url) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + data = response.json() + self.assertEqual( + data["non_field_errors"], + [ + "All related versions should be destroyed before destroying the objecttype" + ], + ) + + class ObjectTypeVersionValidationTests(TokenAuthMixin, APITestCase): + def test_create_version_with_incorrect_schema_fail(self): + object_type = ObjectTypeFactory.create() + url = reverse("objecttypeversion-list", args=[object_type.uuid]) + data = { + "jsonSchema": { + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "any", + } + } + + response = self.client.post(url, data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertTrue("jsonSchema" in response.json()) + + def test_create_version_with_incorrect_objecttype_fail(self): + url = reverse("objecttypeversion-list", args=[uuid.uuid4()]) + data = { + "jsonSchema": { + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "diameter": {"type": "integer", "description": "size in cm."} + }, + } + } + + response = self.client.post(url, data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json()["non_field_errors"], ["Objecttype url is invalid"] + ) + + def test_update_published_version_fail(self): + object_type = ObjectTypeFactory.create() + object_version = ObjectTypeVersionFactory.create( + object_type=object_type, status=ObjectTypeVersionStatus.published + ) + url = reverse( + "objecttypeversion-detail", + args=[object_type.uuid, object_version.version], + ) + new_json_schema = { + "type": "object", + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": ["diameter"], + "properties": {"diameter": {"type": "number"}}, + } + + response = self.client.put(url, {"jsonSchema": new_json_schema}) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + data = response.json() + self.assertEqual( + data["non_field_errors"], ["Only draft versions can be changed"] + ) + + def test_delete_puclished_version_fail(self): + object_type = ObjectTypeFactory.create() + object_version = ObjectTypeVersionFactory.create( + object_type=object_type, status=ObjectTypeVersionStatus.published + ) + url = reverse( + "objecttypeversion-detail", + args=[object_type.uuid, object_version.version], + ) + + response = self.client.delete(url) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + data = response.json() + self.assertEqual( + data["non_field_errors"], ["Only draft versions can be destroyed"] + ) diff --git a/src/objects/token/permissions.py b/src/objects/token/permissions.py index aa2e318f..1b673715 100644 --- a/src/objects/token/permissions.py +++ b/src/objects/token/permissions.py @@ -63,3 +63,8 @@ def has_object_permission(self, request, view, obj): return True return bool(object_permission.mode == PermissionModes.read_and_write) + + +class IsTokenAuthenticated(BasePermission): + def has_permission(self, request, view): + return bool(request.auth) diff --git a/src/objects/token/tests/test_objecttype_authentication.py b/src/objects/token/tests/test_objecttype_authentication.py new file mode 100644 index 00000000..56fff49d --- /dev/null +++ b/src/objects/token/tests/test_objecttype_authentication.py @@ -0,0 +1,63 @@ +from django.urls import reverse + +from rest_framework import status +from rest_framework.test import APITestCase + +from objects.token.models import TokenAuth + + +class TestObjectTypeTokenAuthAuthorization(APITestCase): + def test_valid_token(self): + token_auth = TokenAuth.objects.create( + contact_person="contact_person", + email="contact_person@gmail.nl", + identifier="token-1", + ) + response = self.client.get( + reverse("v2:objecttype-list"), + HTTP_AUTHORIZATION=f"Token {token_auth.token}", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_valid_token_with_no_spaces(self): + token_auth = TokenAuth.objects.create( + contact_person="contact_person", + email="contact_person@gmail.nl", + identifier="token-1", + token="1234-Token-5678", + ) + response = self.client.get( + reverse("v2:objecttype-list"), + HTTP_AUTHORIZATION=f"Token {token_auth.token}", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_invalid_token_with_spaces(self): + token_auth = TokenAuth.objects.create( + contact_person="contact_person", + email="contact_person@gmail.nl", + identifier="token-1", + token="1234 Token 5678", + ) + response = self.client.get( + reverse("v2:objecttype-list"), + HTTP_AUTHORIZATION=f"Token {token_auth.token}", + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_invalid_token_existing(self): + response = self.client.get( + reverse("v2:objecttype-list"), + HTTP_AUTHORIZATION="Token 1234-Token-5678", + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_empty_token(self): + response = self.client.get( + reverse("v2:objecttype-list"), HTTP_AUTHORIZATION="Token " + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_non_token(self): + response = self.client.get(reverse("v2:objecttype-list")) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) From fa23efb0576c2ba97fe090aa18cc9ba281439f40 Mon Sep 17 00:00:00 2001 From: floris272 Date: Tue, 23 Dec 2025 14:36:20 +0100 Subject: [PATCH 02/15] :sparkles: [#564] add upgrade check and transform objecttype model to internal only --- src/objects/conf/base.py | 15 +++ .../check_for_external_objecttypes.py | 30 +++++ ...ter_objecttype_unique_together_and_more.py | 70 ++++++++++++ src/objects/core/models.py | 73 ++---------- src/objects/core/query.py | 8 -- src/objects/core/tests/factories.py | 6 - src/objects/core/tests/test_upgrade_check.py | 106 ++++++++++++++++++ 7 files changed, 229 insertions(+), 79 deletions(-) create mode 100644 src/objects/core/management/commands/check_for_external_objecttypes.py create mode 100644 src/objects/core/migrations/0037_alter_objecttype_unique_together_and_more.py create mode 100644 src/objects/core/tests/test_upgrade_check.py diff --git a/src/objects/conf/base.py b/src/objects/conf/base.py index 8e7d92b0..a0e787eb 100644 --- a/src/objects/conf/base.py +++ b/src/objects/conf/base.py @@ -1,5 +1,12 @@ import os +from upgrade_check.constraints import ( + CommandCheck, + UpgradeCheck, + UpgradePaths, + VersionRange, +) + os.environ["_USE_STRUCTLOG"] = "True" from django.core.exceptions import ImproperlyConfigured @@ -183,3 +190,11 @@ # Note: the LOGIN_URL Django setting is not used because you could have # multiple login urls defined. LOGIN_URLS = [reverse_lazy("admin:login")] + + +UPGRADE_CHECK_PATHS: UpgradePaths = { + "4.0.0": UpgradeCheck( + VersionRange(minimum="3.6.0"), + code_checks=[CommandCheck("check_for_external_objecttypes")], + ), +} diff --git a/src/objects/core/management/commands/check_for_external_objecttypes.py b/src/objects/core/management/commands/check_for_external_objecttypes.py new file mode 100644 index 00000000..bfa87651 --- /dev/null +++ b/src/objects/core/management/commands/check_for_external_objecttypes.py @@ -0,0 +1,30 @@ +from django.core.management import BaseCommand, CommandError + +from objects.core.models import ObjectType + + +class Command(BaseCommand): + help = "Checks if external objecttypes exist" + + def _get_objecttype(self): + """ + Separated for easier mocking + """ + return ObjectType + + def handle(self, *args, **options): + ObjectType = self._get_objecttype() + + external_object_count = 0 + service = set() + for objecttype in ObjectType.objects.iterator(): + if not objecttype.is_imported: + external_object_count += 1 + service.add(objecttype.service) + + msg = f"{external_object_count} objectypes have not been imported from the service(s): {service}" + + self.stdout.write(self.style.ERROR(msg)) + + if external_object_count > 0: + raise CommandError(msg) diff --git a/src/objects/core/migrations/0037_alter_objecttype_unique_together_and_more.py b/src/objects/core/migrations/0037_alter_objecttype_unique_together_and_more.py new file mode 100644 index 00000000..4ff8d8d5 --- /dev/null +++ b/src/objects/core/migrations/0037_alter_objecttype_unique_together_and_more.py @@ -0,0 +1,70 @@ +# Generated by Django 5.2.7 on 2025-12-19 15:41 + +import django.utils.timezone +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0036_objecttype_is_imported'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='objecttype', + unique_together=set(), + ), + migrations.AlterField( + model_name='objecttype', + name='created_at', + field=models.DateField(auto_now_add=True, default=django.utils.timezone.now, help_text='Date when the object type was created', verbose_name='created at'), + preserve_default=False, + ), + migrations.AlterField( + model_name='objecttype', + name='modified_at', + field=models.DateField(auto_now=True, default=django.utils.timezone.now, help_text='Last date when the object type was modified', verbose_name='modified at'), + preserve_default=False, + ), + migrations.AlterField( + model_name='objecttype', + name='name', + field=models.CharField(help_text='Name of the object type', max_length=100, verbose_name='name'), + ), + migrations.AlterField( + model_name='objecttype', + name='name_plural', + field=models.CharField(help_text='Plural name of the object type', max_length=100, verbose_name='name plural'), + ), + migrations.AlterField( + model_name='objecttype', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, help_text='Unique identifier (UUID4) of the OBJECTTYPE in Objecttypes API', unique=True), + ), + migrations.AlterField( + model_name='objecttypeversion', + name='created_at', + field=models.DateField(auto_now_add=True, default=django.utils.timezone.now, help_text='Date when the version was created', verbose_name='created at'), + preserve_default=False, + ), + migrations.AlterField( + model_name='objecttypeversion', + name='modified_at', + field=models.DateField(auto_now=True, default=django.utils.timezone.now, help_text='Last date when the version was modified', verbose_name='modified at'), + preserve_default=False, + ), + migrations.RemoveField( + model_name='objecttype', + name='_name', + ), + migrations.RemoveField( + model_name='objecttype', + name='is_imported', + ), + migrations.RemoveField( + model_name='objecttype', + name='service', + ), + ] diff --git a/src/objects/core/models.py b/src/objects/core/models.py index 894b3c4b..695e8c36 100644 --- a/src/objects/core/models.py +++ b/src/objects/core/models.py @@ -2,21 +2,14 @@ import datetime import uuid -from typing import ClassVar, Iterable +from typing import ClassVar from django.contrib.gis.db.models import GeometryField from django.contrib.postgres.indexes import GinIndex -from django.core.exceptions import ValidationError from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.utils.translation import gettext_lazy as _ -import requests -from requests.exceptions import ConnectionError -from zgw_consumers.models import Service - -from objects.utils.client import get_objecttypes_client - from .constants import ( DataClassificationChoices, ObjectTypeVersionStatus, @@ -28,35 +21,22 @@ class ObjectType(models.Model): - service = models.ForeignKey( - Service, on_delete=models.PROTECT, related_name="object_types" - ) uuid = models.UUIDField( - help_text=_("Unique identifier (UUID4) of the OBJECTTYPE in Objecttypes API") + help_text=_("Unique identifier (UUID4) of the OBJECTTYPE in Objecttypes API"), + unique=True, + default=uuid.uuid4, ) - _name = models.CharField( - max_length=100, - help_text=_("Cached name of the objecttype retrieved from the Objecttype API"), - ) # TODO can be removed after objecttype migration - - is_imported = models.BooleanField( - _("Is imported"), - default=False, - editable=False, - ) # TODO temp field to track if object was imported, can be removed after objecttype migration name = models.CharField( _("name"), max_length=100, help_text=_("Name of the object type"), - blank=True, # TODO blank=False after objecttype migration ) name_plural = models.CharField( _("name plural"), max_length=100, help_text=_("Plural name of the object type"), - blank=True, # TODO blank=False after objecttype migration ) description = models.CharField( _("description"), @@ -133,16 +113,12 @@ class ObjectType(models.Model): ) created_at = models.DateField( _("created at"), - auto_now_add=False, # TODO auto_now_add=True after migration - blank=True, # TODO blank=False after migration - null=True, # TODO null=False after migration + auto_now_add=True, help_text=_("Date when the object type was created"), ) modified_at = models.DateField( _("modified at"), - auto_now=False, # TODO auto_now=True after migration - blank=True, # TODO blank=False after migration - null=True, # TODO null=False after migration + auto_now=True, help_text=_("Last date when the object type was modified"), ) allow_geometry = models.BooleanField( @@ -158,9 +134,6 @@ class ObjectType(models.Model): objects = ObjectTypeQuerySet.as_manager() - class Meta: - unique_together = ("service", "uuid") - def __str__(self): return f"{self.service.label}: {self.name or self._name}" @@ -175,32 +148,6 @@ def last_version(self): def ordered_versions(self): return self.versions.order_by("-version") - @property - def url(self): - # zds_client.get_operation_url() can be used here but it increases HTTP overhead - return f"{self.service.api_root}objecttypes/{self.uuid}" - - @property - def versions_url(self): - return f"{self.url}/versions" - - def clean_fields(self, exclude: Iterable[str] | None = None) -> None: - super().clean_fields(exclude=exclude) - - if exclude and "service" in exclude: - return - - with get_objecttypes_client(self.service) as client: - try: - object_type_data = client.get_objecttype(self.uuid) - except (requests.RequestException, ConnectionError, ValueError) as exc: - raise ValidationError(f"Objecttype can't be requested: {exc}") - except requests.exceptions.JSONDecodeError: - raise ValidationError("Object type version didn't have any data") - - if not self._name: - self._name = object_type_data["name"] - class ObjectTypeVersion(models.Model): object_type = models.ForeignKey( @@ -211,16 +158,12 @@ class ObjectTypeVersion(models.Model): ) created_at = models.DateField( _("created at"), - auto_now_add=False, # TODO auto_now_add=True after migration - blank=True, # TODO blank=False after migration - null=True, # TODO null=False after migration + auto_now_add=True, help_text=_("Date when the version was created"), ) modified_at = models.DateField( _("modified at"), - auto_now=False, # TODO auto_now=True after migration - blank=True, # TODO blank=False after migration - null=True, # TODO null=False after migration + auto_now=True, help_text=_("Last date when the version was modified"), ) published_at = models.DateField( diff --git a/src/objects/core/query.py b/src/objects/core/query.py index 0bafcfbf..287798fe 100644 --- a/src/objects/core/query.py +++ b/src/objects/core/query.py @@ -1,15 +1,7 @@ from django.db import models -from vng_api_common.utils import get_uuid_from_path -from zgw_consumers.models import Service - class ObjectTypeQuerySet(models.QuerySet): - def get_by_url(self, url): # TODO remove - service = Service.get_service(url) - uuid = get_uuid_from_path(url) - return self.get(service=service, uuid=uuid) - def create_from_schema(self, json_schema: dict, **kwargs): object_type_data = { "name": json_schema.get("title", "").title(), diff --git a/src/objects/core/tests/factories.py b/src/objects/core/tests/factories.py index 2adc5843..221f36ed 100644 --- a/src/objects/core/tests/factories.py +++ b/src/objects/core/tests/factories.py @@ -1,22 +1,16 @@ import random -import uuid from datetime import date, timedelta from django.contrib.gis.geos import Point import factory from factory.fuzzy import BaseFuzzyAttribute -from zgw_consumers.test.factories import ServiceFactory from objects.core.constants import ReferenceType from ..models import Object, ObjectRecord, ObjectType, ObjectTypeVersion, Reference class ObjectTypeFactory(factory.django.DjangoModelFactory[ObjectType]): - service = factory.SubFactory(ServiceFactory) # TODO remove - uuid = factory.LazyFunction(uuid.uuid4) # TODO remove - _name = factory.Faker("word") # TODO remove - name = factory.Faker("word") name_plural = factory.LazyAttribute(lambda x: f"{x.name}s") description = factory.Faker("bs") diff --git a/src/objects/core/tests/test_upgrade_check.py b/src/objects/core/tests/test_upgrade_check.py new file mode 100644 index 00000000..2919ce78 --- /dev/null +++ b/src/objects/core/tests/test_upgrade_check.py @@ -0,0 +1,106 @@ +import uuid +from unittest.mock import patch + +from django.core.management import call_command +from django.core.management.base import SystemCheckError +from django.test import TestCase, override_settings + +from upgrade_check.models import Version + +from objects.core.tests.factories import ObjectTypeFactory +from objects.token.tests.test_migrations import BaseMigrationTest + + +class TestUpgradeCheck(BaseMigrationTest): + app = "core" + migrate_from = "0036_objecttype_is_imported" + migrate_to = "0037_alter_objecttype_unique_together_and_more" + + def setUp(self): + super().setUp() + + self.ObjectType = self.old_app_state.get_model("core", "ObjectType") + Service = self.old_app_state.get_model("zgw_consumers", "Service") + + self.service = Service.objects.create() + + # patch get_model in command to return the model with is_imported + self.patch = patch( + "objects.core.management.commands.check_for_external_objecttypes.Command._get_objecttype", + return_value=self.ObjectType, + ).start() + + def tearDown(self): + self.patch.stop() + + @override_settings(RELEASE="4.0.0") + def test_upgrade_from_30_to_40(self): + """ + from 3.0.0 directly to 4.0.0 is not allowed, 3.6.0 is the minimum version + + """ + Version.objects.create(version="3.0.0", git_sha="test") + + with self.assertRaises(SystemCheckError): + call_command("check") + + @override_settings(RELEASE="4.0.0") + def test_upgrade_from_36_to_40_with_non_imported_objecttypes(self): + """ + import should fail because non-imported objecttypes exist + """ + Version.objects.create(version="3.6.0", git_sha="test") + self.ObjectType.objects.create( + is_imported=False, uuid=uuid.uuid4(), service=self.service + ) + + with self.assertRaises(SystemCheckError): + call_command("check") + + @override_settings(RELEASE="4.0.0") + def test_upgrade_from_36_to_40_with_all_imported(self): + """ + import should succeed because all objecttypes are imported + """ + Version.objects.create(version="3.6.0", git_sha="test") + self.ObjectType.objects.create( + is_imported=True, uuid=uuid.uuid4(), service=self.service + ) + + call_command("check") + + @override_settings(RELEASE="4.1.0") + def test_upgrade_from_37_to_41_with_non_imported_objecttypes(self): + """ + import should fail because non-imported objecttypes exist + """ + Version.objects.create(version="3.7.0", git_sha="test") + self.ObjectType.objects.create( + is_imported=False, uuid=uuid.uuid4(), service=self.service + ) + + with self.assertRaises(SystemCheckError): + call_command("check") + + @override_settings(RELEASE="4.1.0") + def test_upgrade_from_37_to_41_with_all_imported(self): + """ + import should succeed because all objecttypes are imported + """ + Version.objects.create(version="3.7.0", git_sha="test") + self.ObjectType.objects.create( + is_imported=True, uuid=uuid.uuid4(), service=self.service + ) + + call_command("check") + + +class TestUpgradeCheckAfter40(TestCase): + @override_settings(RELEASE="4.1.0") + def test_upgrade_from_40_to_41_with_all_imported(self): + """ + import should succeed because version is already 4.0.0 + """ + Version.objects.create(version="4.0.0", git_sha="test") + ObjectTypeFactory.create() + call_command("check") From b9de99f38e1af961922abdeffd0797225a8a43d1 Mon Sep 17 00:00:00 2001 From: floris272 Date: Wed, 24 Dec 2025 12:19:59 +0100 Subject: [PATCH 03/15] :fire: [#564] remove objecttype fetch code and fix tests --- docker/setup_configuration/data.yaml | 23 +- performance_test/create_data.py | 1 - src/objects/api/fields.py | 55 --- src/objects/api/kanalen.py | 6 +- src/objects/api/serializers.py | 32 +- src/objects/api/v2/urls.py | 1 + src/objects/api/v2/views.py | 1 - src/objects/api/validators.py | 27 +- src/objects/core/admin.py | 23 +- .../management/commands/import_objecttypes.py | 16 +- src/objects/core/models.py | 6 +- src/objects/core/query.py | 6 + .../files/objecttypes_empty_database.yaml | 10 - .../objecttypes_existing_objecttype.yaml | 10 - .../tests/files/objecttypes_idempotent.yaml | 10 - .../tests/files/objecttypes_invalid_uuid.yaml | 10 - .../files/objecttypes_unknown_service.yaml | 10 - src/objects/core/tests/test_admin.py | 37 +- .../core/tests/test_import_objecttypes.py | 8 +- .../core/tests/test_objecttype_config.py | 173 --------- src/objects/core/utils.py | 41 +-- .../setup_configuration/models/objecttypes.py | 17 - .../setup_configuration/steps/objecttypes.py | 65 ---- .../tests/test_token_auth_config.py | 27 +- src/objects/tests/admin/test_core_views.py | 74 ---- .../tests/admin/test_token_permissions.py | 44 +-- src/objects/tests/v2/test_auth.py | 118 +----- src/objects/tests/v2/test_auth_fields.py | 22 +- src/objects/tests/v2/test_filters.py | 46 +-- src/objects/tests/v2/test_geo_headers.py | 8 +- src/objects/tests/v2/test_geo_search.py | 14 +- src/objects/tests/v2/test_jsonschema.py | 37 +- src/objects/tests/v2/test_metrics.py | 39 +- .../tests/v2/test_notifications_send.py | 55 +-- src/objects/tests/v2/test_object_api.py | 68 +--- .../tests/v2/test_object_api_fields.py | 10 +- src/objects/tests/v2/test_ordering.py | 6 +- src/objects/tests/v2/test_pagination.py | 6 +- src/objects/tests/v2/test_permissions_api.py | 8 +- src/objects/tests/v2/test_stuf.py | 8 +- src/objects/tests/v2/test_validation.py | 339 +++--------------- src/objects/token/admin.py | 17 +- src/objects/token/tests/test_admin.py | 16 +- src/objects/utils/filters.py | 2 +- src/objects/utils/oas_extensions/__init__.py | 3 +- src/objects/utils/oas_extensions/fields.py | 16 - src/objects/utils/serializers.py | 17 +- src/objects/utils/tests/test_client.py | 105 ------ 48 files changed, 268 insertions(+), 1425 deletions(-) delete mode 100644 src/objects/core/tests/files/objecttypes_empty_database.yaml delete mode 100644 src/objects/core/tests/files/objecttypes_existing_objecttype.yaml delete mode 100644 src/objects/core/tests/files/objecttypes_idempotent.yaml delete mode 100644 src/objects/core/tests/files/objecttypes_invalid_uuid.yaml delete mode 100644 src/objects/core/tests/files/objecttypes_unknown_service.yaml delete mode 100644 src/objects/core/tests/test_objecttype_config.py delete mode 100644 src/objects/setup_configuration/models/objecttypes.py delete mode 100644 src/objects/setup_configuration/steps/objecttypes.py delete mode 100644 src/objects/tests/admin/test_core_views.py delete mode 100644 src/objects/utils/tests/test_client.py diff --git a/docker/setup_configuration/data.yaml b/docker/setup_configuration/data.yaml index 54614547..529caf49 100644 --- a/docker/setup_configuration/data.yaml +++ b/docker/setup_configuration/data.yaml @@ -8,15 +8,6 @@ sites_config: zgw_consumers_config_enable: true zgw_consumers: services: - - identifier: objecttypes-api - label: Objecttypes API - api_root: http://objecttypes-web:8000/api/v2/ - api_connection_check_path: objecttypes - api_type: orc - auth_type: api_key - header_key: Authorization - header_value: Token b9f100590925b529664ed9d370f5f8da124b2c20 - - identifier: notifications-api label: Notificaties API api_root: http://notificaties.local/api/v1/ @@ -35,18 +26,6 @@ notifications_config: notification_delivery_retry_backoff_max: 3 -objecttypes_config_enable: true -objecttypes: - items: - - uuid: b427ef84-189d-43aa-9efd-7bb2c459e281 - name: Object Type 1 - service_identifier: objecttypes-api - - - uuid: b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2 - name: Object Type 2 - service_identifier: objecttypes-api - - tokenauth_config_enable: true tokenauth: items: @@ -119,4 +98,4 @@ oidc_db_config_admin_auth: default_groups: [] make_users_staff: true superuser_group_names: - - Registreerders \ No newline at end of file + - Registreerders diff --git a/performance_test/create_data.py b/performance_test/create_data.py index 6edde525..6a839fa5 100644 --- a/performance_test/create_data.py +++ b/performance_test/create_data.py @@ -10,7 +10,6 @@ from objects.token.tests.factories import PermissionFactory, TokenAuthFactory object_type = ObjectTypeFactory.create( - service__api_root="http://localhost:8001/api/v2/", uuid="f1220670-8ab7-44f1-a318-bd0782e97662", ) diff --git a/src/objects/api/fields.py b/src/objects/api/fields.py index e06763c5..b532ee83 100644 --- a/src/objects/api/fields.py +++ b/src/objects/api/fields.py @@ -1,11 +1,5 @@ -from django.core.exceptions import ObjectDoesNotExist, ValidationError -from django.utils.encoding import smart_str -from django.utils.translation import gettext_lazy as _ - from rest_framework import serializers from vng_api_common.serializers import CachedHyperlinkedIdentityField -from vng_api_common.utils import get_uuid_from_path -from zgw_consumers.models import Service from objects.core.models import ObjectRecord @@ -15,7 +9,6 @@ def get_queryset(self): queryset = ObjectRecord.objects.select_related( "object", "object__object_type", - "object__object_type__service", "correct", "corrected", ).order_by("-pk") @@ -27,54 +20,6 @@ def get_queryset(self): return queryset.filter(object=record_instance.object) -class ObjectTypeField(serializers.RelatedField): - default_error_messages = { - "max_length": _("The value has too many characters"), - "min_length": _("The value has too few characters"), - "does_not_exist": _("ObjectType with url={value} is not configured."), - "invalid": _("Invalid value."), - } - - def __init__(self, **kwargs): - self.max_length = kwargs.pop("max_length", None) - self.min_length = kwargs.pop("min_length", None) - - super().__init__(**kwargs) - - def to_internal_value(self, data): - if self.max_length and len(data) > self.max_length: - self.fail("max_length") - - if self.min_length and len(data) < self.min_length: - self.fail("min_length") - - try: - return self.get_queryset().get_by_url(data) - except ObjectDoesNotExist: - # if service is configured, but object_type is missing - # let's try to create an ObjectType - service = Service.get_service(data) - if not service: - self.fail("does_not_exist", value=smart_str(data)) - - uuid = get_uuid_from_path(data) - object_type = self.get_queryset().model(service=service, uuid=uuid) - - try: - object_type.clean() - except ValidationError: - self.fail("does_not_exist", value=smart_str(data)) - - object_type.save() - return object_type - - except (TypeError, ValueError): - self.fail("invalid") - - def to_representation(self, obj): - return obj.url - - class ObjectUrlField(serializers.HyperlinkedIdentityField): lookup_field = "uuid" diff --git a/src/objects/api/kanalen.py b/src/objects/api/kanalen.py index 86e9b660..a91580e9 100644 --- a/src/objects/api/kanalen.py +++ b/src/objects/api/kanalen.py @@ -7,6 +7,7 @@ from rest_framework.request import Request from objects.core.models import ObjectRecord +from objects.tests.v2.utils import reverse class ObjectKanaal(Kanaal): @@ -32,7 +33,10 @@ def get_kenmerken( data = data or {} return { kenmerk: ( - data.get("type") or obj._object_type.url + data.get("type") + or request.build_absolute_url( + reverse("objecttype-detail", args=[data.object_type.uuid]) + ) if kenmerk == "object_type" else data.get(kenmerk, getattr(obj, kenmerk)) ) diff --git a/src/objects/api/serializers.py b/src/objects/api/serializers.py index 0b41b41b..0a28fe9f 100644 --- a/src/objects/api/serializers.py +++ b/src/objects/api/serializers.py @@ -14,12 +14,13 @@ from objects.token.models import Permission, TokenAuth from objects.utils.serializers import DynamicFieldsMixin -from .fields import CachedObjectUrlField, ObjectSlugRelatedField, ObjectTypeField +from .fields import CachedObjectUrlField, ObjectSlugRelatedField from .utils import merge_patch from .validators import ( GeometryValidator, IsImmutableValidator, JsonSchemaValidator, + ObjectTypeSchemaValidator, VersionUpdateValidator, ) @@ -42,11 +43,11 @@ class Meta: "publishedAt", ) extra_kwargs = { - # "url": {"lookup_field": "version"}, + "url": {"lookup_field": "version"}, "version": {"read_only": True}, "objectType": { "source": "object_type", - # "lookup_field": "uuid", + "lookup_field": "uuid", "read_only": True, }, "jsonSchema": { @@ -97,7 +98,7 @@ class ObjectTypeSerializer(serializers.HyperlinkedModelSerializer): versions = NestedHyperlinkedRelatedField( many=True, read_only=True, - # lookup_field="version", + lookup_field="version", view_name="objecttypeversion-detail", parent_lookup_kwargs={"objecttype_uuid": "object_type__uuid"}, help_text=_("list of URLs for the OBJECTTYPE versions"), @@ -128,7 +129,7 @@ class Meta: "versions", ) extra_kwargs = { - # "url": {"lookup_field": "uuid"}, + "url": {"lookup_field": "uuid"}, "uuid": {"validators": [IsImmutableValidator()]}, "namePlural": {"source": "name_plural"}, "dataClassification": {"source": "data_classification"}, @@ -239,12 +240,13 @@ class ObjectSerializer(DynamicFieldsMixin, serializers.HyperlinkedModelSerialize ], help_text=_("Unique identifier (UUID4)"), ) - type = ObjectTypeField( - min_length=1, - max_length=1000, + type = serializers.HyperlinkedRelatedField( source="_object_type", queryset=ObjectType.objects.all(), - help_text=_("Url reference to OBJECTTYPE in Objecttypes API"), + view_name="objecttype-detail", + help_text=_("Url reference to OBJECTTYPE"), + lookup_url_kwarg="uuid", + lookup_field="uuid", validators=[IsImmutableValidator()], ) record = ObjectRecordSerializer( @@ -257,7 +259,7 @@ class Meta: extra_kwargs = { "url": {"lookup_field": "object.uuid"}, } - validators = [JsonSchemaValidator(), GeometryValidator()] + validators = [ObjectTypeSchemaValidator(), GeometryValidator()] @transaction.atomic def create(self, validated_data): @@ -332,12 +334,14 @@ class ObjectSearchSerializer(serializers.Serializer): class PermissionSerializer(serializers.ModelSerializer): - type = ObjectTypeField( - min_length=1, - max_length=1000, + type = serializers.HyperlinkedRelatedField( source="object_type", queryset=ObjectType.objects.all(), - help_text=_("Url reference to OBJECTTYPE in Objecttypes API"), + view_name="objecttype-detail", + help_text=_("Url reference to OBJECTTYPE"), + lookup_url_kwarg="uuid", + lookup_field="uuid", + validators=[IsImmutableValidator()], ) class Meta: diff --git a/src/objects/api/v2/urls.py b/src/objects/api/v2/urls.py index 0195bc1c..84e73267 100644 --- a/src/objects/api/v2/urls.py +++ b/src/objects/api/v2/urls.py @@ -23,6 +23,7 @@ r"objecttypes", ObjectTypeViewSet, [routers.Nested("versions", ObjectTypeVersionViewSet)], + basename="objecttype", ) router.register(r"objects", ObjectViewSet, basename="object") diff --git a/src/objects/api/v2/views.py b/src/objects/api/v2/views.py index f6a4f8fc..a9635694 100644 --- a/src/objects/api/v2/views.py +++ b/src/objects/api/v2/views.py @@ -285,7 +285,6 @@ class ObjectViewSet( queryset = ( ObjectRecord.objects.select_related( "_object_type", - "_object_type__service", "correct", "corrected", ) diff --git a/src/objects/api/validators.py b/src/objects/api/validators.py index 52ca5c76..6d7d5c02 100644 --- a/src/objects/api/validators.py +++ b/src/objects/api/validators.py @@ -1,12 +1,10 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ -import requests from rest_framework import serializers from rest_framework.fields import get_attribute -from objects.core.utils import check_objecttype_cached -from objects.utils.client import get_objecttypes_client +from objects.core.utils import check_json_schema, check_objecttype from ..core.constants import ObjectTypeVersionStatus from .constants import Operators @@ -29,6 +27,16 @@ def __call__(self, attrs, serializer): class JsonSchemaValidator: code = "invalid-json-schema" + + def __call__(self, value): + try: + check_json_schema(value) + except ValidationError as exc: + raise serializers.ValidationError(exc.args[0], code=self.code) from exc + + +class ObjectTypeSchemaValidator: + code = "invalid-json-schema" requires_context = True def __call__(self, attrs, serializer): @@ -56,7 +64,7 @@ def __call__(self, attrs, serializer): if not object_type or not version: return try: - check_objecttype_cached(object_type, version, data) + check_objecttype(object_type, version, data) except ValidationError as exc: raise serializers.ValidationError(exc.args[0], code=self.code) from exc @@ -145,14 +153,5 @@ def __call__(self, attrs, serializer): if not geometry: return - with get_objecttypes_client(object_type.service) as client: - try: - response_data = client.get_objecttype(object_type.uuid) - except requests.RequestException as exc: - msg = f"Object type can not be retrieved: {exc.args[0]}" - raise ValidationError(msg) - - allow_geometry = response_data.get("allowGeometry", True) - - if geometry and not allow_geometry: + if geometry and not object_type.allow_geometry: raise serializers.ValidationError(self.message, code=self.code) diff --git a/src/objects/core/admin.py b/src/objects/core/admin.py index 8eaf9784..95c7b35c 100644 --- a/src/objects/core/admin.py +++ b/src/objects/core/admin.py @@ -4,7 +4,6 @@ from django import forms from django.conf import settings from django.contrib import admin, messages -from django.contrib.admin import SimpleListFilter from django.contrib.gis.db.models import GeometryField from django.db import models from django.http import HttpRequest, HttpResponseRedirect @@ -246,26 +245,6 @@ def get_corrected_by(self, obj): get_corrected_by.short_description = "corrected by" -class ObjectTypeFilter(SimpleListFilter): - """ - List filters do not use `ModelAdmin.list_select_related` unfortunately, so to avoid - additional queries for each ObjectType.service, the filter's queryset is explicitly - overridden - """ - - title = "object type" - parameter_name = "object_type__id__exact" - - def lookups(self, request, model_admin): - qs = ObjectType.objects.select_related("service") - return [(ot.pk, str(ot)) for ot in qs] - - def queryset(self, request, queryset): - if self.value(): - return queryset.filter(object_type__id=self.value()) - return queryset - - @admin.register(Object) class ObjectAdmin(admin.ModelAdmin): list_display = ( @@ -279,7 +258,7 @@ class ObjectAdmin(admin.ModelAdmin): ) search_fields = ("uuid",) inlines = (ObjectRecordInline,) - list_filter = (ObjectTypeFilter, "created_on", "modified_on") + list_filter = ("object_type", "created_on", "modified_on") def get_search_fields(self, request: HttpRequest) -> Sequence[str]: if settings.OBJECTS_ADMIN_SEARCH_DISABLED: diff --git a/src/objects/core/management/commands/import_objecttypes.py b/src/objects/core/management/commands/import_objecttypes.py index d7c16be4..c1c9a1fb 100644 --- a/src/objects/core/management/commands/import_objecttypes.py +++ b/src/objects/core/management/commands/import_objecttypes.py @@ -36,7 +36,7 @@ def handle(self, *args, **options): self._check_objecttypes_api_version(client) objecttypes = client.list_objecttypes() - data = self._parse_objecttype_data(objecttypes, service) + data = self._parse_objecttype_data(objecttypes) self._bulk_create_or_update_objecttypes(data) self.stdout.write("Successfully imported %s objecttypes" % len(data)) @@ -83,11 +83,8 @@ def _bulk_create_or_update_objecttypes(self, data): update_conflicts=True, # Updates existing Objecttypes based on unique_fields unique_fields=[ "uuid", - "service", - ], # TODO remove service from unique_fields after objecttype migration since it will no longer be part of the ObjectType model. + ], update_fields=[ - "is_imported", - "_name", "name", "name_plural", "description", @@ -125,17 +122,14 @@ def _bulk_create_or_update_objecttype_versions(self, data): ) def _parse_objecttype_data( - self, objecttypes: list[dict[str, Any]], service: Service + self, objecttypes: list[dict[str, Any]] ) -> list[ObjectType]: data = [] for objecttype in objecttypes: - objecttype.pop("versions") - objecttype.pop("url") # This attribute was added in 3.4.0 but removed in 3.4.1 objecttype.pop("linkableToZaken", None) - objecttype["service"] = service - objecttype["is_imported"] = True - objecttype["_name"] = objecttype["name"] + objecttype.pop("versions") + objecttype.pop("url") data.append(ObjectType(**underscoreize(objecttype))) return data diff --git a/src/objects/core/models.py b/src/objects/core/models.py index 695e8c36..b5978eff 100644 --- a/src/objects/core/models.py +++ b/src/objects/core/models.py @@ -17,7 +17,7 @@ UpdateFrequencyChoices, ) from .query import ObjectQuerySet, ObjectRecordQuerySet, ObjectTypeQuerySet -from .utils import check_json_schema, check_objecttype_cached +from .utils import check_json_schema, check_objecttype class ObjectType(models.Model): @@ -135,7 +135,7 @@ class ObjectType(models.Model): objects = ObjectTypeQuerySet.as_manager() def __str__(self): - return f"{self.service.label}: {self.name or self._name}" + return f"{self.name}" @property def last_version(self): @@ -352,7 +352,7 @@ def clean(self): super().clean() if hasattr(self.object, "object_type") and self.version and self.data: - check_objecttype_cached(self.object.object_type, self.version, self.data) + check_objecttype(self.object.object_type, self.version, self.data) def save(self, *args, **kwargs): if not self.id and self.object.last_record: diff --git a/src/objects/core/query.py b/src/objects/core/query.py index 287798fe..ad4e2582 100644 --- a/src/objects/core/query.py +++ b/src/objects/core/query.py @@ -1,7 +1,13 @@ from django.db import models +from vng_api_common.utils import get_uuid_from_path + class ObjectTypeQuerySet(models.QuerySet): + def get_by_url(self, url): + uuid = get_uuid_from_path(url) + return self.get(uuid=uuid) + def create_from_schema(self, json_schema: dict, **kwargs): object_type_data = { "name": json_schema.get("title", "").title(), diff --git a/src/objects/core/tests/files/objecttypes_empty_database.yaml b/src/objects/core/tests/files/objecttypes_empty_database.yaml deleted file mode 100644 index b969949e..00000000 --- a/src/objects/core/tests/files/objecttypes_empty_database.yaml +++ /dev/null @@ -1,10 +0,0 @@ -objecttypes_config_enable: true -objecttypes: - items: - - uuid: b427ef84-189d-43aa-9efd-7bb2c459e281 - name: Object Type 1 - service_identifier: service-1 - - - uuid: b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2 - name: Object Type 2 - service_identifier: service-2 diff --git a/src/objects/core/tests/files/objecttypes_existing_objecttype.yaml b/src/objects/core/tests/files/objecttypes_existing_objecttype.yaml deleted file mode 100644 index f93e005f..00000000 --- a/src/objects/core/tests/files/objecttypes_existing_objecttype.yaml +++ /dev/null @@ -1,10 +0,0 @@ -objecttypes_config_enable: true -objecttypes: - items: - - uuid: b427ef84-189d-43aa-9efd-7bb2c459e281 - name: Object Type 1 - service_identifier: service-1 - - - uuid: 7229549b-7b41-47d1-8106-414b2a69751b - name: Object Type 3 - service_identifier: service-2 diff --git a/src/objects/core/tests/files/objecttypes_idempotent.yaml b/src/objects/core/tests/files/objecttypes_idempotent.yaml deleted file mode 100644 index b969949e..00000000 --- a/src/objects/core/tests/files/objecttypes_idempotent.yaml +++ /dev/null @@ -1,10 +0,0 @@ -objecttypes_config_enable: true -objecttypes: - items: - - uuid: b427ef84-189d-43aa-9efd-7bb2c459e281 - name: Object Type 1 - service_identifier: service-1 - - - uuid: b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2 - name: Object Type 2 - service_identifier: service-2 diff --git a/src/objects/core/tests/files/objecttypes_invalid_uuid.yaml b/src/objects/core/tests/files/objecttypes_invalid_uuid.yaml deleted file mode 100644 index 2a360c8e..00000000 --- a/src/objects/core/tests/files/objecttypes_invalid_uuid.yaml +++ /dev/null @@ -1,10 +0,0 @@ -objecttypes_config_enable: true -objecttypes: - items: - - uuid: b427ef84-189d-43aa-9efd-7bb2c459e281 - name: Object Type 1 - service_identifier: service-1 - - - uuid: foobar - name: Object Type 2 - service_identifier: service-1 diff --git a/src/objects/core/tests/files/objecttypes_unknown_service.yaml b/src/objects/core/tests/files/objecttypes_unknown_service.yaml deleted file mode 100644 index 8348427c..00000000 --- a/src/objects/core/tests/files/objecttypes_unknown_service.yaml +++ /dev/null @@ -1,10 +0,0 @@ -objecttypes_config_enable: true -objecttypes: - items: - - uuid: b427ef84-189d-43aa-9efd-7bb2c459e281 - name: Object Type 1 - service_identifier: unknown - - - uuid: b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2 - name: Object Type 2 - service_identifier: service-1 diff --git a/src/objects/core/tests/test_admin.py b/src/objects/core/tests/test_admin.py index 70aaec31..56d63957 100644 --- a/src/objects/core/tests/test_admin.py +++ b/src/objects/core/tests/test_admin.py @@ -3,19 +3,16 @@ from django.test import override_settings, tag from django.urls import reverse -import requests_mock from django_webtest import WebTest from maykin_2fa.test import disable_admin_mfa -from zgw_consumers.constants import AuthTypes -from zgw_consumers.test.factories import ServiceFactory from objects.accounts.tests.factories import UserFactory from objects.core.tests.factories import ( ObjectFactory, ObjectRecordFactory, ObjectTypeFactory, + ObjectTypeVersionFactory, ) -from objects.tests.utils import mock_objecttype_version @disable_admin_mfa() @@ -27,16 +24,8 @@ def setUp(self): @tag("gh-615") def test_object_changelist_filter_by_objecttype(self): - service = ServiceFactory.create( - api_root="http://objecttypes.local/api/v1/", - auth_type=AuthTypes.api_key, - header_key="Authorization", - header_value="Token 5cebbb33ffa725b6ed5e9e98300061218ba98d71", - ) - object_type = ObjectTypeFactory.create( - service=service, uuid="71a2452a-66c3-4030-b5ec-a06035102e9e" - ) - # Create 100 unused ObjectTypes, which creates 100 Services as well + object_type = ObjectTypeFactory.create(uuid="71a2452a-66c3-4030-b5ec-a06035102e9e") + # Create 100 unused ObjectTypes ObjectTypeFactory.create_batch(100) object1 = ObjectFactory.create(object_type=object_type) object2 = ObjectFactory.create() @@ -94,19 +83,8 @@ def get_num_results(response) -> int: @tag("gh-677") def test_add_new_objectrecord(self): - service = ServiceFactory.create( - api_root="http://objecttypes.local/api/v1/", - auth_type=AuthTypes.api_key, - header_key="Authorization", - header_value="Token 5cebbb33ffa725b6ed5e9e98300061218ba98d71", - ) - object_type = ObjectTypeFactory.create( - service=service, uuid="71a2452a-66c3-4030-b5ec-a06035102e9e" - ) - object_type_url = ( - "http://objecttypes.local/api/v1/" - "objecttypes/71a2452a-66c3-4030-b5ec-a06035102e9e/versions/1" - ) + object_type = ObjectTypeFactory.create(uuid="71a2452a-66c3-4030-b5ec-a06035102e9e") + ObjectTypeVersionFactory.create(object_type=object_type) object = ObjectFactory.create(object_type=object_type) self.assertEqual(object.records.count(), 0) @@ -125,10 +103,7 @@ def test_add_new_objectrecord(self): form["records-0-version"] = 1 form["records-0-start_at"] = "2025-01-01" - with requests_mock.Mocker() as m: - m.get(object_type_url, json=mock_objecttype_version(object_type_url)) - response = form.submit() - + form.submit() self.assertEqual(object.records.count(), 1) @tag("gh-621") diff --git a/src/objects/core/tests/test_import_objecttypes.py b/src/objects/core/tests/test_import_objecttypes.py index faf741a7..d04947d6 100644 --- a/src/objects/core/tests/test_import_objecttypes.py +++ b/src/objects/core/tests/test_import_objecttypes.py @@ -4,6 +4,7 @@ from django.test import TestCase import requests_mock +from freezegun import freeze_time from zgw_consumers.models import Service from objects.core.models import ObjectType, ObjectTypeVersion @@ -14,6 +15,7 @@ ) +@freeze_time("2020-12-01") class TestImportObjectTypesCommand(TestCase): def setUp(self): super().setUp() @@ -74,7 +76,6 @@ def test_new_objecttypes_are_created(self): self.assertEqual(ObjectType.objects.count(), 2) objecttype = ObjectType.objects.get(uuid=uuid1) - self.assertEqual(objecttype.is_imported, True) self.assertEqual(objecttype.name, "Melding") self.assertEqual(objecttype._name, "Melding") self.assertEqual(objecttype.name_plural, "Meldingen") @@ -117,8 +118,8 @@ def test_new_objecttypes_are_created(self): self.assertEqual(str(version.status), "published") def test_existing_objecttypes_are_updated(self): - objecttype1 = ObjectTypeFactory.create(service=self.service) - objecttype2 = ObjectTypeFactory.create(service=self.service) + objecttype1 = ObjectTypeFactory.create() + objecttype2 = ObjectTypeFactory.create() self.m.get( f"{self.url}objecttypes", @@ -138,7 +139,6 @@ def test_existing_objecttypes_are_updated(self): self.assertEqual(ObjectTypeVersion.objects.count(), 4) objecttype = ObjectType.objects.get(uuid=objecttype1.uuid) - self.assertEqual(objecttype.is_imported, True) self.assertEqual(objecttype.name, "Melding") self.assertEqual(objecttype._name, "Melding") diff --git a/src/objects/core/tests/test_objecttype_config.py b/src/objects/core/tests/test_objecttype_config.py deleted file mode 100644 index 5588f1d5..00000000 --- a/src/objects/core/tests/test_objecttype_config.py +++ /dev/null @@ -1,173 +0,0 @@ -from pathlib import Path - -from django.db.models import QuerySet -from django.test import TestCase - -from django_setup_configuration.exceptions import ConfigurationRunFailed -from django_setup_configuration.test_utils import execute_single_step -from zgw_consumers.models import Service -from zgw_consumers.test.factories import ServiceFactory - -from objects.core.models import ObjectType -from objects.core.tests.factories import ObjectTypeFactory -from objects.setup_configuration.steps.objecttypes import ObjectTypesConfigurationStep - -TEST_FILES = (Path(__file__).parent / "files").resolve() - - -class ObjectTypesConfigurationStepTests(TestCase): - def test_empty_database(self): - service_1 = ServiceFactory.create(slug="service-1") - service_2 = ServiceFactory.create(slug="service-2") - - test_file_path = str(TEST_FILES / "objecttypes_empty_database.yaml") - - execute_single_step(ObjectTypesConfigurationStep, yaml_source=test_file_path) - - objecttypes: QuerySet[ObjectType] = ObjectType.objects.order_by("_name") - - self.assertEqual(objecttypes.count(), 2) - - objecttype_1: ObjectType = objecttypes.first() - - self.assertEqual(str(objecttype_1.uuid), "b427ef84-189d-43aa-9efd-7bb2c459e281") - self.assertEqual(objecttype_1._name, "Object Type 1") - self.assertEqual(objecttype_1.service, service_1) - - objecttype_2: ObjectType = objecttypes.last() - - self.assertEqual(str(objecttype_2.uuid), "b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2") - self.assertEqual(objecttype_2._name, "Object Type 2") - self.assertEqual(objecttype_2.service, service_2) - - def test_existing_objecttype(self): - test_file_path = str(TEST_FILES / "objecttypes_existing_objecttype.yaml") - - service_1: Service = ServiceFactory.create(slug="service-1") - service_2: Service = ServiceFactory.create(slug="service-2") - - objecttype_1: ObjectType = ObjectTypeFactory.create( - service=service_1, - uuid="b427ef84-189d-43aa-9efd-7bb2c459e281", - _name="Object Type 001", - ) - objecttype_2: ObjectType = ObjectTypeFactory.create( - service=service_2, - uuid="b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2", - _name="Object Type 002", - ) - - execute_single_step(ObjectTypesConfigurationStep, yaml_source=test_file_path) - - self.assertEqual(ObjectType.objects.count(), 3) - - objecttype_1.refresh_from_db() - - self.assertEqual(str(objecttype_1.uuid), "b427ef84-189d-43aa-9efd-7bb2c459e281") - self.assertEqual(objecttype_1._name, "Object Type 1") - self.assertEqual(objecttype_1.service, service_1) - - objecttype_2.refresh_from_db() - - self.assertEqual(str(objecttype_2.uuid), "b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2") - self.assertEqual(objecttype_2._name, "Object Type 002") - self.assertEqual(objecttype_2.service, service_2) - - objecttype_3: ObjectType = ObjectType.objects.get( - uuid="7229549b-7b41-47d1-8106-414b2a69751b" - ) - - self.assertEqual(str(objecttype_3.uuid), "7229549b-7b41-47d1-8106-414b2a69751b") - self.assertEqual(objecttype_3._name, "Object Type 3") - self.assertEqual(objecttype_3.service, service_2) - - def test_unknown_service(self): - service = ServiceFactory.create(slug="service-1") - - objecttype: ObjectType = ObjectTypeFactory.create( - uuid="b427ef84-189d-43aa-9efd-7bb2c459e281", - _name="Object Type 001", - service=service, - ) - - test_file_path = str(TEST_FILES / "objecttypes_unknown_service.yaml") - - with self.assertRaises(ConfigurationRunFailed): - execute_single_step( - ObjectTypesConfigurationStep, yaml_source=test_file_path - ) - - self.assertEqual(ObjectType.objects.count(), 1) - - objecttype.refresh_from_db() - - self.assertEqual(str(objecttype.uuid), "b427ef84-189d-43aa-9efd-7bb2c459e281") - self.assertEqual(objecttype._name, "Object Type 001") - self.assertEqual(objecttype.service, service) - - def test_invalid_uuid(self): - test_file_path = str(TEST_FILES / "objecttypes_invalid_uuid.yaml") - - service: Service = ServiceFactory.create(slug="service-1") - - objecttype: ObjectType = ObjectTypeFactory.create( - service=service, - uuid="b427ef84-189d-43aa-9efd-7bb2c459e281", - _name="Object Type 001", - ) - - with self.assertRaises(ConfigurationRunFailed): - execute_single_step( - ObjectTypesConfigurationStep, yaml_source=test_file_path - ) - - self.assertEqual(ObjectType.objects.count(), 1) - - objecttype.refresh_from_db() - - self.assertEqual(str(objecttype.uuid), "b427ef84-189d-43aa-9efd-7bb2c459e281") - # Name should not be changed, because the error causes a rollback - self.assertEqual(objecttype._name, "Object Type 001") - self.assertEqual(objecttype.service, service) - - def test_idempotent_step(self): - service_1 = ServiceFactory.create(slug="service-1") - service_2 = ServiceFactory.create(slug="service-2") - - test_file_path = str(TEST_FILES / "objecttypes_idempotent.yaml") - - execute_single_step(ObjectTypesConfigurationStep, yaml_source=test_file_path) - - objecttypes: QuerySet[ObjectType] = ObjectType.objects.order_by("_name") - - self.assertEqual(objecttypes.count(), 2) - - objecttype_1: ObjectType = objecttypes.first() - - self.assertEqual(str(objecttype_1.uuid), "b427ef84-189d-43aa-9efd-7bb2c459e281") - self.assertEqual(objecttype_1._name, "Object Type 1") - self.assertEqual(objecttype_1.service, service_1) - - objecttype_2: ObjectType = objecttypes.last() - - self.assertEqual(str(objecttype_2.uuid), "b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2") - self.assertEqual(objecttype_2._name, "Object Type 2") - self.assertEqual(objecttype_2.service, service_2) - - # Rerun - execute_single_step(ObjectTypesConfigurationStep, yaml_source=test_file_path) - - objecttype_1.refresh_from_db() - objecttype_2.refresh_from_db() - - self.assertEqual(ObjectType.objects.count(), 2) - - # objecttype 1 - self.assertEqual(str(objecttype_1.uuid), "b427ef84-189d-43aa-9efd-7bb2c459e281") - self.assertEqual(objecttype_1._name, "Object Type 1") - self.assertEqual(objecttype_1.service, service_1) - - # objecttype 2 - self.assertEqual(str(objecttype_2.uuid), "b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2") - self.assertEqual(objecttype_2._name, "Object Type 2") - self.assertEqual(objecttype_2.service, service_2) diff --git a/src/objects/core/utils.py b/src/objects/core/utils.py index 3879d711..0b6965a5 100644 --- a/src/objects/core/utils.py +++ b/src/objects/core/utils.py @@ -1,58 +1,27 @@ -from django.conf import settings from django.core.exceptions import ValidationError import jsonschema -import requests from jsonschema.exceptions import SchemaError from jsonschema.validators import validator_for from objects.core import models -from objects.utils.cache import cache -from objects.utils.client import get_objecttypes_client -def check_objecttype_cached( +def check_objecttype( object_type: "models.ObjectType", version: int, data: dict ) -> None: - @cache( - f"objecttypen-{object_type.uuid}:versions-{version}", - timeout=settings.OBJECTTYPE_VERSION_CACHE_TIMEOUT, - ) - def get_objecttype_version_response(): - with get_objecttypes_client(object_type.service) as client: - try: - return client.get_objecttype_version(object_type.uuid, version) - except (requests.RequestException, requests.JSONDecodeError): - raise ValidationError( - "Object type version can not be retrieved.", - code="invalid", - ) - try: - version_data = get_objecttype_version_response() - jsonschema.validate(data, version_data["jsonSchema"]) - except KeyError: + version_data = object_type.versions.get(version=version) + jsonschema.validate(data, version_data.json_schema) + except models.ObjectTypeVersion.DoesNotExist: raise ValidationError( - f"{object_type.versions_url} does not appear to be a valid objecttype.", + f"{object_type} version: {version} does not appear to exist.", code="invalid_key", ) except jsonschema.exceptions.ValidationError as exc: raise ValidationError(exc.args[0], code="invalid_jsonschema") -def can_connect_to_objecttypes() -> bool: - """ - check that all services of objecttypes are available - """ - from zgw_consumers.models import Service - - for service in Service.objects.filter(object_types__isnull=False).distinct(): - with get_objecttypes_client(service) as client: - if not client.can_connect: - return False - return True - - def check_json_schema(json_schema: dict): schema_validator = validator_for(json_schema) try: diff --git a/src/objects/setup_configuration/models/objecttypes.py b/src/objects/setup_configuration/models/objecttypes.py deleted file mode 100644 index 4125848a..00000000 --- a/src/objects/setup_configuration/models/objecttypes.py +++ /dev/null @@ -1,17 +0,0 @@ -from django_setup_configuration.fields import DjangoModelRef -from django_setup_configuration.models import ConfigurationModel -from zgw_consumers.models import Service - -from objects.core.models import ObjectType - - -class ObjectTypeConfigurationModel(ConfigurationModel): - service_identifier: str = DjangoModelRef(Service, "slug") - name: str = DjangoModelRef(ObjectType, "_name") - - class Meta: - django_model_refs = {ObjectType: ("uuid",)} - - -class ObjectTypesConfigurationModel(ConfigurationModel): - items: list[ObjectTypeConfigurationModel] diff --git a/src/objects/setup_configuration/steps/objecttypes.py b/src/objects/setup_configuration/steps/objecttypes.py deleted file mode 100644 index 75ffddf4..00000000 --- a/src/objects/setup_configuration/steps/objecttypes.py +++ /dev/null @@ -1,65 +0,0 @@ -from django.core.exceptions import ValidationError -from django.db import IntegrityError - -from django_setup_configuration.configuration import BaseConfigurationStep -from django_setup_configuration.exceptions import ConfigurationRunFailed -from zgw_consumers.models import Service - -from objects.core.models import ObjectType -from objects.setup_configuration.models.objecttypes import ObjectTypesConfigurationModel - - -class ObjectTypesConfigurationStep(BaseConfigurationStep): - """ - Configure references to objecttypes in the Objecttypes API. - - .. note:: Note that these objecttypes references should match instances in the Objecttypes API. Currently - there is no configuration step to do this automatically, so these have to be configured - manually or by loading fixtures. - """ - - config_model = ObjectTypesConfigurationModel - verbose_name = "Objecttypes Configuration" - - namespace = "objecttypes" - enable_setting = "objecttypes_config_enable" - - def execute(self, model: ObjectTypesConfigurationModel) -> None: - for item in model.items: - try: - service = Service.objects.get(slug=item.service_identifier) - except Service.DoesNotExist: - raise ConfigurationRunFailed( - f"No service found with identifier {item.service_identifier}" - ) - - objecttype_kwargs = dict( - service=service, - uuid=item.uuid, - _name=item.name, - ) - - objecttype_instance = ObjectType(**objecttype_kwargs) - - try: - objecttype_instance.full_clean( - exclude=("id", "service"), validate_unique=False - ) - except ValidationError as exception: - exception_message = ( - f"Validation error(s) occured for objecttype {item.uuid}." - ) - raise ConfigurationRunFailed(exception_message) from exception - - try: - ObjectType.objects.update_or_create( - uuid=item.uuid, - defaults={ - key: value - for key, value in objecttype_kwargs.items() - if key != "uuid" - }, - ) - except IntegrityError as exception: - exception_message = f"Failed configuring ObjectType {item.uuid}." - raise ConfigurationRunFailed(exception_message) from exception diff --git a/src/objects/setup_configuration/tests/test_token_auth_config.py b/src/objects/setup_configuration/tests/test_token_auth_config.py index 83d3c599..582f67e4 100644 --- a/src/objects/setup_configuration/tests/test_token_auth_config.py +++ b/src/objects/setup_configuration/tests/test_token_auth_config.py @@ -7,8 +7,6 @@ PrerequisiteFailed, ) from django_setup_configuration.test_utils import execute_single_step -from zgw_consumers.models import Service -from zgw_consumers.test.factories import ServiceFactory from objects.core.models import ObjectType from objects.core.tests.factories import ObjectTypeFactory @@ -21,21 +19,17 @@ class TokenTestCase(TestCase): def setUp(self): - self.service = ServiceFactory.create(slug="service") ObjectTypeFactory.create( - service=self.service, uuid="3a82fb7f-fc9b-4104-9804-993f639d6d0d", - _name="Object Type 001", + name="Object Type 001", ) ObjectTypeFactory.create( - service=self.service, uuid="ca754b52-3f37-4c49-837c-130e8149e337", - _name="Object Type 002", + name="Object Type 002", ) ObjectTypeFactory.create( - service=self.service, uuid="feeaa795-d212-4fa2-bb38-2c34996e5702", - _name="Object Type 003", + name="Object Type 003", ) @@ -435,7 +429,6 @@ class TokenAuthConfigurationStepWithPermissionsTests(TokenTestCase): def test_valid_setup_default_without_permissions(self): self.assertEqual(TokenAuth.objects.count(), 0) self.assertEqual(Permission.objects.count(), 0) - self.assertEqual(Service.objects.count(), 1) self.assertEqual(ObjectType.objects.count(), 3) execute_single_step( @@ -470,7 +463,6 @@ def test_valid_setup_default_without_permissions(self): def test_valid_setup_complete(self): self.assertEqual(TokenAuth.objects.count(), 0) self.assertEqual(Permission.objects.count(), 0) - self.assertEqual(Service.objects.count(), 1) self.assertEqual(ObjectType.objects.count(), 3) execute_single_step( @@ -493,7 +485,7 @@ def test_valid_setup_complete(self): self.assertEqual(token.object_types.count(), 2) self.assertEqual(token_permissions.count(), 2) object_type = ObjectType.objects.get( - uuid="3a82fb7f-fc9b-4104-9804-993f639d6d0d", service=self.service + uuid="3a82fb7f-fc9b-4104-9804-993f639d6d0d" ) permission = token_permissions.get(object_type=object_type) self.assertTrue(object_type in token.object_types.all()) @@ -507,7 +499,7 @@ def test_valid_setup_complete(self): self.assertTrue("record__data__leeftijd" in permission.fields["1"]) self.assertTrue("record__data__kiemjaar" in permission.fields["1"]) object_type = ObjectType.objects.get( - uuid="ca754b52-3f37-4c49-837c-130e8149e337", service=self.service + uuid="ca754b52-3f37-4c49-837c-130e8149e337" ) permission = token_permissions.get(object_type=object_type) self.assertTrue(object_type in token.object_types.all()) @@ -528,7 +520,7 @@ def test_valid_setup_complete(self): self.assertEqual(token.permissions.count(), 1) self.assertEqual(token.object_types.count(), 1) object_type = ObjectType.objects.get( - uuid="feeaa795-d212-4fa2-bb38-2c34996e5702", service=self.service + uuid="feeaa795-d212-4fa2-bb38-2c34996e5702" ) permission = token_permissions.get(object_type=object_type) self.assertTrue(object_type in token.object_types.all()) @@ -584,7 +576,7 @@ def test_valid_update_permissions(self): self.assertEqual(token.permissions.count(), 1) self.assertEqual(token.object_types.count(), 1) object_type = ObjectType.objects.get( - uuid="3a82fb7f-fc9b-4104-9804-993f639d6d0d", service=self.service + uuid="3a82fb7f-fc9b-4104-9804-993f639d6d0d" ) permission = token.permissions.get(object_type=object_type) self.assertTrue(object_type in token.object_types.all()) @@ -623,7 +615,6 @@ def test_valid_update_permissions(self): def test_valid_idempotent_step(self): self.assertEqual(TokenAuth.objects.count(), 0) self.assertEqual(Permission.objects.count(), 0) - self.assertEqual(Service.objects.count(), 1) self.assertEqual(ObjectType.objects.count(), 3) execute_single_step( @@ -647,7 +638,7 @@ def test_valid_idempotent_step(self): self.assertEqual(old_token.object_types.count(), 2) self.assertEqual(old_token_permissions.count(), 2) object_type = ObjectType.objects.get( - uuid="3a82fb7f-fc9b-4104-9804-993f639d6d0d", service=self.service + uuid="3a82fb7f-fc9b-4104-9804-993f639d6d0d" ) old_permission = old_token_permissions.get(object_type=object_type) self.assertTrue(object_type in old_token.object_types.all()) @@ -695,7 +686,7 @@ def test_valid_idempotent_step(self): def test_invalid_permissions_object_type_does_not_exist(self): self.assertFalse( ObjectType.objects.filter( - uuid="69feca90-6c3d-4628-ace8-19e4b0ae4065", service=self.service + uuid="69feca90-6c3d-4628-ace8-19e4b0ae4065" ).exists() ) object_source = { diff --git a/src/objects/tests/admin/test_core_views.py b/src/objects/tests/admin/test_core_views.py deleted file mode 100644 index deef07f2..00000000 --- a/src/objects/tests/admin/test_core_views.py +++ /dev/null @@ -1,74 +0,0 @@ -from unittest import skip - -from django.urls import reverse - -import requests_mock -from django_webtest import WebTest -from maykin_2fa.test import disable_admin_mfa -from requests.exceptions import HTTPError - -from objects.accounts.tests.factories import UserFactory -from objects.token.tests.factories import ObjectTypeFactory - -from ..utils import mock_objecttype, mock_objecttype_version, mock_service_oas_get - - -@disable_admin_mfa() -@requests_mock.Mocker() -@skip("outdated") # TODO view was removed -class ObjectTypeAdminVersionsTests(WebTest): - def test_valid_response_view(self, m): - objecttypes_api = "https://example.com/objecttypes/v1/" - object_type = ObjectTypeFactory.create(service__api_root=objecttypes_api) - mock_service_oas_get(m, objecttypes_api, "objecttypes") - m.get(f"{objecttypes_api}objecttypes", json=[]) - m.get(object_type.url, json=mock_objecttype(object_type.url)) - version = mock_objecttype_version(object_type.url, attrs={"jsonSchema": {}}) - m.get( - object_type.versions_url, - json={ - "count": 1, - "next": None, - "previous": None, - "results": [version], - }, - ) - - user = UserFactory.create(is_staff=True, is_superuser=True) - - # object_type exist - url = reverse("admin:objecttype_versions", args=[object_type.pk]) - response = self.app.get(url, user=user) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.json), 1) - - # object_type does not exist - url = reverse("admin:objecttype_versions", args=[object_type.pk + 1]) - response = self.app.get(url, user=user) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, []) - - def test_endpoint_unreachable(self, m): - user = UserFactory.create(is_staff=True, is_superuser=True) - object_type = ObjectTypeFactory.create() - m.get(object_type.versions_url, exc=HTTPError) - - url = reverse("admin:objecttype_versions", args=[object_type.pk]) - response = self.app.get(url, user=user) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, []) - - def test_invalid_authentication_view(self, m): - url = reverse("admin:objecttype_versions", args=[1]) - response = self.client.get(url) - redirect_url = f"{reverse('admin:login')}?next={url}" - self.assertRedirects(response, redirect_url, status_code=302) - - def test_invalid_permission_view(self, m): - user = UserFactory.create(is_staff=False, is_superuser=False) - url = reverse("admin:objecttype_versions", args=[1]) - response = self.app.get(url, user=user, auto_follow=True) - self.assertContains( - response, - f"You are authenticated as {user.username}, but are not authorized to access this page", - ) diff --git a/src/objects/tests/admin/test_token_permissions.py b/src/objects/tests/admin/test_token_permissions.py index 169c804d..049103ef 100644 --- a/src/objects/tests/admin/test_token_permissions.py +++ b/src/objects/tests/admin/test_token_permissions.py @@ -2,21 +2,16 @@ from django.urls import reverse_lazy -import requests_mock from django_webtest import WebTest from maykin_2fa.test import disable_admin_mfa -from requests.exceptions import ConnectionError from objects.accounts.tests.factories import UserFactory from objects.token.tests.factories import ObjectTypeFactory, TokenAuthFactory -from ..utils import mock_objecttype, mock_objecttype_version, mock_service_oas_get - -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" +from ...core.tests.factories import ObjectTypeVersionFactory @disable_admin_mfa() -@requests_mock.Mocker() class AddPermissionTests(WebTest): url = reverse_lazy("admin:token_permission_add") @@ -24,44 +19,27 @@ def setUp(self): user = UserFactory.create(is_superuser=True, is_staff=True) self.app.set_user(user) - def test_add_permission_choices_without_properties(self, m): - object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + def test_add_permission_choices_without_properties(self): + object_type = ObjectTypeFactory.create() TokenAuthFactory.create() - # mock objecttypes api - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - m.get(f"{OBJECT_TYPES_API}objecttypes", json=[]) - m.get(object_type.url, json=mock_objecttype(object_type.url)) - version1 = mock_objecttype_version(object_type.url, attrs={"jsonSchema": {}}) - version2 = mock_objecttype_version(object_type.url, attrs={"version": 2}) - m.get(f"{object_type.url}/versions", json=[version1, version2]) + version1 = ObjectTypeVersionFactory.create( + object_type=object_type, json_schema={} + ) + version2 = ObjectTypeVersionFactory.create(object_type=object_type, version=2) response = self.app.get(self.url) self.assertEqual(response.status_code, 200) - self.assertEqual(version1["jsonSchema"], {}) - self.assertTrue("diameter", version2["jsonSchema"]["properties"].keys()) - self.assertTrue("plantDate", version2["jsonSchema"]["properties"].keys()) + self.assertEqual(version1.json_schema, {}) + self.assertTrue("diameter", version2.json_schema["properties"].keys()) + self.assertTrue("plantDate", version2.json_schema["properties"].keys()) self.assertFalse("record__data__diameter" in str(response.content)) self.assertFalse("record__data__plantDate" in str(response.content)) - def test_get_permission_with_unavailable_objecttypes(self, m): - """ - regression test for https://github.com/maykinmedia/objects-api/issues/373 - """ - object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) - # mock objecttypes api - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - m.get(f"{OBJECT_TYPES_API}objecttypes", exc=ConnectionError) - m.get(f"{object_type.url}/versions", exc=ConnectionError) - - response = self.app.get(self.url) - - self.assertEqual(response.status_code, 200) - - def test_token_auth_is_preselected_in_select(self, m): + def test_token_auth_is_preselected_in_select(self): token = TokenAuthFactory.create() url = f"{self.url}?token_auth={token.pk}" page = self.app.get(url) diff --git a/src/objects/tests/v2/test_auth.py b/src/objects/tests/v2/test_auth.py index 9f75371b..ad6057b2 100644 --- a/src/objects/tests/v2/test_auth.py +++ b/src/objects/tests/v2/test_auth.py @@ -1,15 +1,13 @@ from django.contrib.gis.geos import Point -import requests_mock from rest_framework import status from rest_framework.test import APITestCase -from objects.core.models import ObjectType from objects.core.tests.factories import ( ObjectFactory, ObjectRecordFactory, ObjectTypeFactory, - ServiceFactory, + ObjectTypeVersionFactory, ) from objects.token.constants import PermissionModes from objects.token.tests.factories import PermissionFactory, TokenAuthFactory @@ -17,11 +15,8 @@ from ...token.models import TokenAuth from ..constants import GEO_WRITE_KWARGS, POLYGON_AMSTERDAM_CENTRUM -from ..utils import mock_objecttype, mock_objecttype_version, mock_service_oas_get from .utils import reverse, reverse_lazy -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - class TokenAuthTests(APITestCase): def setUp(self) -> None: @@ -50,7 +45,7 @@ class PermissionTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() def test_retrieve_no_object_permission(self): object = ObjectFactory.create() @@ -171,25 +166,10 @@ def test_create_with_invalid_objecttype(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_create_with_unknown_objecttype_service(self): - url = reverse("object-list") - data = { - "type": "https://other-api.nl/v1/objecttypes/8be76be2-6567-4f5c-a17b-05217ab6d7b2", - "record": { - "typeVersion": 1, - "data": {"plantDate": "2020-04-12"}, - "startDate": "2020-01-01", - }, - } - - response = self.client.post(url, data, **GEO_WRITE_KWARGS) - - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_create_with_unknown_objecttype_uuid(self): url = reverse("object-list") data = { - "type": f"{OBJECT_TYPES_API}objecttypes/8be76be2-6567-4f5c-a17b-05217ab6d7b2", + "type": "objecttypes/8be76be2-6567-4f5c-a17b-05217ab6d7b2", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12"}, @@ -207,7 +187,7 @@ class FilterAuthTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() def test_list_objects_without_object_permissions(self): ObjectFactory.create_batch(2) @@ -254,7 +234,7 @@ def test_search_objects_without_object_permissions(self): "coordinates": [POLYGON_AMSTERDAM_CENTRUM], } }, - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", }, **GEO_WRITE_KWARGS, ) @@ -283,7 +263,7 @@ def test_search_objects_limited_to_object_permission(self): "coordinates": [POLYGON_AMSTERDAM_CENTRUM], } }, - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", }, **GEO_WRITE_KWARGS, ) @@ -355,112 +335,48 @@ def test_history_superuser(self): self.assertEqual(response.status_code, status.HTTP_200_OK) - @requests_mock.Mocker() - def test_create_superuser(self, m): - object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + def test_create_superuser(self): + object_type = ObjectTypeFactory.create() + ObjectTypeVersionFactory.create(object_type=object_type) url = reverse("object-list") data = { - "type": f"{object_type.url}", + "type": f"https://testserver{reverse('objecttype-detail', args=[object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12", "diameter": 30}, "startAt": "2020-01-01", }, } - # mocks - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - m.get( - f"{object_type.url}/versions/1", - json=mock_objecttype_version(object_type.url), - ) response = self.client.post(url, data, **GEO_WRITE_KWARGS) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - def test_create_superuser_no_service(self): - url = reverse("object-list") - data = { - "type": f"{OBJECT_TYPES_API}objecttypes/8be76be2-6567-4f5c-a17b-05217ab6d7b2", - "record": { - "typeVersion": 1, - "data": {"plantDate": "2020-04-12", "diameter": 30}, - "startAt": "2020-01-01", - }, - } - - response = self.client.post(url, data, **GEO_WRITE_KWARGS) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - @requests_mock.Mocker() - def test_create_superuser_no_object_type(self, m): - objecttype_url = ( - f"{OBJECT_TYPES_API}objecttypes/8be76be2-6567-4f5c-a17b-05217ab6d7b2" - ) - service = ServiceFactory.create(api_root=OBJECT_TYPES_API) - url = reverse("object-list") - data = { - "type": objecttype_url, - "record": { - "typeVersion": 1, - "data": {"plantDate": "2020-04-12", "diameter": 30}, - "startAt": "2020-01-01", - }, - } - # mocks - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - m.get(objecttype_url, json=mock_objecttype(objecttype_url)) - m.get( - f"{objecttype_url}/versions/1", - json=mock_objecttype_version(objecttype_url), - ) - - response = self.client.post(url, data, **GEO_WRITE_KWARGS) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - # check created object type - object_type = ObjectType.objects.get() - self.assertEqual(object_type.service, service) - self.assertEqual(object_type.url, objecttype_url) - - @requests_mock.Mocker() - def test_update_superuser(self, m): - object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + def test_update_superuser(self): + object_type = ObjectTypeFactory.create() + ObjectTypeVersionFactory.create(object_type=object_type) record = ObjectRecordFactory.create(object__object_type=object_type, version=1) url = reverse("object-detail", args=[record.object.uuid]) data = { - "type": f"{object_type.url}", + "type": f"https://testserver{reverse('objecttype-detail', args=[object_type.uuid])}", "record": { "typeVersion": record.version, "data": record.data, "startAt": record.start_at, }, } - # mocks - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - m.get( - f"{object_type.url}/versions/1", - json=mock_objecttype_version(object_type.url), - ) response = self.client.put(url, data=data, **GEO_WRITE_KWARGS) self.assertEqual(response.status_code, status.HTTP_200_OK) - @requests_mock.Mocker() - def test_patch_superuser(self, m): - object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + def test_patch_superuser(self): + object_type = ObjectTypeFactory.create() + ObjectTypeVersionFactory.create(object_type=object_type) record = ObjectRecordFactory.create( object__object_type=object_type, version=1, data__name="old" ) url = reverse("object-detail", args=[record.object.uuid]) - # mocks - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - m.get( - f"{object_type.url}/versions/1", - json=mock_objecttype_version(object_type.url), - ) response = self.client.patch( url, diff --git a/src/objects/tests/v2/test_auth_fields.py b/src/objects/tests/v2/test_auth_fields.py index 38dc5351..cc5e8a70 100644 --- a/src/objects/tests/v2/test_auth_fields.py +++ b/src/objects/tests/v2/test_auth_fields.py @@ -17,15 +17,13 @@ from ..constants import GEO_WRITE_KWARGS, POLYGON_AMSTERDAM_CENTRUM from .utils import reverse, reverse_lazy -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - class RetrieveAuthFieldsTests(TokenAuthMixin, APITestCase): @classmethod def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() def test_retrieve_without_query(self): PermissionFactory.create( @@ -48,7 +46,7 @@ def test_retrieve_without_query(self): response.json(), { "url": f"http://testserver{url}", - "type": self.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": {"startAt": record.start_at.isoformat()}, }, ) @@ -88,7 +86,7 @@ def test_retrieve_with_query_fields(self): response.json(), { "url": f"http://testserver{url}", - "type": self.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": {"data": {"name": record.data["name"]}}, }, ) @@ -136,7 +134,7 @@ def test_retrieve_query_fields_not_allowed(self): { "url": f"http://testserver{url}", "record": {"data": {"name": "some"}}, - "type": self.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", }, ) self.assertEqual( @@ -171,8 +169,8 @@ class ListAuthFieldsTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) - cls.other_object_type = ObjectTypeFactory.create() + cls.object_type = ObjectTypeFactory.create() + cls.other_object_type = ObjectTypeFactory() def test_list_without_query_different_object_types(self): PermissionFactory.create( @@ -222,7 +220,7 @@ def test_list_without_query_different_object_types(self): }, { "url": f"http://testserver{reverse('object-detail', args=[record1.object.uuid])}", - "type": self.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "index": record1.index, "typeVersion": record1.version, @@ -240,7 +238,7 @@ def test_list_without_query_different_object_types(self): ) self.assertEqual( response.headers["x-unauthorized-fields"], - f"{self.other_object_type.url}(1)=type; {self.object_type.url}(1)=uuid", + f"http://testserver{reverse('objecttype-detail', args=[self.other_object_type.uuid])}(1)=type; http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}(1)=uuid", ) def test_list_with_query_fields(self): @@ -391,7 +389,7 @@ class SearchAuthFieldsTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() def test_search_with_fields_auth(self): PermissionFactory.create( @@ -426,7 +424,7 @@ def test_search_with_fields_auth(self): [ { "url": f"http://testserver{reverse('object-detail', args=[record.object.uuid])}", - "type": self.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "geometry": { "type": "Point", diff --git a/src/objects/tests/v2/test_filters.py b/src/objects/tests/v2/test_filters.py index 842df3a8..234c9062 100644 --- a/src/objects/tests/v2/test_filters.py +++ b/src/objects/tests/v2/test_filters.py @@ -20,8 +20,6 @@ from ...core.constants import DataClassificationChoices from .utils import reverse, reverse_lazy -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - class FilterObjectTypeTests(TokenAuthMixin, APITestCase): url = reverse_lazy("object-list") @@ -30,10 +28,8 @@ class FilterObjectTypeTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) - cls.another_object_type = ObjectTypeFactory.create( - service=cls.object_type.service - ) + cls.object_type = ObjectTypeFactory.create() + cls.another_object_type = ObjectTypeFactory.create() PermissionFactory.create( object_type=cls.object_type, @@ -51,7 +47,12 @@ def test_filter_object_type(self): ObjectRecordFactory.create(object=object) ObjectFactory.create(object_type=self.another_object_type) - response = self.client.get(self.url, {"type": self.object_type.url}) + response = self.client.get( + self.url, + { + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", + }, + ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -69,27 +70,6 @@ def test_filter_invalid_objecttype(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json()["type"], ["Invalid value."]) - def test_filter_unknown_objecttype(self): - objecttype_url = ( - f"{OBJECT_TYPES_API}objecttypes/8be76be2-6567-4f5c-a17b-05217ab6d7b2" - ) - response = self.client.get(self.url, {"type": objecttype_url}) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json()["type"], - [ - f"Select a valid object type. {objecttype_url} is not one of the available choices." - ], - ) - - def test_filter_too_long_object_type(self): - object_type_long = f"{OBJECT_TYPES_API}{'a' * 1000}/{self.object_type.uuid}" - response = self.client.get(self.url, {"type": object_type_long}) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["type"], ["The value has too many characters"]) - class FilterDataAttrsTests(TokenAuthMixin, APITestCase): url = reverse_lazy("object-list") @@ -98,7 +78,7 @@ class FilterDataAttrsTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_only, @@ -446,7 +426,7 @@ class FilterDataAttrTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_only, @@ -839,7 +819,7 @@ class FilterDateTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_only, @@ -974,7 +954,7 @@ class FilterDataIcontainsTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_only, @@ -1052,7 +1032,7 @@ class FilterTypeVersionTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_only, diff --git a/src/objects/tests/v2/test_geo_headers.py b/src/objects/tests/v2/test_geo_headers.py index f481648a..24c219e6 100644 --- a/src/objects/tests/v2/test_geo_headers.py +++ b/src/objects/tests/v2/test_geo_headers.py @@ -13,15 +13,13 @@ from ..constants import GEO_READ_KWARGS, POLYGON_AMSTERDAM_CENTRUM from .utils import reverse -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - class GeoHeaderTests(TokenAuthMixin, APITestCase): @classmethod def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_and_write, @@ -62,7 +60,7 @@ def test_get_with_incorrect_get_headers(self): def test_create_without_geo_headers(self): data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"diameter": 30}, @@ -79,7 +77,7 @@ def test_update_without_geo_headers(self): object = ObjectFactory.create(object_type=self.object_type) url = reverse("object-detail", args=[object.uuid]) data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"diameter": 30}, diff --git a/src/objects/tests/v2/test_geo_search.py b/src/objects/tests/v2/test_geo_search.py index d375ec96..71e13496 100644 --- a/src/objects/tests/v2/test_geo_search.py +++ b/src/objects/tests/v2/test_geo_search.py @@ -11,8 +11,6 @@ from ..constants import GEO_WRITE_KWARGS, POLYGON_AMSTERDAM_CENTRUM from .utils import reverse, reverse_lazy -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - class GeoSearchTests(TokenAuthMixin, APITestCase): url = reverse_lazy("object-search") @@ -21,10 +19,8 @@ class GeoSearchTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) - cls.another_object_type = ObjectTypeFactory.create( - service=cls.object_type.service - ) + cls.object_type = ObjectTypeFactory.create() + cls.another_object_type = ObjectTypeFactory.create() PermissionFactory.create( object_type=cls.object_type, @@ -90,7 +86,7 @@ def test_filter_objecttype(self): "coordinates": [POLYGON_AMSTERDAM_CENTRUM], } }, - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", }, **GEO_WRITE_KWARGS, ) @@ -110,7 +106,9 @@ def test_without_geometry(self): ObjectRecordFactory.create(object__object_type=self.another_object_type) response = self.client.post( self.url, - {"type": self.object_type.url}, + { + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}" + }, **GEO_WRITE_KWARGS, ) diff --git a/src/objects/tests/v2/test_jsonschema.py b/src/objects/tests/v2/test_jsonschema.py index d26e8d31..fcf04c70 100644 --- a/src/objects/tests/v2/test_jsonschema.py +++ b/src/objects/tests/v2/test_jsonschema.py @@ -2,19 +2,16 @@ from rest_framework import status from rest_framework.test import APITestCase -from objects.core.tests.factories import ObjectTypeFactory + +from objects.core.tests.factories import ObjectTypeFactory, ObjectTypeVersionFactory from objects.token.constants import PermissionModes from objects.token.tests.factories import PermissionFactory from objects.utils.test import ClearCachesMixin, TokenAuthMixin from ..constants import GEO_WRITE_KWARGS -from ..utils import mock_objecttype, mock_objecttype_version, mock_service_oas_get from .utils import reverse -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - -@requests_mock.Mocker() class JsonSchemaTests(TokenAuthMixin, ClearCachesMixin, APITestCase): """GH issue - https://github.com/maykinmedia/objects-api/issues/330""" @@ -22,25 +19,21 @@ class JsonSchemaTests(TokenAuthMixin, ClearCachesMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_and_write, token_auth=cls.token_auth, ) - def test_create_object_with_additional_properties_allowed(self, m): - object_type_data = mock_objecttype_version(self.object_type.url) - object_type_data["jsonSchema"]["additionalProperties"] = True - - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - - m.get(f"{self.object_type.url}/versions/1", json=object_type_data) - m.get(self.object_type.url, json=mock_objecttype(self.object_type.url)) + def test_create_object_with_additional_properties_allowed(self): + object_type_data = ObjectTypeVersionFactory.create(object_type=self.object_type) + object_type_data.json_schema["additionalProperties"] = True + object_type_data.save() url = reverse("object-list") data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"diameter": 30, "newProperty": "some value"}, @@ -52,18 +45,14 @@ def test_create_object_with_additional_properties_allowed(self, m): self.assertEqual(response.status_code, status.HTTP_201_CREATED) - def test_create_object_with_additional_properties_not_allowed(self, m): - object_type_data = mock_objecttype_version(self.object_type.url) - object_type_data["jsonSchema"]["additionalProperties"] = False - - # mocks - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - m.get(f"{self.object_type.url}/versions/1", json=object_type_data) - m.get(self.object_type.url, json=mock_objecttype(self.object_type.url)) + def test_create_object_with_additional_properties_not_allowed(self): + object_type_data = ObjectTypeVersionFactory.create(object_type=self.object_type) + object_type_data.json_schema["additionalProperties"] = False + object_type_data.save() url = reverse("object-list") data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"diameter": 30, "newProperty": "some value"}, diff --git a/src/objects/tests/v2/test_metrics.py b/src/objects/tests/v2/test_metrics.py index 9340f07a..44722df2 100644 --- a/src/objects/tests/v2/test_metrics.py +++ b/src/objects/tests/v2/test_metrics.py @@ -1,6 +1,5 @@ from unittest.mock import MagicMock, patch -import requests_mock from freezegun import freeze_time from rest_framework import status from rest_framework.test import APITestCase @@ -17,24 +16,22 @@ ObjectFactory, ObjectRecordFactory, ObjectTypeFactory, + ObjectTypeVersionFactory, ) from objects.token.constants import PermissionModes from objects.token.tests.factories import PermissionFactory from objects.utils.test import TokenAuthMixin from ..constants import GEO_WRITE_KWARGS -from ..utils import mock_objecttype, mock_objecttype_version, mock_service_oas_get from .utils import reverse -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - @freeze_time("2024-08-31") class ObjectMetricsTests(TokenAuthMixin, APITestCase): @classmethod def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_and_write, @@ -51,19 +48,13 @@ def create_object_with_record(self, diameter: int = 10): ) return obj - @requests_mock.Mocker() @patch.object(objects_create_counter, "add", wraps=objects_create_counter.add) - def test_objects_create_counter(self, m, mock_add: MagicMock): - 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)) + def test_objects_create_counter(self, mock_add: MagicMock): + ObjectTypeVersionFactory.create(object_type=self.object_type) url = reverse("object-list") data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"diameter": 10}, @@ -74,15 +65,9 @@ def test_objects_create_counter(self, m, mock_add: MagicMock): self.assertEqual(response.status_code, 201) mock_add.assert_called_once_with(1) - @requests_mock.Mocker() @patch.object(objects_update_counter, "add", wraps=objects_update_counter.add) - def test_objects_update_counter(self, m, mock_add: MagicMock): - 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)) + def test_objects_update_counter(self, mock_add: MagicMock): + ObjectTypeVersionFactory.create(object_type=self.object_type) obj = self.create_object_with_record() url = reverse("object-detail", args=[obj.uuid]) @@ -97,15 +82,9 @@ def test_objects_update_counter(self, m, mock_add: MagicMock): self.assertEqual(response.status_code, 200) mock_add.assert_called_once_with(1) - @requests_mock.Mocker() @patch.object(objects_delete_counter, "add", wraps=objects_delete_counter.add) - def test_objects_delete_counter(self, m, mock_add: MagicMock): - 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)) + def test_objects_delete_counter(self, mock_add: MagicMock): + ObjectTypeVersionFactory.create(object_type=self.object_type) obj = self.create_object_with_record() url = reverse("object-detail", args=[obj.uuid]) diff --git a/src/objects/tests/v2/test_notifications_send.py b/src/objects/tests/v2/test_notifications_send.py index 9e8d9c02..0ee72d46 100644 --- a/src/objects/tests/v2/test_notifications_send.py +++ b/src/objects/tests/v2/test_notifications_send.py @@ -3,7 +3,6 @@ from django.test import override_settings -import requests_mock from freezegun import freeze_time from notifications_api_common.models import NotificationsConfig from rest_framework import status @@ -18,27 +17,26 @@ ObjectRecordFactory, ObjectTypeFactory, ReferenceFactory, + ObjectTypeVersionFactory, ) from objects.token.constants import PermissionModes from objects.token.tests.factories import PermissionFactory from objects.utils.test import TokenAuthMixin from ..constants import GEO_WRITE_KWARGS -from ..utils import mock_objecttype, mock_objecttype_version, mock_service_oas_get from .utils import reverse -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - @freeze_time("2018-09-07T00:00:00Z") @override_settings(NOTIFICATIONS_DISABLED=False) -@requests_mock.Mocker() class SendNotifTestCase(TokenAuthMixin, APITestCase): @classmethod def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() + ObjectTypeVersionFactory.create(object_type=cls.object_type) + PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_and_write, @@ -63,20 +61,14 @@ def setUp(self): config.save() @patch("notifications_api_common.viewsets.send_notification.delay") - def test_send_notif_create_object(self, mocker, mock_task): + def test_send_notif_create_object(self, mock_task): """ Check if notifications will be send when Object is created """ - 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, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12", "diameter": 30}, @@ -104,29 +96,23 @@ def test_send_notif_create_object(self, mocker, mock_task): "actie": "create", "aanmaakdatum": "2018-09-07T02:00:00+02:00", "kenmerken": { - "objectType": self.object_type.url, + "objectType": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", }, }, ) @patch("notifications_api_common.viewsets.send_notification.delay") - def test_send_notif_update_object(self, mocker, mock_task): + def test_send_notif_update_object(self, mock_task): """ Check if notifications will be send when Object is created """ - 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) ObjectRecordFactory.create(object=obj) url = reverse("object-detail", args=[obj.uuid]) data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12", "diameter": 30}, @@ -154,29 +140,23 @@ def test_send_notif_update_object(self, mocker, mock_task): "actie": "update", "aanmaakdatum": "2018-09-07T02:00:00+02:00", "kenmerken": { - "objectType": self.object_type.url, + "objectType": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", }, }, ) @patch("notifications_api_common.viewsets.send_notification.delay") - def test_send_notif_partial_update_object(self, mocker, mock_task): + def test_send_notif_partial_update_object(self, mock_task): """ Check if notifications will be send when Object is created """ - 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) ObjectRecordFactory.create(object=obj) url = reverse("object-detail", args=[obj.uuid]) data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12", "diameter": 30}, @@ -204,21 +184,16 @@ def test_send_notif_partial_update_object(self, mocker, mock_task): "actie": "partial_update", "aanmaakdatum": "2018-09-07T02:00:00+02:00", "kenmerken": { - "objectType": self.object_type.url, + "objectType": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", }, }, ) @patch("notifications_api_common.viewsets.send_notification.delay") - def test_send_notif_delete_object(self, mocker, mock_task): + def test_send_notif_delete_object(self, mock_task): """ Check if notifications will be send when Object is created """ - 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), - ) obj = ObjectFactory.create(object_type=self.object_type) ObjectRecordFactory.create(object=obj) @@ -241,7 +216,7 @@ def test_send_notif_delete_object(self, mocker, mock_task): "actie": "destroy", "aanmaakdatum": "2018-09-07T02:00:00+02:00", "kenmerken": { - "objectType": self.object_type.url, + "objectType": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", }, }, ) diff --git a/src/objects/tests/v2/test_object_api.py b/src/objects/tests/v2/test_object_api.py index 7f1bd279..aba925ec 100644 --- a/src/objects/tests/v2/test_object_api.py +++ b/src/objects/tests/v2/test_object_api.py @@ -12,6 +12,7 @@ ObjectFactory, ObjectRecordFactory, ObjectTypeFactory, + ObjectTypeVersionFactory, ReferenceFactory, ) from objects.token.constants import PermissionModes @@ -19,11 +20,8 @@ from objects.utils.test import TokenAuthMixin from ..constants import GEO_WRITE_KWARGS -from ..utils import mock_objecttype, mock_objecttype_version, mock_service_oas_get from .utils import reverse, reverse_lazy -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - @freeze_time("2020-08-08") @requests_mock.Mocker() @@ -34,7 +32,11 @@ class ObjectApiTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() + cls.objecttype_version = ObjectTypeVersionFactory.create( + object_type=cls.object_type, + ) + PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_and_write, @@ -69,7 +71,7 @@ def test_list_actual_objects(self, m): { "url": f"http://testserver{reverse('object-detail', args=[object_record1.object.uuid])}", "uuid": str(object_record1.object.uuid), - "type": object_record1.object.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[object_record1.object.object_type.uuid])}", "record": { "index": object_record1.index, "typeVersion": object_record1.version, @@ -107,7 +109,7 @@ def test_retrieve_object(self, m): { "url": f"http://testserver{reverse('object-detail', args=[object.uuid])}", "uuid": str(object.uuid), - "type": object.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "index": object_record.index, "typeVersion": object_record.version, @@ -186,16 +188,9 @@ def test_retrieve_by_index(self, m): ) def test_create_object(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, + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12", "diameter": 30}, @@ -209,7 +204,7 @@ def test_create_object(self, m): response = self.client.post(url, data, **GEO_WRITE_KWARGS) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data) object = Object.objects.get() @@ -225,13 +220,6 @@ def test_create_object(self, m): self.assertIsNone(record.end_at) def test_update_object(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( @@ -243,7 +231,7 @@ def test_update_object(self, m): url = reverse("object-detail", args=[object.uuid]) data = { - "type": object.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12", "diameter": 30}, @@ -285,12 +273,6 @@ def test_update_object(self, m): self.assertEqual(initial_record.end_at, date(2020, 1, 1)) def test_patch_object_record(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, @@ -334,12 +316,6 @@ def test_patch_object_record(self, m): self.assertEqual(initial_record.end_at, date(2020, 1, 1)) def test_patch_validates_merged_object_rather_than_partial_object(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, @@ -436,19 +412,12 @@ def test_history_object(self, m): # In the ticket https://github.com/maykinmedia/objects-api/issues/282 we discovered that updating an object \ # where the startAt value has been modified with an earlier date causes an 500 response. def test_updating_object_after_changing_the_startAt_value_returns_200(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)) - object_uuid = uuid.uuid4() url_object_list = reverse("object-list") start_data = { "uuid": object_uuid, - "type": self.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12", "diameter": 30}, @@ -468,7 +437,7 @@ def test_updating_object_after_changing_the_startAt_value_returns_200(self, m): url_object_update = reverse("object-detail", args=[object_uuid]) modified_data = { - "type": self.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12", "diameter": 30}, @@ -497,13 +466,6 @@ def test_updating_object_after_changing_the_startAt_value_returns_200(self, m): # regression test for https://github.com/maykinmedia/objects-api/issues/268 def test_update_object_correctionFor(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)) - initial_record = ObjectRecordFactory.create( object__object_type=self.object_type, version=1 ) @@ -513,7 +475,7 @@ def test_update_object_correctionFor(self, m): url = reverse("object-detail", args=[object.uuid]) modified_data = { - "type": self.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12", "diameter": 30}, @@ -758,7 +720,7 @@ class ObjectsAvailableRecordsTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_and_write, diff --git a/src/objects/tests/v2/test_object_api_fields.py b/src/objects/tests/v2/test_object_api_fields.py index e6addd58..f849e05e 100644 --- a/src/objects/tests/v2/test_object_api_fields.py +++ b/src/objects/tests/v2/test_object_api_fields.py @@ -14,8 +14,6 @@ from .utils import reverse -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - class DynamicFieldsTests(TokenAuthMixin, APITestCase): maxDiff = None @@ -24,7 +22,7 @@ class DynamicFieldsTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_and_write, @@ -53,12 +51,12 @@ def test_list_with_selected_fields(self): [ { "url": f"http://testserver{reverse('object-detail', args=[object_record2.object.uuid])}", - "type": self.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": {"index": 1, "typeVersion": object_record2.version}, }, { "url": f"http://testserver{reverse('object-detail', args=[object_record1.object.uuid])}", - "type": self.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": {"index": 1, "typeVersion": object_record1.version}, }, ], @@ -83,7 +81,7 @@ def test_retrieve_with_selected_fields(self): data, { "url": f"http://testserver{reverse('object-detail', args=[object.uuid])}", - "type": object.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[object.object_type.uuid])}", "record": { "geometry": { "type": "Point", diff --git a/src/objects/tests/v2/test_ordering.py b/src/objects/tests/v2/test_ordering.py index cc4d950d..e29a5052 100644 --- a/src/objects/tests/v2/test_ordering.py +++ b/src/objects/tests/v2/test_ordering.py @@ -10,8 +10,6 @@ from .utils import reverse_lazy -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - class OrderingTests(TokenAuthMixin, APITestCase): url = reverse_lazy("object-list") @@ -20,7 +18,7 @@ class OrderingTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() PermissionFactory.create( object_type=cls.object_type, @@ -128,7 +126,7 @@ class OrderingAllowedTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() def test_not_allowed_field(self): PermissionFactory.create( diff --git a/src/objects/tests/v2/test_pagination.py b/src/objects/tests/v2/test_pagination.py index 80475fc3..482d7d6f 100644 --- a/src/objects/tests/v2/test_pagination.py +++ b/src/objects/tests/v2/test_pagination.py @@ -10,8 +10,6 @@ from .utils import reverse_lazy -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - class FilterObjectTypeTests(TokenAuthMixin, APITestCase): url = reverse_lazy("object-list") @@ -20,8 +18,8 @@ class FilterObjectTypeTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) - PermissionFactory.create( + cls.object_type = ObjectTypeFactory.create() + PermissionFactory( object_type=cls.object_type, mode=PermissionModes.read_only, token_auth=cls.token_auth, diff --git a/src/objects/tests/v2/test_permissions_api.py b/src/objects/tests/v2/test_permissions_api.py index efb954de..f2af7979 100644 --- a/src/objects/tests/v2/test_permissions_api.py +++ b/src/objects/tests/v2/test_permissions_api.py @@ -7,8 +7,6 @@ from .utils import reverse -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - class ObjectApiTests(TokenAuthMixin, APITestCase): maxDiff = None @@ -45,13 +43,13 @@ def test_list_permissions(self): "previous": None, "results": [ { - "type": permission1.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[permission1.object_type.uuid])}", "mode": PermissionModes.read_and_write, "use_fields": False, "fields": {}, }, { - "type": permission2.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[permission2.object_type.uuid])}", "mode": "read_only", "use_fields": True, "fields": {"1": ["url", "uuid"], "2": ["url", "record"]}, @@ -83,7 +81,7 @@ def test_list_permissions_for_only_user(self): data, [ { - "type": permission1.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[permission1.object_type.uuid])}", "mode": PermissionModes.read_and_write, "use_fields": False, "fields": {}, diff --git a/src/objects/tests/v2/test_stuf.py b/src/objects/tests/v2/test_stuf.py index ba29ce75..86b64cad 100644 --- a/src/objects/tests/v2/test_stuf.py +++ b/src/objects/tests/v2/test_stuf.py @@ -20,8 +20,6 @@ from .utils import reverse -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - class Stuf21Tests(TokenAuthMixin, APITestCase): """# noqa @@ -37,7 +35,7 @@ class Stuf21Tests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() cls.object = ObjectFactory.create(object_type=cls.object_type) PermissionFactory.create( object_type=cls.object_type, @@ -288,7 +286,7 @@ class Stuf22Tests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() cls.object = ObjectFactory.create(object_type=cls.object_type) cls.record_1 = ObjectRecordFactory.create( object=cls.object, @@ -417,7 +415,7 @@ class Stuf23Tests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory.create() cls.object = ObjectFactory.create(object_type=cls.object_type) cls.record_1 = ObjectRecordFactory.create( object=cls.object, diff --git a/src/objects/tests/v2/test_validation.py b/src/objects/tests/v2/test_validation.py index 326618b8..00233b10 100644 --- a/src/objects/tests/v2/test_validation.py +++ b/src/objects/tests/v2/test_validation.py @@ -1,11 +1,5 @@ -import datetime import uuid -from django.conf import settings - -import requests -import requests_mock -from freezegun import freeze_time from rest_framework import status from rest_framework.test import APITestCase @@ -21,202 +15,29 @@ from ...core.constants import ObjectTypeVersionStatus from ..constants import GEO_WRITE_KWARGS -from ..utils import mock_objecttype, mock_objecttype_version, mock_service_oas_get from .utils import reverse -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - -@requests_mock.Mocker() class ObjectTypeValidationTests(TokenAuthMixin, ClearCachesMixin, APITestCase): @classmethod def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) - PermissionFactory.create( - object_type=cls.object_type, - mode=PermissionModes.read_and_write, - token_auth=cls.token_auth, - ) + def setUp(self): + super().setUp() + + self.object_type = ObjectTypeFactory.create() - def test_valid_create_object_check_cache(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), - ) - url = reverse("object-list") - data = { - "type": self.object_type.url, - "record": { - "typeVersion": 1, - "data": {"plantDate": "2020-04-12", "diameter": 30}, - "startAt": "2020-01-01", - }, - } - with self.subTest("ok_cache"): - self.assertEqual(m.call_count, 0) - self.assertEqual(Object.objects.count(), 0) - for n in range(5): - self.client.post(url, data, **GEO_WRITE_KWARGS) - # just one request should run — the first one - self.assertEqual(m.call_count, 1) - self.assertEqual(Object.objects.count(), 5) - - with self.subTest("clear_cache"): - m.reset_mock() - self.assertEqual(m.call_count, 0) - for n in range(5): - self._clear_caches() - self.client.post(url, data, **GEO_WRITE_KWARGS) - self.assertEqual(m.call_count, 5) - self.assertEqual(Object.objects.count(), 10) - - with self.subTest("cache_timeout"): - m.reset_mock() - self._clear_caches() - old_datetime = datetime.datetime(2025, 5, 1, 12, 0) - with freeze_time(old_datetime.isoformat()): - self.assertEqual(m.call_count, 0) - self.client.post(url, data, **GEO_WRITE_KWARGS) - self.client.post(url, data, **GEO_WRITE_KWARGS) - # only one request for two post - self.assertEqual(m.call_count, 1) - - # cache_timeout is still ok - cache_timeout = settings.OBJECTTYPE_VERSION_CACHE_TIMEOUT - new_datetime = old_datetime + datetime.timedelta( - seconds=(cache_timeout - 60) - ) - with freeze_time(new_datetime.isoformat()): - # same request as before - self.assertEqual(m.call_count, 1) - self.client.post(url, data, **GEO_WRITE_KWARGS) - # same request as before - self.assertEqual(m.call_count, 1) - - # cache_timeout is expired - cache_timeout = settings.OBJECTTYPE_VERSION_CACHE_TIMEOUT - new_datetime = old_datetime + datetime.timedelta( - seconds=(cache_timeout + 60) - ) - with freeze_time(new_datetime.isoformat()): - # same request as before - self.assertEqual(m.call_count, 1) - self.client.post(url, data, **GEO_WRITE_KWARGS) - # new request - self.assertEqual(m.call_count, 2) - - def test_create_object_with_not_found_objecttype_url(self, m): - object_type_invalid = ObjectTypeFactory.create(service=self.object_type.service) PermissionFactory.create( - object_type=object_type_invalid, + object_type=self.object_type, mode=PermissionModes.read_and_write, token_auth=self.token_auth, ) - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - m.get(f"{object_type_invalid.url}/versions/1", status_code=404) - - url = reverse("object-list") - data = { - "type": object_type_invalid.url, - "record": { - "typeVersion": 1, - "data": {"plantDate": "2020-04-12"}, - "startAt": "2020-01-01", - }, - } - - response = self.client.post(url, data, **GEO_WRITE_KWARGS) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(Object.objects.count(), 0) - - def test_create_object_with_invalid_length(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), - ) - object_type_long = f"{OBJECT_TYPES_API}{'a' * 1000}/{self.object_type.uuid}" - data = { - "type": object_type_long, - "record": { - "typeVersion": 1, - "data": {"plantDate": "2020-04-12", "diameter": 30}, - "startAt": "2020-01-01", - }, - } - url = reverse("object-list") - - response = self.client.post(url, data, **GEO_WRITE_KWARGS) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(Object.objects.count(), 0) - - data = response.json() - self.assertEqual(data["type"], ["The value has too many characters"]) - - def test_create_object_no_version(self, m): - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - m.get(f"{self.object_type.url}/versions/10", status_code=404) - - url = reverse("object-list") - data = { - "type": self.object_type.url, - "record": { - "typeVersion": 10, - "data": {"plantDate": "2020-04-12", "diameter": 30}, - "startAt": "2020-01-01", - }, - } - - response = self.client.post(url, data, **GEO_WRITE_KWARGS) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(Object.objects.count(), 0) - - data = response.json() - self.assertEqual( - data["non_field_errors"], ["Object type version can not be retrieved."] - ) - - def test_create_object_objecttype_request_error(self, m): - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - m.get(f"{self.object_type.url}/versions/10", exc=requests.HTTPError) + def test_create_object_no_version(self): url = reverse("object-list") data = { - "type": self.object_type.url, - "record": { - "typeVersion": 10, - "data": {"plantDate": "2020-04-12", "diameter": 30}, - "startAt": "2020-01-01", - }, - } - - response = self.client.post(url, data, **GEO_WRITE_KWARGS) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(Object.objects.count(), 0) - - data = response.json() - self.assertEqual( - data["non_field_errors"], ["Object type version can not be retrieved."] - ) - - def test_create_object_objecttype_with_no_jsonSchema(self, m): - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - m.get( - f"{self.object_type.url}/versions/10", - status_code=200, - json={"key": "value"}, - ) - - url = reverse("object-list") - data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 10, "data": {"plantDate": "2020-04-12", "diameter": 30}, @@ -232,21 +53,15 @@ def test_create_object_objecttype_with_no_jsonSchema(self, m): data = response.json() self.assertEqual( data["non_field_errors"], - [ - f"{self.object_type.versions_url} does not appear to be a valid objecttype." - ], + [f"{self.object_type} version: 10 does not appear to exist."], ) - def test_create_object_schema_invalid(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), - ) + def test_create_object_schema_invalid(self): + ObjectTypeVersionFactory.create(object_type=self.object_type) url = reverse("object-list") data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12"}, @@ -264,12 +79,10 @@ def test_create_object_schema_invalid(self, m): data["non_field_errors"], ["'diameter' is a required property"] ) - def test_create_object_without_record_invalid(self, m): - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - + def test_create_object_without_record_invalid(self): url = reverse("object-list") data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", } response = self.client.post(url, data, **GEO_WRITE_KWARGS) @@ -277,17 +90,13 @@ def test_create_object_without_record_invalid(self, m): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(Object.objects.count(), 0) - def test_create_object_correction_invalid(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), - ) + def test_create_object_correction_invalid(self): + ObjectTypeVersionFactory.create(object_type=self.object_type) record = ObjectRecordFactory.create() url = reverse("object-list") data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12", "diameter": 30}, @@ -307,23 +116,18 @@ def test_create_object_correction_invalid(self, m): [f"Object with index={record.index} does not exist."], ) - def test_create_object_geometry_not_allowed(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, attrs={"allowGeometry": False}), - ) + def test_create_object_geometry_not_allowed(self): + self.object_type.allow_geometry = False + self.object_type.save() + + ObjectTypeVersionFactory.create(object_type=self.object_type) url = reverse("object-list") data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, - "data": {"plantDate": "2020-04-12", "diameter": 30}, + "data": {"diameter": 30}, "geometry": { "type": "Point", "coordinates": [4.910649523925713, 52.37240093589432], @@ -340,51 +144,17 @@ def test_create_object_geometry_not_allowed(self, m): ["This object type doesn't support geometry"], ) - def test_create_object_with_geometry_without_allowGeometry(self, m): - """test the support of Objecttypes api without allowGeometry property""" - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - object_type_response = mock_objecttype(self.object_type.url) - del object_type_response["allowGeometry"] - m.get(self.object_type.url, json=object_type_response) - m.get( - f"{self.object_type.url}/versions/1", - json=mock_objecttype_version(self.object_type.url), - ) - - url = reverse("object-list") - 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", - }, - } - - response = self.client.post(url, data, **GEO_WRITE_KWARGS) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - def test_create_object_with_empty_data_valid(self, m): + def test_create_object_with_empty_data_valid(self): """ regression test for https://github.com/maykinmedia/objects-api/issues/371 """ - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - objecttype_version_response = mock_objecttype_version(self.object_type.url) - objecttype_version_response["jsonSchema"]["required"] = [] - m.get( - f"{self.object_type.url}/versions/1", - json=objecttype_version_response, - ) - m.get(self.object_type.url, json=mock_objecttype(self.object_type.url)) + version = ObjectTypeVersionFactory.create(object_type=self.object_type) + version.json_schema["required"] = [] + version.save() url = reverse("object-list") data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {}, @@ -396,20 +166,17 @@ def test_create_object_with_empty_data_valid(self, m): self.assertEqual(response.status_code, status.HTTP_201_CREATED) - def test_create_object_with_empty_data_invalid(self, m): + def test_create_object_with_empty_data_invalid( + self, + ): """ regression test for https://github.com/maykinmedia/objects-api/issues/371 """ - 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)) + ObjectTypeVersionFactory.create(object_type=self.object_type) url = reverse("object-list") data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {}, @@ -421,12 +188,8 @@ def test_create_object_with_empty_data_invalid(self, m): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_update_object_with_correction_invalid(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), - ) + def test_update_object_with_correction_invalid(self): + ObjectTypeVersionFactory.create(object_type=self.object_type) corrected_record, initial_record = ObjectRecordFactory.create_batch( 2, object__object_type=self.object_type @@ -434,7 +197,7 @@ def test_update_object_with_correction_invalid(self, m): object = initial_record.object url = reverse("object-detail", args=[object.uuid]) data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12", "diameter": 30}, @@ -453,15 +216,13 @@ def test_update_object_with_correction_invalid(self, m): ["Object with index=5 does not exist."], ) - def test_update_object_type_invalid(self, m): - old_object_type = ObjectTypeFactory.create(service=self.object_type.service) + def test_update_object_type_invalid(self): + old_object_type = ObjectTypeFactory.create() PermissionFactory.create( object_type=old_object_type, mode=PermissionModes.read_and_write, token_auth=self.token_auth, ) - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - m.get(self.object_type.url, json=mock_objecttype(self.object_type.url)) initial_record = ObjectRecordFactory.create( object__object_type=old_object_type, @@ -472,7 +233,7 @@ def test_update_object_type_invalid(self, m): url = reverse("object-detail", args=[object.uuid]) data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", } response = self.client.patch(url, data, **GEO_WRITE_KWARGS) @@ -485,10 +246,7 @@ def test_update_object_type_invalid(self, m): ["This field can't be changed"], ) - def test_update_uuid_invalid(self, m): - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - m.get(self.object_type.url, json=mock_objecttype(self.object_type.url)) - + def test_update_uuid_invalid(self): initial_record = ObjectRecordFactory.create( object__object_type=self.object_type ) @@ -504,16 +262,10 @@ def test_update_uuid_invalid(self, m): data = response.json() self.assertEqual(data["uuid"], ["This field can't be changed"]) - def test_update_geometry_not_allowed(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, attrs={"allowGeometry": False}), - ) + def test_update_geometry_not_allowed(self): + self.object_type.allow_geometry = False + self.object_type.save() + ObjectTypeVersionFactory.create(object_type=self.object_type) initial_record = ObjectRecordFactory.create( object__object_type=self.object_type, geometry=None @@ -524,7 +276,7 @@ def test_update_geometry_not_allowed(self, m): data = { "record": { "typeVersion": 1, - "data": {"plantDate": "2020-04-12", "diameter": 30}, + "data": {"diameter": 30}, "geometry": { "type": "Point", "coordinates": [4.910649523925713, 52.37240093589432], @@ -569,7 +321,6 @@ def test_create_object_with_duplicate_uuid_returns_400(self, m): response.json()["uuid"], ["An object with this UUID already exists."] ) - # TODO from objecttypes def test_patch_objecttype_with_uuid_fail(self): object_type = ObjectTypeFactory.create() url = reverse("objecttype-detail", args=[object_type.uuid]) diff --git a/src/objects/token/admin.py b/src/objects/token/admin.py index e2472669..8578ce4f 100644 --- a/src/objects/token/admin.py +++ b/src/objects/token/admin.py @@ -1,10 +1,8 @@ -from django.contrib import admin, messages +from django.contrib import admin from django.contrib.admin.utils import unquote -from django.utils.translation import gettext_lazy as _ from objects.api.serializers import ObjectSerializer from objects.core.models import ObjectType -from objects.core.utils import can_connect_to_objecttypes from objects.utils.admin import EditInlineAdminMixin from objects.utils.serializers import build_spec, get_field_names @@ -78,19 +76,12 @@ def get_extra_context(self, request, object_id): "object_type_choices": object_type_choices, "mode_choices": mode_choices, "form_data": self.get_form_data(request, object_id), - "objecttypes_available": can_connect_to_objecttypes(), } def change_view(self, request, object_id, form_url="", extra_context=None): extra_context = extra_context or {} extra_context.update(self.get_extra_context(request, object_id)) - if extra_context["objecttypes_available"] is False: - msg = _( - "ObjectTypes API is not reachable. Field-based authorization is impossible" - ) - self.message_user(request, msg, messages.WARNING) - return super().change_view( request, object_id, @@ -102,12 +93,6 @@ def add_view(self, request, form_url="", extra_context=None): extra_context = extra_context or {} extra_context.update(self.get_extra_context(request, object_id=None)) - if extra_context["objecttypes_available"] is False: - msg = _( - "ObjectTypes API is not reachable. Field-based authorization is impossible" - ) - self.message_user(request, msg, messages.WARNING) - return super().add_view(request, form_url, extra_context) diff --git a/src/objects/token/tests/test_admin.py b/src/objects/token/tests/test_admin.py index 16fdcce2..54a6edfe 100644 --- a/src/objects/token/tests/test_admin.py +++ b/src/objects/token/tests/test_admin.py @@ -3,8 +3,6 @@ from maykin_2fa.test import disable_admin_mfa from maykin_common.vcr import VCRMixin -from zgw_consumers.constants import AuthTypes -from zgw_consumers.test.factories import ServiceFactory from objects.accounts.tests.factories import UserFactory from objects.core.tests.factories import ObjectTypeFactory @@ -12,8 +10,6 @@ @disable_admin_mfa() class PermissionAdminTests(VCRMixin, TestCase): - object_types_api = "http://127.0.0.1:8008/api/{version}/" - def setUp(self): super().setUp() @@ -27,15 +23,7 @@ def test_with_object_types_api_v2(self): Regression test for #449. Test if Permission admin can handle objecttypes API V2 which added pagination """ - v2_service = ServiceFactory.create( - api_root=self.object_types_api.format(version="v2"), - auth_type=AuthTypes.api_key, - header_key="Authorization", - header_value="Token 5cebbb33ffa725b6ed5e9e98300061218ba98d71", - ) - object_type = ObjectTypeFactory.create( - service=v2_service, uuid="71a2452a-66c3-4030-b5ec-a06035102e9e" - ) + object_type = ObjectTypeFactory.create(uuid="71a2452a-66c3-4030-b5ec-a06035102e9e") response = self.client.get(self.url) self.assertEqual(response.status_code, 200) @@ -49,5 +37,5 @@ def test_with_object_types_api_v2(self): ) self.assertEqual( choices[1][1], - f"{v2_service.label}: {object_type._name}", + str(object_type), ) diff --git a/src/objects/utils/filters.py b/src/objects/utils/filters.py index a56ac7d6..c150d118 100644 --- a/src/objects/utils/filters.py +++ b/src/objects/utils/filters.py @@ -46,7 +46,7 @@ def to_python(self, value): return result -class ObjectTypeFilter(URLModelChoiceFilter): +class ObjectTypeFilter(URLModelChoiceFilter): # TODO remove? field_class = ObjectTypeField diff --git a/src/objects/utils/oas_extensions/__init__.py b/src/objects/utils/oas_extensions/__init__.py index df74f7e8..e1d4dac0 100644 --- a/src/objects/utils/oas_extensions/__init__.py +++ b/src/objects/utils/oas_extensions/__init__.py @@ -1,4 +1,4 @@ -from .fields import HyperlinkedIdentityFieldExtension, ObjectTypeField +from .fields import HyperlinkedIdentityFieldExtension from .geojson import GeometryFieldExtension from .query import DjangoFilterExtension @@ -6,5 +6,4 @@ "DjangoFilterExtension", "GeometryFieldExtension", "HyperlinkedIdentityFieldExtension", - "ObjectTypeField", ) diff --git a/src/objects/utils/oas_extensions/fields.py b/src/objects/utils/oas_extensions/fields.py index 72519b5b..36760bd5 100644 --- a/src/objects/utils/oas_extensions/fields.py +++ b/src/objects/utils/oas_extensions/fields.py @@ -3,22 +3,6 @@ from drf_spectacular.types import OpenApiTypes from rest_framework import serializers -from objects.api.fields import ObjectTypeField - - -class ObjectTypeExtension(OpenApiSerializerFieldExtension): - target_class = ObjectTypeField - - def map_serializer_field(self, auto_schema, direction): - schema = build_basic_type(OpenApiTypes.URI) - schema.update( - { - "minLength": 1, - "maxLength": 1000, - } - ) - return schema - class HyperlinkedIdentityFieldExtension(OpenApiSerializerFieldExtension): target_class = serializers.HyperlinkedIdentityField diff --git a/src/objects/utils/serializers.py b/src/objects/utils/serializers.py index 4fdc2cca..d613cce6 100644 --- a/src/objects/utils/serializers.py +++ b/src/objects/utils/serializers.py @@ -3,6 +3,7 @@ from glom import SKIP, GlomError, glom from rest_framework import fields, serializers +from objects.tests.v2.utils import reverse from objects.token.constants import PermissionModes ALL_FIELDS = ["*"] @@ -106,7 +107,13 @@ def to_representation(self, instance): not_allowed = set(get_field_names(data)) - set(get_field_names(result_data)) if not_allowed: self.not_allowed[ - f"{instance._object_type.url}({instance.version})" + f"{ + self.context['request'].build_absolute_uri( + reverse( + 'objecttype-detail', args=[instance._object_type.uuid] + ) + ) + }({instance.version})" ] |= not_allowed else: spec_query = build_spec(query_fields) @@ -121,7 +128,13 @@ def to_representation(self, instance): ) if not_allowed: self.not_allowed[ - f"{instance._object_type.url}({instance.version})" + f"{ + self.context['request'].build_absolute_uri( + reverse( + 'objecttype-detail', args=[instance._object_type.uuid] + ) + ) + }({instance.version})" ] |= not_allowed return result_data diff --git a/src/objects/utils/tests/test_client.py b/src/objects/utils/tests/test_client.py deleted file mode 100644 index 701ad768..00000000 --- a/src/objects/utils/tests/test_client.py +++ /dev/null @@ -1,105 +0,0 @@ -import requests_mock -from rest_framework.test import APITestCase - -from objects.core.models import ObjectType -from objects.core.tests.factories import ObjectTypeFactory -from objects.tests.utils import ( - mock_objecttype, - mock_objecttype_version, - mock_service_oas_get, -) -from objects.token.constants import PermissionModes -from objects.token.tests.factories import PermissionFactory -from objects.utils.client import get_objecttypes_client - -from ..test import TokenAuthMixin - -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - - -@requests_mock.Mocker() -class ObjecttypesClientTest(TokenAuthMixin, APITestCase): - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - cls.object_type = ObjectTypeFactory.create(service__api_root=OBJECT_TYPES_API) - PermissionFactory.create( - object_type=cls.object_type, - mode=PermissionModes.read_and_write, - token_auth=cls.token_auth, - ) - - def test_list_objecttypes(self, m): - object_type = ObjectType.objects.first() - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - object_type_mock = mock_objecttype(object_type.url) - m.get( - f"{OBJECT_TYPES_API}objecttypes", - json={ - "count": 1, - "next": None, - "previous": None, - "results": [object_type_mock], - }, - ) - with get_objecttypes_client(object_type.service) as client: - self.assertTrue(client.can_connect) - data = client.list_objecttypes() - self.assertEqual(data, [object_type_mock]) - self.assertEqual(len(data), 1) - - def test_get_objecttype(self, m): - object_type = ObjectType.objects.first() - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - m.get(object_type.url, json=mock_objecttype(object_type.url)) - - with get_objecttypes_client(object_type.service) as client: - data = client.get_objecttype(object_type.uuid) - self.assertTrue(data["url"], str(object_type.url)) - - def test_list_objecttype_versions(self, m): - object_type = ObjectType.objects.first() - mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") - object_type_mock = mock_objecttype(object_type.url) - m.get( - f"{OBJECT_TYPES_API}objecttypes", - json={ - "count": 1, - "next": None, - "previous": None, - "results": [object_type_mock], - }, - ) - version_mock = mock_objecttype_version(object_type.url) - m.get( - f"{object_type.url}/versions", - json={ - "count": 1, - "next": None, - "previous": None, - "results": [version_mock], - }, - ) - - with get_objecttypes_client(object_type.service) as client: - self.assertTrue(client.can_connect) - data = client.list_objecttypes() - self.assertEqual(data, [object_type_mock]) - self.assertEqual(len(data), 1) - data = client.list_objecttype_versions(object_type.uuid) - self.assertEqual(data, [version_mock]) - self.assertEqual(len(data), 1) - self.assertEqual(data[0]["version"], 1) - - def test_get_objecttype_version(self, m): - object_type = ObjectType.objects.first() - 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), - ) - - with get_objecttypes_client(object_type.service) as client: - data = client.get_objecttype_version(object_type.uuid, 1) - self.assertEqual(data["url"], f"{self.object_type.url}/versions/1") From 1e47d4ad992e58cd6d5b9dc17ab496c4dfd484ec Mon Sep 17 00:00:00 2001 From: floris272 Date: Wed, 24 Dec 2025 13:20:56 +0100 Subject: [PATCH 04/15] :green_heart: [#564] fix ci --- src/objects/conf/base.py | 1 - src/objects/tests/v2/test_validation.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/objects/conf/base.py b/src/objects/conf/base.py index a0e787eb..06b9625d 100644 --- a/src/objects/conf/base.py +++ b/src/objects/conf/base.py @@ -168,7 +168,6 @@ "zgw_consumers.contrib.setup_configuration.steps.ServiceConfigurationStep", "notifications_api_common.contrib.setup_configuration.steps.NotificationConfigurationStep", "mozilla_django_oidc_db.setup_configuration.steps.AdminOIDCConfigurationStep", - "objects.setup_configuration.steps.objecttypes.ObjectTypesConfigurationStep", "objects.setup_configuration.steps.token_auth.TokenAuthConfigurationStep", ) diff --git a/src/objects/tests/v2/test_validation.py b/src/objects/tests/v2/test_validation.py index 00233b10..a6bd6229 100644 --- a/src/objects/tests/v2/test_validation.py +++ b/src/objects/tests/v2/test_validation.py @@ -268,7 +268,7 @@ def test_update_geometry_not_allowed(self): ObjectTypeVersionFactory.create(object_type=self.object_type) initial_record = ObjectRecordFactory.create( - object__object_type=self.object_type, geometry=None + object__object_type=self.object_type, geometry=None, data={"diameter": 20} ) object = initial_record.object From 2cb188fb86b578fbfbde76945de79e04294d56fc Mon Sep 17 00:00:00 2001 From: floris272 Date: Wed, 24 Dec 2025 13:38:25 +0100 Subject: [PATCH 05/15] :recycle: [#564] update ObjectTypeVersionFactory data and remove OBJECTTYPE_VERSION_CACHE_TIMEOUT --- docs/installation/config.rst | 8 +------- src/objects/conf/base.py | 11 ----------- src/objects/core/tests/factories.py | 6 +++++- src/objects/tests/v2/test_object_api.py | 23 +++++++++++------------ 4 files changed, 17 insertions(+), 31 deletions(-) diff --git a/docs/installation/config.rst b/docs/installation/config.rst index 407063b7..bbb9d027 100644 --- a/docs/installation/config.rst +++ b/docs/installation/config.rst @@ -32,7 +32,7 @@ Database * ``DB_HOST``: hostname of the PostgreSQL database. Defaults to ``db`` for the docker environment, otherwise defaults to ``localhost``. * ``DB_PORT``: port number of the database. Defaults to: ``5432``. * ``DB_CONN_MAX_AGE``: The lifetime of a database connection, as an integer of seconds. Use 0 to close database connections at the end of each request — Django’s historical behavior. This setting is ignored if connection pooling is used. Defaults to: ``60``. -* ``DB_POOL_ENABLED``: **Experimental:** Whether to use connection pooling. This feature is not yet recommended for production use. See the documentation for details: https://open-api-framework.readthedocs.io/en/latest/connection_pooling.html. Defaults to: ``False``. +* ``DB_POOL_ENABLED``: Whether to use connection pooling. Defaults to: ``False``. * ``DB_POOL_MIN_SIZE``: The minimum number of connection the pool will hold. The pool will actively try to create new connections if some are lost (closed, broken) and will try to never go below min_size. Defaults to: ``4``. * ``DB_POOL_MAX_SIZE``: The maximum number of connections the pool will hold. If None, or equal to min_size, the pool will not grow or shrink. If larger than min_size, the pool can grow if more than min_size connections are requested at the same time and will shrink back after the extra connections have been unused for more than max_idle seconds. Defaults to: ``None``. * ``DB_POOL_TIMEOUT``: The default maximum time in seconds that a client can wait to receive a connection from the pool (using connection() or getconn()). Note that these methods allow to override the timeout default. Defaults to: ``30``. @@ -98,12 +98,6 @@ Content Security Policy * ``CSP_REPORT_PERCENTAGE``: Fraction (between 0 and 1) of requests to include report-uri directive. Defaults to: ``0.0``. -Cache ------ - -* ``OBJECTTYPE_VERSION_CACHE_TIMEOUT``: Timeout in seconds for cache when retrieving objecttype versions. Defaults to: ``300``. - - Optional -------- diff --git a/src/objects/conf/base.py b/src/objects/conf/base.py index 06b9625d..adaa080a 100644 --- a/src/objects/conf/base.py +++ b/src/objects/conf/base.py @@ -65,17 +65,6 @@ # FIXME should this be UTC? TIME_ZONE = "Europe/Amsterdam" -# -# Caches -# - -OBJECTTYPE_VERSION_CACHE_TIMEOUT = config( - "OBJECTTYPE_VERSION_CACHE_TIMEOUT", - default=5 * 60, # 300 seconds - help_text="Timeout in seconds for cache when retrieving objecttype versions.", - group="Cache", -) - # # Additional Django settings # diff --git a/src/objects/core/tests/factories.py b/src/objects/core/tests/factories.py index 221f36ed..4dfe4f70 100644 --- a/src/objects/core/tests/factories.py +++ b/src/objects/core/tests/factories.py @@ -26,7 +26,11 @@ class ObjectTypeVersionFactory(factory.django.DjangoModelFactory[ObjectTypeVersi "title": "Tree", "$schema": "http://json-schema.org/draft-07/schema#", "required": ["diameter"], - "properties": {"diameter": {"type": "integer", "description": "size in cm."}}, + "properties": {"diameter": {"type": "integer", "description": "size in cm."}, "plantDate": { + "type": "string", + "format": "date", + "description": "Date the tree was planted.", + },}, } class Meta: diff --git a/src/objects/tests/v2/test_object_api.py b/src/objects/tests/v2/test_object_api.py index aba925ec..6d3821b0 100644 --- a/src/objects/tests/v2/test_object_api.py +++ b/src/objects/tests/v2/test_object_api.py @@ -24,7 +24,6 @@ @freeze_time("2020-08-08") -@requests_mock.Mocker() class ObjectApiTests(TokenAuthMixin, APITestCase): maxDiff = None @@ -43,7 +42,7 @@ def setUpTestData(cls): token_auth=cls.token_auth, ) - def test_list_actual_objects(self, m): + def test_list_actual_objects(self): object_record1 = ObjectRecordFactory.create( object__object_type=self.object_type, start_at=date.today(), @@ -89,7 +88,7 @@ def test_list_actual_objects(self, m): }, ) - def test_retrieve_object(self, m): + def test_retrieve_object(self): object = ObjectFactory.create(object_type=self.object_type) object_record = ObjectRecordFactory.create( object=object, @@ -125,7 +124,7 @@ def test_retrieve_object(self, m): }, ) - def test_retrieve_by_index(self, m): + def test_retrieve_by_index(self): record1 = ObjectRecordFactory.create( object__object_type=self.object_type, start_at=date(2020, 1, 1), @@ -187,7 +186,7 @@ def test_retrieve_by_index(self, m): }, ) - def test_create_object(self, m): + def test_create_object(self): url = reverse("object-list") data = { "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", @@ -219,7 +218,7 @@ def test_create_object(self, m): self.assertEqual(record.geometry.coords, (4.910649523925713, 52.37240093589432)) self.assertIsNone(record.end_at) - def test_update_object(self, m): + def test_update_object(self): # 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( @@ -272,7 +271,7 @@ def test_update_object(self, m): self.assertEqual(initial_record.corrected, current_record) self.assertEqual(initial_record.end_at, date(2020, 1, 1)) - def test_patch_object_record(self, m): + def test_patch_object_record(self): initial_record = ObjectRecordFactory.create( version=1, object__object_type=self.object_type, @@ -315,7 +314,7 @@ def test_patch_object_record(self, m): 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(self, m): + def test_patch_validates_merged_object_rather_than_partial_object(self): initial_record = ObjectRecordFactory.create( version=1, object__object_type=self.object_type, @@ -348,7 +347,7 @@ def test_patch_validates_merged_object_rather_than_partial_object(self, m): {"plantDate": "2024-10-09", "diameter": 20, "name": "Name"}, ) - def test_delete_object(self, m): + def test_delete_object(self): record = ObjectRecordFactory.create(object__object_type=self.object_type) object = record.object url = reverse("object-detail", args=[object.uuid]) @@ -358,7 +357,7 @@ def test_delete_object(self, m): self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(Object.objects.count(), 0) - def test_history_object(self, m): + def test_history_object(self): record1 = ObjectRecordFactory.create( object__object_type=self.object_type, start_at=date(2020, 1, 1), @@ -411,7 +410,7 @@ def test_history_object(self, m): # In the ticket https://github.com/maykinmedia/objects-api/issues/282 we discovered that updating an object \ # where the startAt value has been modified with an earlier date causes an 500 response. - def test_updating_object_after_changing_the_startAt_value_returns_200(self, m): + def test_updating_object_after_changing_the_startAt_value_returns_200(self): object_uuid = uuid.uuid4() url_object_list = reverse("object-list") @@ -465,7 +464,7 @@ def test_updating_object_after_changing_the_startAt_value_returns_200(self, m): ) # regression test for https://github.com/maykinmedia/objects-api/issues/268 - def test_update_object_correctionFor(self, m): + def test_update_object_correctionFor(self): initial_record = ObjectRecordFactory.create( object__object_type=self.object_type, version=1 ) From 5c9305e6295b08824b5cff7f9e650262440f4840 Mon Sep 17 00:00:00 2001 From: floris272 Date: Wed, 7 Jan 2026 09:49:58 +0100 Subject: [PATCH 06/15] :white_check_mark: [#564] fix default test objecttype JSON_SCHEMA --- docs/installation/config.rst | 2 +- src/objects/core/tests/factories.py | 13 ++++++++----- src/objects/tests/v2/test_object_api.py | 1 - src/objects/tests/v2/test_objecttypeversion_api.py | 9 ++++++++- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/installation/config.rst b/docs/installation/config.rst index bbb9d027..75cf37b6 100644 --- a/docs/installation/config.rst +++ b/docs/installation/config.rst @@ -32,7 +32,7 @@ Database * ``DB_HOST``: hostname of the PostgreSQL database. Defaults to ``db`` for the docker environment, otherwise defaults to ``localhost``. * ``DB_PORT``: port number of the database. Defaults to: ``5432``. * ``DB_CONN_MAX_AGE``: The lifetime of a database connection, as an integer of seconds. Use 0 to close database connections at the end of each request — Django’s historical behavior. This setting is ignored if connection pooling is used. Defaults to: ``60``. -* ``DB_POOL_ENABLED``: Whether to use connection pooling. Defaults to: ``False``. +* ``DB_POOL_ENABLED``: **Experimental:** Whether to use connection pooling. This feature is not yet recommended for production use. See the documentation for details: https://open-api-framework.readthedocs.io/en/latest/connection_pooling.html. Defaults to: ``False``. * ``DB_POOL_MIN_SIZE``: The minimum number of connection the pool will hold. The pool will actively try to create new connections if some are lost (closed, broken) and will try to never go below min_size. Defaults to: ``4``. * ``DB_POOL_MAX_SIZE``: The maximum number of connections the pool will hold. If None, or equal to min_size, the pool will not grow or shrink. If larger than min_size, the pool can grow if more than min_size connections are requested at the same time and will shrink back after the extra connections have been unused for more than max_idle seconds. Defaults to: ``None``. * ``DB_POOL_TIMEOUT``: The default maximum time in seconds that a client can wait to receive a connection from the pool (using connection() or getconn()). Note that these methods allow to override the timeout default. Defaults to: ``30``. diff --git a/src/objects/core/tests/factories.py b/src/objects/core/tests/factories.py index 4dfe4f70..bb561551 100644 --- a/src/objects/core/tests/factories.py +++ b/src/objects/core/tests/factories.py @@ -26,11 +26,14 @@ class ObjectTypeVersionFactory(factory.django.DjangoModelFactory[ObjectTypeVersi "title": "Tree", "$schema": "http://json-schema.org/draft-07/schema#", "required": ["diameter"], - "properties": {"diameter": {"type": "integer", "description": "size in cm."}, "plantDate": { - "type": "string", - "format": "date", - "description": "Date the tree was planted.", - },}, + "properties": { + "diameter": {"type": "integer", "description": "size in cm."}, + "plantDate": { + "type": "string", + "format": "date", + "description": "Date the tree was planted.", + }, + }, } class Meta: diff --git a/src/objects/tests/v2/test_object_api.py b/src/objects/tests/v2/test_object_api.py index 6d3821b0..26d02290 100644 --- a/src/objects/tests/v2/test_object_api.py +++ b/src/objects/tests/v2/test_object_api.py @@ -2,7 +2,6 @@ import uuid from datetime import date, timedelta -import requests_mock from freezegun import freeze_time from rest_framework import status from rest_framework.test import APITestCase diff --git a/src/objects/tests/v2/test_objecttypeversion_api.py b/src/objects/tests/v2/test_objecttypeversion_api.py index 160ad347..23348e43 100644 --- a/src/objects/tests/v2/test_objecttypeversion_api.py +++ b/src/objects/tests/v2/test_objecttypeversion_api.py @@ -16,7 +16,14 @@ "title": "Tree", "$schema": "http://json-schema.org/draft-07/schema#", "required": ["diameter"], - "properties": {"diameter": {"type": "integer", "description": "size in cm."}}, + "properties": { + "diameter": {"type": "integer", "description": "size in cm."}, + "plantDate": { + "type": "string", + "format": "date", + "description": "Date the tree was planted.", + }, + }, } From 681ea8b671e3c135c0c6b615814fd6388e05fe3e Mon Sep 17 00:00:00 2001 From: floris272 Date: Wed, 7 Jan 2026 14:24:04 +0100 Subject: [PATCH 07/15] :white_check_mark: [#564] fix factory dict assignment --- src/objects/core/tests/factories.py | 28 +++++++++++++------------ src/objects/tests/v2/test_object_api.py | 4 ++-- src/objects/utils/cache.py | 20 ------------------ 3 files changed, 17 insertions(+), 35 deletions(-) delete mode 100644 src/objects/utils/cache.py diff --git a/src/objects/core/tests/factories.py b/src/objects/core/tests/factories.py index bb561551..d94a5ed4 100644 --- a/src/objects/core/tests/factories.py +++ b/src/objects/core/tests/factories.py @@ -21,20 +21,22 @@ class Meta: class ObjectTypeVersionFactory(factory.django.DjangoModelFactory[ObjectTypeVersion]): object_type = factory.SubFactory(ObjectTypeFactory) - json_schema = { - "type": "object", - "title": "Tree", - "$schema": "http://json-schema.org/draft-07/schema#", - "required": ["diameter"], - "properties": { - "diameter": {"type": "integer", "description": "size in cm."}, - "plantDate": { - "type": "string", - "format": "date", - "description": "Date the tree was planted.", + json_schema = factory.Dict( + { + "type": "object", + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": ["diameter"], + "properties": { + "diameter": {"type": "integer", "description": "size in cm."}, + "plantDate": { + "type": "string", + "format": "date", + "description": "Date the tree was planted.", + }, }, - }, - } + } + ) class Meta: model = ObjectTypeVersion diff --git a/src/objects/tests/v2/test_object_api.py b/src/objects/tests/v2/test_object_api.py index 26d02290..75f76aff 100644 --- a/src/objects/tests/v2/test_object_api.py +++ b/src/objects/tests/v2/test_object_api.py @@ -290,7 +290,7 @@ def test_patch_object_record(self): response = self.client.patch(url, data, **GEO_WRITE_KWARGS) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) initial_record.refresh_from_db() @@ -334,7 +334,7 @@ def test_patch_validates_merged_object_rather_than_partial_object(self): } response = self.client.patch(url, data, **GEO_WRITE_KWARGS) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) self.assertEqual( response.json()["record"]["data"], {"plantDate": "2024-10-09", "diameter": 20, "name": "Name"}, diff --git a/src/objects/utils/cache.py b/src/objects/utils/cache.py deleted file mode 100644 index 6a457744..00000000 --- a/src/objects/utils/cache.py +++ /dev/null @@ -1,20 +0,0 @@ -from functools import wraps - -from django.core.cache import caches - - -def cache(key: str, alias: str = "default", **set_options): - def decorator(func: callable): - @wraps(func) - def wrapped(*args, **kwargs): - _cache = caches[alias] - result = _cache.get(key) - if result is not None: - return result - result = func(*args, **kwargs) - _cache.set(key, result, **set_options) - return result - - return wrapped - - return decorator From be8b0a493dca83fe7bc36b40858ac0dca4dbb5ca Mon Sep 17 00:00:00 2001 From: floris272 Date: Wed, 7 Jan 2026 15:26:39 +0100 Subject: [PATCH 08/15] :green_heart: [#564] remove objecttype from example setup config data --- docker/setup_configuration/data.yaml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docker/setup_configuration/data.yaml b/docker/setup_configuration/data.yaml index 529caf49..5b87eb79 100644 --- a/docker/setup_configuration/data.yaml +++ b/docker/setup_configuration/data.yaml @@ -36,14 +36,15 @@ tokenauth: organization: Organization 1 application: Application 1 administration: Administration 1 - permissions: - - object_type: b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2 - mode: read_only - use_fields: true - fields: - '1': - - record__data__leeftijd - - record__data__kiemjaar +# TODO +# permissions: +# - object_type: b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2 +# mode: read_only +# use_fields: true +# fields: +# '1': +# - record__data__leeftijd +# - record__data__kiemjaar # additional permissions can be added like this: # - object_type: b427ef84-189d-43aa-9efd-7bb2c459e281 # mode: read_and_write From 9d90670a97ee4956b8a15d2572903239b18458d3 Mon Sep 17 00:00:00 2001 From: floris272 Date: Tue, 27 Jan 2026 15:09:06 +0100 Subject: [PATCH 09/15] :recycle: [#564] add requested changes --- docker-compose.yml | 40 +- performance_test/create_data.py | 2 +- src/objects/api/fields.py | 35 +- src/objects/api/serializers.py | 28 +- src/objects/api/v2/filters.py | 2 +- src/objects/api/v2/openapi.yaml | 934 +++++++++++++++++- src/objects/api/v2/views.py | 3 +- src/objects/conf/api.py | 7 +- src/objects/conf/ci.py | 3 + .../management/commands/import_objecttypes.py | 10 +- ...er_objecttype_unique_together_and_more.py} | 6 +- src/objects/core/models.py | 12 +- src/objects/core/tests/factories.py | 1 + src/objects/core/tests/test_admin.py | 8 +- .../core/tests/test_import_objecttypes.py | 7 +- src/objects/tests/v2/test_auth_fields.py | 2 +- src/objects/tests/v2/test_filters.py | 2 +- src/objects/tests/v2/test_jsonschema.py | 2 - .../tests/v2/test_notifications_send.py | 76 +- src/objects/tests/v2/test_object_api.py | 40 +- src/objects/tests/v2/test_pagination.py | 2 +- src/objects/tests/v2/test_validation.py | 135 +-- src/objects/token/tests/test_admin.py | 4 +- src/objects/utils/autoschema.py | 34 +- src/objects/utils/filters.py | 2 +- src/objects/utils/oas_extensions/__init__.py | 4 +- src/objects/utils/oas_extensions/fields.py | 22 +- src/objects/utils/test.py | 2 +- 28 files changed, 1154 insertions(+), 271 deletions(-) rename src/objects/core/migrations/{0037_alter_objecttype_unique_together_and_more.py => 0038_alter_objecttype_unique_together_and_more.py} (94%) diff --git a/docker-compose.yml b/docker-compose.yml index e427ba0a..7c86f3eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,7 @@ services: SUBPATH: ${SUBPATH} DB_CONN_MAX_AGE: "0" DB_POOL_ENABLED: True - + # Enabling Open Telemetry requires the services in docker/docker-compose.observability.yaml # to be up and running. OTEL_SDK_DISABLED: ${OTEL_SDK_DISABLED:-true} @@ -89,44 +89,6 @@ services: networks: - objects-dev - objecttypes-web: - image: maykinmedia/objecttypes-api:latest - environment: &app-env - DB_NAME: objecttypes - DB_USER: objecttypes - DJANGO_SETTINGS_MODULE: objecttypes.conf.docker - SECRET_KEY: ${SECRET_KEY:-fgv=c0hz&tl*8*3m3893@m+1pstrvidc9e^5@fpspmg%cyf15d} - ALLOWED_HOSTS: '*' - CACHE_DEFAULT: redis:6379/0 - CACHE_AXES: redis:6379/0 - DISABLE_2FA: yes - SUBPATH: ${SUBPATH:-/} - volumes: - - ./docker/objecttypes/objecttypes.json:/app/fixtures/objecttypes.json - ports: - - 8001:8000 - depends_on: - objecttypes-web-init: - condition: service_completed_successfully - networks: - - objects-dev - - objecttypes-web-init: - image: maykinmedia/objecttypes-api:latest - environment: - <<: *app-env - # - # Django-setup-configuration - RUN_SETUP_CONFIG: ${RUN_SETUP_CONFIG:-true} - command: /setup_configuration.sh - volumes: - - ./docker/setup_configuration/objecttypes_data.yaml:/app/setup_configuration/data.yaml - depends_on: - - db - - redis - networks: - - objects-dev - celery: image: maykinmedia/objects-api:latest build: *web_build diff --git a/performance_test/create_data.py b/performance_test/create_data.py index 6a839fa5..0cad8c22 100644 --- a/performance_test/create_data.py +++ b/performance_test/create_data.py @@ -13,7 +13,7 @@ uuid="f1220670-8ab7-44f1-a318-bd0782e97662", ) -token = TokenAuthFactory(token="secret", is_superuser=False) +token = TokenAuthFactory.create(token="secret", is_superuser=False) PermissionFactory.create( object_type=object_type, mode=PermissionModes.read_only, diff --git a/src/objects/api/fields.py b/src/objects/api/fields.py index b532ee83..2dcb483d 100644 --- a/src/objects/api/fields.py +++ b/src/objects/api/fields.py @@ -1,7 +1,9 @@ +from django.utils.translation import gettext_lazy as _ + from rest_framework import serializers from vng_api_common.serializers import CachedHyperlinkedIdentityField -from objects.core.models import ObjectRecord +from objects.core.models import ObjectRecord, ObjectType class ObjectSlugRelatedField(serializers.SlugRelatedField): @@ -20,16 +22,31 @@ def get_queryset(self): return queryset.filter(object=record_instance.object) -class ObjectUrlField(serializers.HyperlinkedIdentityField): - lookup_field = "uuid" +class ObjectTypeField(serializers.HyperlinkedRelatedField): + default_error_messages = { + "max_length": _("The value has too many characters"), + "min_length": _("The value has too few characters"), + } - def get_url(self, obj, view_name, request, format): - if hasattr(obj, "pk") and obj.pk in (None, ""): - return None + def __init__(self, **kwargs): + self.max_length = kwargs.pop("max_length", None) + self.min_length = kwargs.pop("min_length", None) + + kwargs.setdefault("queryset", ObjectType.objects.all()) + kwargs.setdefault("view_name", "objecttype-detail") + kwargs.setdefault("lookup_field", "uuid") + kwargs.setdefault("lookup_url_kwarg", "uuid") + + super().__init__(**kwargs) + + def to_internal_value(self, data): + if self.max_length and len(data) > self.max_length: + self.fail("max_length") + + if self.min_length and len(data) < self.min_length: + self.fail("min_length") - lookup_value = getattr(obj.object, "uuid") - kwargs = {self.lookup_url_kwarg: lookup_value} - return self.reverse(view_name, kwargs=kwargs, request=request, format=format) + return super().to_internal_value(data) class CachedObjectUrlField(CachedHyperlinkedIdentityField): diff --git a/src/objects/api/serializers.py b/src/objects/api/serializers.py index 0a28fe9f..26d55031 100644 --- a/src/objects/api/serializers.py +++ b/src/objects/api/serializers.py @@ -10,11 +10,17 @@ from rest_framework_nested.serializers import NestedHyperlinkedModelSerializer from vng_api_common.utils import get_help_text -from objects.core.models import Object, ObjectRecord, ObjectType, ObjectTypeVersion, Reference +from objects.core.models import ( + Object, + ObjectRecord, + ObjectType, + ObjectTypeVersion, + Reference, +) from objects.token.models import Permission, TokenAuth from objects.utils.serializers import DynamicFieldsMixin -from .fields import CachedObjectUrlField, ObjectSlugRelatedField +from .fields import CachedObjectUrlField, ObjectSlugRelatedField, ObjectTypeField from .utils import merge_patch from .validators import ( GeometryValidator, @@ -146,11 +152,13 @@ class Meta: "modifiedAt": {"source": "modified_at", "read_only": True}, } + class ReferenceSerializer(serializers.ModelSerializer): class Meta: model = Reference fields = ["type", "url"] + class ObjectRecordSerializer(serializers.ModelSerializer[ObjectRecord]): correctionFor = ObjectSlugRelatedField( source="correct", @@ -240,13 +248,11 @@ class ObjectSerializer(DynamicFieldsMixin, serializers.HyperlinkedModelSerialize ], help_text=_("Unique identifier (UUID4)"), ) - type = serializers.HyperlinkedRelatedField( + type = ObjectTypeField( + min_length=1, + max_length=1000, source="_object_type", - queryset=ObjectType.objects.all(), - view_name="objecttype-detail", help_text=_("Url reference to OBJECTTYPE"), - lookup_url_kwarg="uuid", - lookup_field="uuid", validators=[IsImmutableValidator()], ) record = ObjectRecordSerializer( @@ -334,13 +340,11 @@ class ObjectSearchSerializer(serializers.Serializer): class PermissionSerializer(serializers.ModelSerializer): - type = serializers.HyperlinkedRelatedField( + type = ObjectTypeField( + min_length=1, + max_length=1000, source="object_type", - queryset=ObjectType.objects.all(), - view_name="objecttype-detail", help_text=_("Url reference to OBJECTTYPE"), - lookup_url_kwarg="uuid", - lookup_field="uuid", validators=[IsImmutableValidator()], ) diff --git a/src/objects/api/v2/filters.py b/src/objects/api/v2/filters.py index 153dc9eb..7311e0f7 100644 --- a/src/objects/api/v2/filters.py +++ b/src/objects/api/v2/filters.py @@ -170,7 +170,7 @@ def clean(self): class ObjectRecordFilterSet(FilterSet): type = ObjectTypeFilter( field_name="_object_type", - help_text=_("Url reference to OBJECTTYPE in Objecttypes API"), + help_text=_("Url reference to OBJECTTYPE"), queryset=ObjectType.objects.all(), min_length=1, max_length=1000, diff --git a/src/objects/api/v2/openapi.yaml b/src/objects/api/v2/openapi.yaml index cc94168b..35ef1e2d 100644 --- a/src/objects/api/v2/openapi.yaml +++ b/src/objects/api/v2/openapi.yaml @@ -197,8 +197,7 @@ paths: - name: pageSize required: false in: query - description: 'Het aantal resultaten terug te geven per pagina. (default: 100, - maximum: 500).' + description: 'Het aantal resultaten terug te geven per pagina. (default: 100).' schema: type: integer - in: query @@ -216,7 +215,7 @@ paths: format: uri maxLength: 1000 minLength: 1 - description: Url reference to OBJECTTYPE in Objecttypes API + description: Url reference to OBJECTTYPE - in: query name: typeVersion schema: @@ -640,8 +639,7 @@ paths: - name: pageSize required: false in: query - description: 'Het aantal resultaten terug te geven per pagina. (default: 100, - maximum: 500).' + description: 'Het aantal resultaten terug te geven per pagina. (default: 100).' schema: type: integer - in: path @@ -717,8 +715,7 @@ paths: - name: pageSize required: false in: query - description: 'Het aantal resultaten terug te geven per pagina. (default: 100, - maximum: 500).' + description: 'Het aantal resultaten terug te geven per pagina. (default: 100).' schema: type: integer tags: @@ -737,7 +734,7 @@ paths: format: uri maxLength: 1000 minLength: 1 - description: Url reference to OBJECTTYPE in Objecttypes API + description: Url reference to OBJECTTYPE data_attrs: type: string description: | @@ -849,6 +846,470 @@ paths: schema: $ref: '#/components/schemas/PaginatedObjectList' description: OK + /objecttypes: + get: + operationId: objecttype_list + parameters: + - in: query + name: dataClassification + schema: + type: string + enum: + - confidential + - intern + - open + - strictly_confidential + description: |- + Confidential level of the object type + + * `open` - Open + * `intern` - Intern + * `confidential` - Confidential + * `strictly_confidential` - Strictly confidential + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: pageSize + required: false + in: query + description: 'Het aantal resultaten terug te geven per pagina. (default: 100).' + schema: + type: integer + tags: + - objecttypes + security: + - tokenAuth: [] + responses: + '200': + headers: + 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/PaginatedObjectTypeList' + description: OK + post: + operationId: objecttype_create + parameters: + - in: header + name: Content-Type + schema: + type: string + enum: + - application/json + description: Content type of the request body. + required: true + tags: + - objecttypes + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectType' + required: true + security: + - tokenAuth: [] + responses: + '201': + headers: + 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/ObjectType' + description: Created + /objecttypes/{objecttype_uuid}/versions: + get: + operationId: objecttypeversion_list + description: Retrieve all versions of an OBJECTTYPE + parameters: + - in: path + name: objecttype_uuid + schema: + type: string + format: uuid + description: Unique identifier (UUID4) + required: true + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: pageSize + required: false + in: query + description: 'Het aantal resultaten terug te geven per pagina. (default: 100).' + schema: + type: integer + tags: + - objecttypes + security: + - tokenAuth: [] + responses: + '200': + headers: + 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/PaginatedObjectTypeVersionList' + description: OK + post: + operationId: objecttypeversion_create + description: Create an OBJECTTYPE with the given version. + parameters: + - in: header + name: Content-Type + schema: + type: string + enum: + - application/json + description: Content type of the request body. + required: true + - in: path + name: objecttype_uuid + schema: + type: string + format: uuid + description: Unique identifier (UUID4) + required: true + tags: + - objecttypes + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectTypeVersion' + security: + - tokenAuth: [] + responses: + '201': + headers: + 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/ObjectTypeVersion' + description: Created + /objecttypes/{objecttype_uuid}/versions/{version}: + get: + operationId: objecttypeversion_read + description: Retrieve an OBJECTTYPE with the given version. + parameters: + - in: path + name: objecttype_uuid + schema: + type: string + format: uuid + description: Unique identifier (UUID4) + required: true + - in: path + name: version + schema: + type: integer + maximum: 32767 + minimum: 0 + description: Integer version of the OBJECTTYPE + required: true + tags: + - objecttypes + security: + - tokenAuth: [] + responses: + '200': + headers: + 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/ObjectTypeVersion' + description: OK + put: + operationId: objecttypeversion_update + description: Update an OBJECTTYPE with the given version. + parameters: + - in: header + name: Content-Type + schema: + type: string + enum: + - application/json + description: Content type of the request body. + required: true + - in: path + name: objecttype_uuid + schema: + type: string + format: uuid + description: Unique identifier (UUID4) + required: true + - in: path + name: version + schema: + type: integer + maximum: 32767 + minimum: 0 + description: Integer version of the OBJECTTYPE + required: true + tags: + - objecttypes + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectTypeVersion' + security: + - tokenAuth: [] + responses: + '200': + headers: + 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/ObjectTypeVersion' + description: OK + patch: + operationId: objecttypeversion_partial_update + description: Partially update an OBJECTTYPE with the given version. + parameters: + - in: header + name: Content-Type + schema: + type: string + enum: + - application/json + description: Content type of the request body. + required: true + - in: path + name: objecttype_uuid + schema: + type: string + format: uuid + description: Unique identifier (UUID4) + required: true + - in: path + name: version + schema: + type: integer + maximum: 32767 + minimum: 0 + description: Integer version of the OBJECTTYPE + required: true + tags: + - objecttypes + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedObjectTypeVersion' + security: + - tokenAuth: [] + responses: + '200': + headers: + 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/ObjectTypeVersion' + description: OK + delete: + operationId: objecttypeversion_delete + description: Destroy the given OBJECTTYPE. + parameters: + - in: path + name: objecttype_uuid + schema: + type: string + format: uuid + description: Unique identifier (UUID4) + required: true + - in: path + name: version + schema: + type: integer + maximum: 32767 + minimum: 0 + description: Integer version of the OBJECTTYPE + required: true + tags: + - objecttypes + security: + - tokenAuth: [] + responses: + '204': + headers: + API-version: + schema: + type: string + description: 'Geeft een specifieke API-versie aan in de context van + een specifieke aanroep. Voorbeeld: 1.2.1.' + description: No response body + /objecttypes/{uuid}: + get: + operationId: objecttype_read + parameters: + - in: path + name: uuid + schema: + type: string + format: uuid + description: Unique identifier (UUID4) + required: true + tags: + - objecttypes + security: + - tokenAuth: [] + responses: + '200': + headers: + 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/ObjectType' + description: OK + put: + operationId: objecttype_update + parameters: + - in: header + name: Content-Type + schema: + type: string + enum: + - application/json + description: Content type of the request body. + required: true + - in: path + name: uuid + schema: + type: string + format: uuid + description: Unique identifier (UUID4) + required: true + tags: + - objecttypes + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectType' + required: true + security: + - tokenAuth: [] + responses: + '200': + headers: + 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/ObjectType' + description: OK + patch: + operationId: objecttype_partial_update + parameters: + - in: header + name: Content-Type + schema: + type: string + enum: + - application/json + description: Content type of the request body. + required: true + - in: path + name: uuid + schema: + type: string + format: uuid + description: Unique identifier (UUID4) + required: true + tags: + - objecttypes + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedObjectType' + security: + - tokenAuth: [] + responses: + '200': + headers: + 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/ObjectType' + description: OK + delete: + operationId: objecttype_delete + parameters: + - in: path + name: uuid + schema: + type: string + format: uuid + description: Unique identifier (UUID4) + required: true + tags: + - objecttypes + security: + - tokenAuth: [] + responses: + '204': + headers: + API-version: + schema: + type: string + description: 'Geeft een specifieke API-versie aan in de context van + een specifieke aanroep. Voorbeeld: 1.2.1.' + description: No response body /permissions: get: operationId: permission_list @@ -863,8 +1324,7 @@ paths: - name: pageSize required: false in: query - description: 'Het aantal resultaten terug te geven per pagina. (default: 100, - maximum: 500).' + description: 'Het aantal resultaten terug te geven per pagina. (default: 100).' schema: type: integer tags: @@ -898,6 +1358,18 @@ components: description: Kept OBJECT URLs required: - behouden + DataClassificationEnum: + enum: + - open + - intern + - confidential + - strictly_confidential + type: string + description: |- + * `open` - Open + * `intern` - Intern + * `confidential` - Confidential + * `strictly_confidential` - Strictly confidential GeoJSONGeometry: title: GeoJSONGeometry type: object @@ -1108,7 +1580,7 @@ components: format: uri minLength: 1 maxLength: 1000 - description: Url reference to OBJECTTYPE in Objecttypes API + description: Url reference to OBJECTTYPE record: allOf: - $ref: '#/components/schemas/ObjectRecord' @@ -1180,6 +1652,188 @@ components: properties: geometry: $ref: '#/components/schemas/GeoWithin' + ObjectType: + type: object + properties: + url: + type: string + format: uri + readOnly: true + minLength: 1 + maxLength: 1000 + description: URL reference to this object. This is the unique identification + and location of this object. + uuid: + type: string + format: uuid + description: Unique identifier (UUID4) + name: + type: string + description: Name of the object type + maxLength: 100 + namePlural: + type: string + description: Plural name of the object type + maxLength: 100 + description: + type: string + description: The description of the object type + maxLength: 1000 + dataClassification: + allOf: + - $ref: '#/components/schemas/DataClassificationEnum' + description: |- + Confidential level of the object type + + * `open` - Open + * `intern` - Intern + * `confidential` - Confidential + * `strictly_confidential` - Strictly confidential + maintainerOrganization: + type: string + description: Organization which is responsible for the object type + maxLength: 200 + maintainerDepartment: + type: string + description: Business department which is responsible for the object type + maxLength: 200 + contactPerson: + type: string + description: Name of the person in the organization who can provide information + about the object type + maxLength: 200 + contactEmail: + type: string + description: Email of the person in the organization who can provide information + about the object type + maxLength: 200 + source: + type: string + description: Name of the system from which the object type originates + maxLength: 200 + updateFrequency: + allOf: + - $ref: '#/components/schemas/UpdateFrequencyEnum' + description: |- + Indicates how often the object type is updated + + * `real_time` - Real-time + * `hourly` - Hourly + * `daily` - Daily + * `weekly` - Weekly + * `monthly` - Monthly + * `yearly` - Yearly + * `unknown` - Unknown + providerOrganization: + type: string + description: Organization which is responsible for publication of the object + type + maxLength: 200 + documentationUrl: + type: string + format: uri + description: Link to the documentation for the object type + maxLength: 200 + labels: + type: object + additionalProperties: + type: string + description: Key-value pairs of keywords related for the object type + linkableToZaken: + type: boolean + description: |- + Objects of this type can have a link to 1 or more Zaken. + True indicates the lifetime of the object is linked to the lifetime of linked zaken, i.e., when all linked Zaken to an object are archived/destroyed, the object will also be archived/destroyed. + createdAt: + type: string + format: date + readOnly: true + description: Date when the object type was created + modifiedAt: + type: string + format: date + readOnly: true + description: Last date when the object type was modified + allowGeometry: + type: boolean + description: 'Shows whether the related objects can have geographic coordinates. + If the value is ''false'' the related objects are not allowed to have + coordinates and the creation/update of objects with `geometry` property + will raise an error ' + versions: + type: array + items: + type: string + format: uri + minLength: 1 + maxLength: 1000 + readOnly: true + description: list of URLs for the OBJECTTYPE versions + required: + - name + - namePlural + ObjectTypeVersion: + type: object + description: |- + A type of `ModelSerializer` that uses hyperlinked relationships with compound keys instead + of primary key relationships. Specifically: + + * A 'url' field is included instead of the 'id' field. + * Relationships to other instances are hyperlinks, instead of primary keys. + + NOTE: this only works with DRF 3.1.0 and above. + properties: + url: + type: string + format: uri + minLength: 1 + maxLength: 1000 + description: URL reference to this object. This is the unique identification + and location of this object. + readOnly: true + version: + type: integer + readOnly: true + description: Integer version of the OBJECTTYPE + objectType: + type: string + format: uri + minLength: 1 + maxLength: 1000 + description: URL reference to this object. This is the unique identification + and location of this object. + readOnly: true + status: + allOf: + - $ref: '#/components/schemas/StatusEnum' + description: |- + Status of the object type version + + * `published` - Published + * `draft` - Draft + * `deprecated` - Deprecated + jsonSchema: + type: object + additionalProperties: true + title: JSON schema + description: JSON schema for Object validation + createdAt: + type: string + format: date + readOnly: true + description: Date when the version was created + modifiedAt: + type: string + format: date + readOnly: true + description: Last date when the version was modified + publishedAt: + type: string + format: date + readOnly: true + nullable: true + title: Published_at + description: Date when the version was published PaginatedHistoryRecordList: type: object required: @@ -1226,6 +1880,52 @@ components: type: array items: $ref: '#/components/schemas/Object' + PaginatedObjectTypeList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/ObjectType' + PaginatedObjectTypeVersionList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/ObjectTypeVersion' PaginatedPermissionList: type: object required: @@ -1272,11 +1972,190 @@ components: format: uri minLength: 1 maxLength: 1000 - description: Url reference to OBJECTTYPE in Objecttypes API + description: Url reference to OBJECTTYPE record: allOf: - $ref: '#/components/schemas/ObjectRecord' description: State of the OBJECT at a certain time + PatchedObjectType: + type: object + properties: + url: + type: string + format: uri + readOnly: true + minLength: 1 + maxLength: 1000 + description: URL reference to this object. This is the unique identification + and location of this object. + uuid: + type: string + format: uuid + description: Unique identifier (UUID4) + name: + type: string + description: Name of the object type + maxLength: 100 + namePlural: + type: string + description: Plural name of the object type + maxLength: 100 + description: + type: string + description: The description of the object type + maxLength: 1000 + dataClassification: + allOf: + - $ref: '#/components/schemas/DataClassificationEnum' + description: |- + Confidential level of the object type + + * `open` - Open + * `intern` - Intern + * `confidential` - Confidential + * `strictly_confidential` - Strictly confidential + maintainerOrganization: + type: string + description: Organization which is responsible for the object type + maxLength: 200 + maintainerDepartment: + type: string + description: Business department which is responsible for the object type + maxLength: 200 + contactPerson: + type: string + description: Name of the person in the organization who can provide information + about the object type + maxLength: 200 + contactEmail: + type: string + description: Email of the person in the organization who can provide information + about the object type + maxLength: 200 + source: + type: string + description: Name of the system from which the object type originates + maxLength: 200 + updateFrequency: + allOf: + - $ref: '#/components/schemas/UpdateFrequencyEnum' + description: |- + Indicates how often the object type is updated + + * `real_time` - Real-time + * `hourly` - Hourly + * `daily` - Daily + * `weekly` - Weekly + * `monthly` - Monthly + * `yearly` - Yearly + * `unknown` - Unknown + providerOrganization: + type: string + description: Organization which is responsible for publication of the object + type + maxLength: 200 + documentationUrl: + type: string + format: uri + description: Link to the documentation for the object type + maxLength: 200 + labels: + type: object + additionalProperties: + type: string + description: Key-value pairs of keywords related for the object type + linkableToZaken: + type: boolean + description: |- + Objects of this type can have a link to 1 or more Zaken. + True indicates the lifetime of the object is linked to the lifetime of linked zaken, i.e., when all linked Zaken to an object are archived/destroyed, the object will also be archived/destroyed. + createdAt: + type: string + format: date + readOnly: true + description: Date when the object type was created + modifiedAt: + type: string + format: date + readOnly: true + description: Last date when the object type was modified + allowGeometry: + type: boolean + description: 'Shows whether the related objects can have geographic coordinates. + If the value is ''false'' the related objects are not allowed to have + coordinates and the creation/update of objects with `geometry` property + will raise an error ' + versions: + type: array + items: + type: string + format: uri + minLength: 1 + maxLength: 1000 + readOnly: true + description: list of URLs for the OBJECTTYPE versions + PatchedObjectTypeVersion: + type: object + description: |- + A type of `ModelSerializer` that uses hyperlinked relationships with compound keys instead + of primary key relationships. Specifically: + + * A 'url' field is included instead of the 'id' field. + * Relationships to other instances are hyperlinks, instead of primary keys. + + NOTE: this only works with DRF 3.1.0 and above. + properties: + url: + type: string + format: uri + minLength: 1 + maxLength: 1000 + description: URL reference to this object. This is the unique identification + and location of this object. + readOnly: true + version: + type: integer + readOnly: true + description: Integer version of the OBJECTTYPE + objectType: + type: string + format: uri + minLength: 1 + maxLength: 1000 + description: URL reference to this object. This is the unique identification + and location of this object. + readOnly: true + status: + allOf: + - $ref: '#/components/schemas/StatusEnum' + description: |- + Status of the object type version + + * `published` - Published + * `draft` - Draft + * `deprecated` - Deprecated + jsonSchema: + type: object + additionalProperties: true + title: JSON schema + description: JSON schema for Object validation + createdAt: + type: string + format: date + readOnly: true + description: Date when the version was created + modifiedAt: + type: string + format: date + readOnly: true + description: Last date when the version was modified + publishedAt: + type: string + format: date + readOnly: true + nullable: true + title: Published_at + description: Date when the version was published Permission: type: object properties: @@ -1285,7 +2164,7 @@ components: format: uri minLength: 1 maxLength: 1000 - description: Url reference to OBJECTTYPE in Objecttypes API + description: Url reference to OBJECTTYPE mode: allOf: - $ref: '#/components/schemas/ModeEnum' @@ -1362,6 +2241,34 @@ components: - zaak type: string description: '* `zaak` - Zaak' + StatusEnum: + enum: + - published + - draft + - deprecated + type: string + description: |- + * `published` - Published + * `draft` - Draft + * `deprecated` - Deprecated + UpdateFrequencyEnum: + enum: + - real_time + - hourly + - daily + - weekly + - monthly + - yearly + - unknown + type: string + description: |- + * `real_time` - Real-time + * `hourly` - Hourly + * `daily` - Daily + * `weekly` - Weekly + * `monthly` - Monthly + * `yearly` - Yearly + * `unknown` - Unknown securitySchemes: tokenAuth: type: apiKey @@ -1372,6 +2279,7 @@ servers: - url: /api/v2 tags: - name: objects +- name: objecttypes - name: permissions externalDocs: url: https://objects-and-objecttypes-api.readthedocs.io/ diff --git a/src/objects/api/v2/views.py b/src/objects/api/v2/views.py index a9635694..46448083 100644 --- a/src/objects/api/v2/views.py +++ b/src/objects/api/v2/views.py @@ -31,10 +31,9 @@ objecttype_delete_counter, objecttype_update_counter, ) - from objects.cloud_events.constants import ZAAK_ONTKOPPELD from objects.cloud_events.tasks import send_zaak_events -from objects.core.constants import ReferenceType, ObjectTypeVersionStatus +from objects.core.constants import ObjectTypeVersionStatus, ReferenceType from objects.core.models import Object, ObjectRecord, ObjectType, ObjectTypeVersion from objects.token.models import Permission, TokenAuth from objects.token.permissions import IsTokenAuthenticated, ObjectTypeBasedPermission diff --git a/src/objects/conf/api.py b/src/objects/conf/api.py index b7a7e09b..a8d02dd8 100644 --- a/src/objects/conf/api.py +++ b/src/objects/conf/api.py @@ -108,7 +108,12 @@ "drf_spectacular.hooks.postprocess_schema_enums", "maykin_common.drf_spectacular.hooks.remove_invalid_url_defaults", ], - "TAGS": [{"name": "objects"}, {"name": "permissions"}], + # "GET_MOCK_REQUEST": "objecttypes.utils.autoschema.build_mock_request", # TODO + "TAGS": [ + {"name": "objects"}, + {"name": "objecttypes"}, + {"name": "permissions"}, + ], # TODO "SERVERS": [{"url": "/api/v2"}], } diff --git a/src/objects/conf/ci.py b/src/objects/conf/ci.py index b6944643..da397a9e 100644 --- a/src/objects/conf/ci.py +++ b/src/objects/conf/ci.py @@ -12,6 +12,9 @@ os.environ.setdefault("OTEL_SDK_DISABLED", "true") os.environ.setdefault("OTEL_SERVICE_NAME", "objects-ci") +os.environ.setdefault("DB_USER", "postgres") +os.environ.setdefault("DB_PASSWORD", "postgres") + from .base import * # noqa isort:skip CACHES = { diff --git a/src/objects/core/management/commands/import_objecttypes.py b/src/objects/core/management/commands/import_objecttypes.py index c1c9a1fb..231cdee7 100644 --- a/src/objects/core/management/commands/import_objecttypes.py +++ b/src/objects/core/management/commands/import_objecttypes.py @@ -12,9 +12,9 @@ from objects.core.models import ObjectType, ObjectTypeVersion from objects.utils.client import get_objecttypes_client -# Minimum Objecttypes application version is 3.4.0, because that version added the -# version header to the responses -MIN_OBJECTTYPES_VERSION = "2.2.2" +MIN_OBJECTTYPES_API_VERSION = ( + "2.2.2" # added boolean field linkable_to_zaken to ObjectType +) class Command(BaseCommand): @@ -70,10 +70,10 @@ def _check_objecttypes_api_version(self, client): api_version = client.get_objecttypes_api_version() if api_version is None or Version( client.get_objecttypes_api_version() - ) < Version(MIN_OBJECTTYPES_VERSION): + ) < Version(MIN_OBJECTTYPES_API_VERSION): raise CommandError( _("Object types API version must be {} or higher.").format( - MIN_OBJECTTYPES_VERSION + MIN_OBJECTTYPES_API_VERSION ) ) diff --git a/src/objects/core/migrations/0037_alter_objecttype_unique_together_and_more.py b/src/objects/core/migrations/0038_alter_objecttype_unique_together_and_more.py similarity index 94% rename from src/objects/core/migrations/0037_alter_objecttype_unique_together_and_more.py rename to src/objects/core/migrations/0038_alter_objecttype_unique_together_and_more.py index 4ff8d8d5..677e7e83 100644 --- a/src/objects/core/migrations/0037_alter_objecttype_unique_together_and_more.py +++ b/src/objects/core/migrations/0038_alter_objecttype_unique_together_and_more.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ - ('core', '0036_objecttype_is_imported'), + ('core', '0037_reference'), ] operations = [ @@ -59,10 +59,6 @@ class Migration(migrations.Migration): model_name='objecttype', name='_name', ), - migrations.RemoveField( - model_name='objecttype', - name='is_imported', - ), migrations.RemoveField( model_name='objecttype', name='service', diff --git a/src/objects/core/models.py b/src/objects/core/models.py index b5978eff..168e44a7 100644 --- a/src/objects/core/models.py +++ b/src/objects/core/models.py @@ -22,11 +22,17 @@ class ObjectType(models.Model): uuid = models.UUIDField( - help_text=_("Unique identifier (UUID4) of the OBJECTTYPE in Objecttypes API"), + help_text=_("Unique identifier (UUID4)"), unique=True, default=uuid.uuid4, ) + is_imported = models.BooleanField( + _("Is imported"), + default=False, + editable=False, + ) # TODO temp field to track if object was imported, can be removed after objecttype migration (in 4.1.0) + name = models.CharField( _("name"), max_length=100, @@ -232,7 +238,7 @@ class Object(models.Model): object_type = models.ForeignKey( ObjectType, on_delete=models.PROTECT, - help_text=_("OBJECTTYPE in Objecttypes API"), + help_text=_("OBJECTTYPE"), ) created_on = models.DateTimeField(auto_now_add=True, help_text=_("Creation date")) @@ -318,7 +324,7 @@ class ObjectRecord(models.Model): _object_type = models.ForeignKey( ObjectType, on_delete=models.PROTECT, - help_text=_("OBJECTTYPE in Objecttypes API"), + help_text=_("OBJECTTYPE"), null=False, blank=False, db_index=True, diff --git a/src/objects/core/tests/factories.py b/src/objects/core/tests/factories.py index d94a5ed4..0ea5614f 100644 --- a/src/objects/core/tests/factories.py +++ b/src/objects/core/tests/factories.py @@ -10,6 +10,7 @@ from ..models import Object, ObjectRecord, ObjectType, ObjectTypeVersion, Reference + class ObjectTypeFactory(factory.django.DjangoModelFactory[ObjectType]): name = factory.Faker("word") name_plural = factory.LazyAttribute(lambda x: f"{x.name}s") diff --git a/src/objects/core/tests/test_admin.py b/src/objects/core/tests/test_admin.py index 56d63957..3b198aae 100644 --- a/src/objects/core/tests/test_admin.py +++ b/src/objects/core/tests/test_admin.py @@ -24,7 +24,9 @@ def setUp(self): @tag("gh-615") def test_object_changelist_filter_by_objecttype(self): - object_type = ObjectTypeFactory.create(uuid="71a2452a-66c3-4030-b5ec-a06035102e9e") + object_type = ObjectTypeFactory.create( + uuid="71a2452a-66c3-4030-b5ec-a06035102e9e" + ) # Create 100 unused ObjectTypes ObjectTypeFactory.create_batch(100) object1 = ObjectFactory.create(object_type=object_type) @@ -83,7 +85,9 @@ def get_num_results(response) -> int: @tag("gh-677") def test_add_new_objectrecord(self): - object_type = ObjectTypeFactory.create(uuid="71a2452a-66c3-4030-b5ec-a06035102e9e") + object_type = ObjectTypeFactory.create( + uuid="71a2452a-66c3-4030-b5ec-a06035102e9e" + ) ObjectTypeVersionFactory.create(object_type=object_type) object = ObjectFactory.create(object_type=object_type) diff --git a/src/objects/core/tests/test_import_objecttypes.py b/src/objects/core/tests/test_import_objecttypes.py index d04947d6..6001b25a 100644 --- a/src/objects/core/tests/test_import_objecttypes.py +++ b/src/objects/core/tests/test_import_objecttypes.py @@ -25,7 +25,7 @@ def setUp(self): self.m.start() self.service = Service.objects.create(api_root=self.url, slug="objecttypes-api") - self.m.head(self.url, status_code=200, headers={"api-version": "3.4.0"}) + self.m.head(self.url, status_code=200, headers={"api-version": "2.2.2"}) def tearDown(self): self.m.stop() @@ -42,7 +42,7 @@ def test_api_version_is_required(self): self._call_command() def test_api_version_must_be_greater_than_constant(self): - self.m.head(self.url, status_code=200, headers={"api-version": "2.1.0"}) + self.m.head(self.url, status_code=200, headers={"api-version": "2.2.1"}) with self.assertRaisesMessage( CommandError, "API version must be 2.2.2 or higher" @@ -77,7 +77,6 @@ def test_new_objecttypes_are_created(self): objecttype = ObjectType.objects.get(uuid=uuid1) self.assertEqual(objecttype.name, "Melding") - self.assertEqual(objecttype._name, "Melding") self.assertEqual(objecttype.name_plural, "Meldingen") self.assertEqual(objecttype.description, "") self.assertEqual(objecttype.data_classification, "intern") @@ -90,6 +89,7 @@ def test_new_objecttypes_are_created(self): self.assertEqual(objecttype.provider_organization, "") self.assertEqual(objecttype.documentation_url, "") self.assertEqual(objecttype.labels, {}) + self.assertEqual(objecttype.linkable_to_zaken, False) self.assertEqual(str(objecttype.created_at), "2020-12-01") self.assertEqual(str(objecttype.modified_at), "2020-12-01") self.assertEqual(objecttype.allow_geometry, True) @@ -140,7 +140,6 @@ def test_existing_objecttypes_are_updated(self): objecttype = ObjectType.objects.get(uuid=objecttype1.uuid) self.assertEqual(objecttype.name, "Melding") - self.assertEqual(objecttype._name, "Melding") version = ObjectTypeVersion.objects.get(object_type=objecttype, version=1) self.assertEqual( diff --git a/src/objects/tests/v2/test_auth_fields.py b/src/objects/tests/v2/test_auth_fields.py index cc5e8a70..4df3efcd 100644 --- a/src/objects/tests/v2/test_auth_fields.py +++ b/src/objects/tests/v2/test_auth_fields.py @@ -170,7 +170,7 @@ def setUpTestData(cls): super().setUpTestData() cls.object_type = ObjectTypeFactory.create() - cls.other_object_type = ObjectTypeFactory() + cls.other_object_type = ObjectTypeFactory.create() def test_list_without_query_different_object_types(self): PermissionFactory.create( diff --git a/src/objects/tests/v2/test_filters.py b/src/objects/tests/v2/test_filters.py index 234c9062..fc820f13 100644 --- a/src/objects/tests/v2/test_filters.py +++ b/src/objects/tests/v2/test_filters.py @@ -1067,7 +1067,7 @@ def test_filter_unkown_version(self): self.assertEqual(len(data), 0) -class FilterTests(TokenAuthMixin, APITestCase): +class ObjectTypeFilterTests(TokenAuthMixin, APITestCase): url = reverse_lazy("objecttype-list") def test_filter_public_data(self): diff --git a/src/objects/tests/v2/test_jsonschema.py b/src/objects/tests/v2/test_jsonschema.py index fcf04c70..2e4388ee 100644 --- a/src/objects/tests/v2/test_jsonschema.py +++ b/src/objects/tests/v2/test_jsonschema.py @@ -1,8 +1,6 @@ -import requests_mock from rest_framework import status from rest_framework.test import APITestCase - from objects.core.tests.factories import ObjectTypeFactory, ObjectTypeVersionFactory from objects.token.constants import PermissionModes from objects.token.tests.factories import PermissionFactory diff --git a/src/objects/tests/v2/test_notifications_send.py b/src/objects/tests/v2/test_notifications_send.py index 0ee72d46..6ce035a0 100644 --- a/src/objects/tests/v2/test_notifications_send.py +++ b/src/objects/tests/v2/test_notifications_send.py @@ -16,8 +16,8 @@ ObjectFactory, ObjectRecordFactory, ObjectTypeFactory, - ReferenceFactory, ObjectTypeVersionFactory, + ReferenceFactory, ) from objects.token.constants import PermissionModes from objects.token.tests.factories import PermissionFactory @@ -228,17 +228,10 @@ def test_send_notif_delete_object(self, mock_task): 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)) - + def test_send_cloudevent_adding_zaak(self, mock_notification, mock_event): url = reverse("object-list") data = { - "type": self.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12", "diameter": 30}, @@ -284,7 +277,7 @@ def test_send_cloudevent_adding_zaak(self, mocker, mock_notification, mock_event "actie": "create", "aanmaakdatum": "2018-09-07T02:00:00+02:00", "kenmerken": { - "objectType": self.object_type.url, + "objectType": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", }, }, ) @@ -296,16 +289,7 @@ def test_send_cloudevent_adding_zaak(self, mocker, mock_notification, mock_event 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)) - + def test_send_cloudevents_changing_zaak(self, mock_notification, mock_event): obj = ObjectFactory.create(object_type=self.object_type) ref = ReferenceFactory.create( type="zaak", url="https://example.com/zaak/1", record__object=obj @@ -317,7 +301,7 @@ def test_send_cloudevents_changing_zaak( url = reverse("object-detail", args=[obj.uuid]) data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12", "diameter": 30}, @@ -381,7 +365,7 @@ def test_send_cloudevents_changing_zaak( "actie": "partial_update", "aanmaakdatum": "2018-09-07T02:00:00+02:00", "kenmerken": { - "objectType": self.object_type.url, + "objectType": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", }, "source": "objects-api-test", }, @@ -394,16 +378,7 @@ def test_send_cloudevents_changing_zaak( 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)) - + def test_send_cloudevents_deleting_object(self, mock_notification, mock_event): obj = ObjectFactory.create(object_type=self.object_type) ref = ReferenceFactory.create( type="zaak", url="https://example.com/zaak/1", record__object=obj @@ -447,7 +422,7 @@ def test_send_cloudevents_deleting_object( "actie": "destroy", "aanmaakdatum": "2018-09-07T02:00:00+02:00", "kenmerken": { - "objectType": self.object_type.url, + "objectType": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", }, "source": "objects-api-test", }, @@ -461,15 +436,9 @@ def test_send_cloudevents_deleting_object( NOTIFICATIONS_SOURCE="objects-api-test", ) def test_send_cloudevents_deleting_object_archiving_only_zaak( - self, mocker, mock_notification, mock_event + self, 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" @@ -512,7 +481,7 @@ def test_send_cloudevents_deleting_object_archiving_only_zaak( "actie": "destroy", "aanmaakdatum": "2018-09-07T02:00:00+02:00", "kenmerken": { - "objectType": self.object_type.url, + "objectType": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", }, "source": "objects-api-test", }, @@ -526,15 +495,9 @@ def test_send_cloudevents_deleting_object_archiving_only_zaak( NOTIFICATIONS_SOURCE="objects-api-test", ) def test_send_cloudevents_deleting_object_archiving_a_zaak( - self, mocker, mock_notification, mock_event + self, 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" @@ -579,7 +542,7 @@ def test_send_cloudevents_deleting_object_archiving_a_zaak( "actie": "update", # wasn't destroyed, but changed! "aanmaakdatum": "2018-09-07T02:00:00+02:00", "kenmerken": { - "objectType": self.object_type.url, + "objectType": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", }, "source": "objects-api-test", }, @@ -591,19 +554,10 @@ def test_send_cloudevents_deleting_object_archiving_a_zaak( ) @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)) - + def test_no_notifications_sent_when_disabled(self, mock_notification, mock_events): url = reverse("object-list") data = { - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12", "diameter": 30}, diff --git a/src/objects/tests/v2/test_object_api.py b/src/objects/tests/v2/test_object_api.py index 75f76aff..eca2a78d 100644 --- a/src/objects/tests/v2/test_object_api.py +++ b/src/objects/tests/v2/test_object_api.py @@ -492,17 +492,10 @@ def test_update_object_correctionFor(self): 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)) - + def test_create_object_with_references(self): url = reverse("object-list") data = { - "type": self.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12", "diameter": 30}, @@ -522,14 +515,7 @@ def test_create_object_with_references(self, m): {("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)) - + def test_update_object_with_references(self): # 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( @@ -541,7 +527,7 @@ def test_update_object_with_references(self, m): url = reverse("object-detail", args=[object.uuid]) data = { - "type": object.object_type.url, + "type": f"http://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"plantDate": "2020-04-12", "diameter": 30}, @@ -581,16 +567,10 @@ def test_update_object_with_references(self, m): 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): + def test_patch_object_record_with_references(self): # 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, @@ -640,14 +620,8 @@ def test_patch_object_record_with_references(self, m): self.assertEqual(initial_record.end_at, date(2020, 1, 1)) def test_patch_validates_merged_object_rather_than_partial_object_with_references( - self, m + self, ): - 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, @@ -684,7 +658,7 @@ def test_patch_validates_merged_object_rather_than_partial_object_with_reference {"plantDate": "2020-04-10", "diameter": 20, "name": "Name"}, ) - def test_delete_object_with_references(self, m): + def test_delete_object_with_references(self): record = ObjectRecordFactory.create(object__object_type=self.object_type) ReferenceFactory.create_batch(2, record=record) object = record.object diff --git a/src/objects/tests/v2/test_pagination.py b/src/objects/tests/v2/test_pagination.py index 482d7d6f..1bfbb006 100644 --- a/src/objects/tests/v2/test_pagination.py +++ b/src/objects/tests/v2/test_pagination.py @@ -19,7 +19,7 @@ def setUpTestData(cls): super().setUpTestData() cls.object_type = ObjectTypeFactory.create() - PermissionFactory( + PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_only, token_auth=cls.token_auth, diff --git a/src/objects/tests/v2/test_validation.py b/src/objects/tests/v2/test_validation.py index a6bd6229..2af6e9d2 100644 --- a/src/objects/tests/v2/test_validation.py +++ b/src/objects/tests/v2/test_validation.py @@ -349,83 +349,84 @@ def test_delete_objecttype_with_versions_fail(self): ], ) - class ObjectTypeVersionValidationTests(TokenAuthMixin, APITestCase): - def test_create_version_with_incorrect_schema_fail(self): - object_type = ObjectTypeFactory.create() - url = reverse("objecttypeversion-list", args=[object_type.uuid]) - data = { - "jsonSchema": { - "title": "Tree", - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "any", - } - } - response = self.client.post(url, data) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertTrue("jsonSchema" in response.json()) - - def test_create_version_with_incorrect_objecttype_fail(self): - url = reverse("objecttypeversion-list", args=[uuid.uuid4()]) - data = { - "jsonSchema": { - "title": "Tree", - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "diameter": {"type": "integer", "description": "size in cm."} - }, - } +class ObjectTypeVersionValidationTests(TokenAuthMixin, APITestCase): + def test_create_version_with_incorrect_schema_fail(self): + object_type = ObjectTypeFactory.create() + url = reverse("objecttypeversion-list", args=[object_type.uuid]) + data = { + "jsonSchema": { + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "any", } + } + + response = self.client.post(url, data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertTrue("jsonSchema" in response.json()) - response = self.client.post(url, data) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json()["non_field_errors"], ["Objecttype url is invalid"] - ) - - def test_update_published_version_fail(self): - object_type = ObjectTypeFactory.create() - object_version = ObjectTypeVersionFactory.create( - object_type=object_type, status=ObjectTypeVersionStatus.published - ) - url = reverse( - "objecttypeversion-detail", - args=[object_type.uuid, object_version.version], - ) - new_json_schema = { - "type": "object", + def test_create_version_with_incorrect_objecttype_fail(self): + url = reverse("objecttypeversion-list", args=[uuid.uuid4()]) + data = { + "jsonSchema": { "title": "Tree", "$schema": "http://json-schema.org/draft-07/schema#", - "required": ["diameter"], - "properties": {"diameter": {"type": "number"}}, + "properties": { + "diameter": {"type": "integer", "description": "size in cm."} + }, } + } + + response = self.client.post(url, data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json()["non_field_errors"], ["Objecttype url is invalid"] + ) - response = self.client.put(url, {"jsonSchema": new_json_schema}) + def test_update_published_version_fail(self): + object_type = ObjectTypeFactory.create() + object_version = ObjectTypeVersionFactory.create( + object_type=object_type, status=ObjectTypeVersionStatus.published + ) + url = reverse( + "objecttypeversion-detail", + args=[object_type.uuid, object_version.version], + ) + new_json_schema = { + "type": "object", + "title": "Tree", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": ["diameter"], + "properties": {"diameter": {"type": "number"}}, + } - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response = self.client.put(url, {"jsonSchema": new_json_schema}) - data = response.json() - self.assertEqual( - data["non_field_errors"], ["Only draft versions can be changed"] - ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_delete_puclished_version_fail(self): - object_type = ObjectTypeFactory.create() - object_version = ObjectTypeVersionFactory.create( - object_type=object_type, status=ObjectTypeVersionStatus.published - ) - url = reverse( - "objecttypeversion-detail", - args=[object_type.uuid, object_version.version], - ) + data = response.json() + self.assertEqual( + data["non_field_errors"], ["Only draft versions can be changed"] + ) - response = self.client.delete(url) + def test_delete_puclished_version_fail(self): + object_type = ObjectTypeFactory.create() + object_version = ObjectTypeVersionFactory.create( + object_type=object_type, status=ObjectTypeVersionStatus.published + ) + url = reverse( + "objecttypeversion-detail", + args=[object_type.uuid, object_version.version], + ) + + response = self.client.delete(url) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - data = response.json() - self.assertEqual( - data["non_field_errors"], ["Only draft versions can be destroyed"] - ) + data = response.json() + self.assertEqual( + data["non_field_errors"], ["Only draft versions can be destroyed"] + ) diff --git a/src/objects/token/tests/test_admin.py b/src/objects/token/tests/test_admin.py index 54a6edfe..78b6f55a 100644 --- a/src/objects/token/tests/test_admin.py +++ b/src/objects/token/tests/test_admin.py @@ -23,7 +23,9 @@ def test_with_object_types_api_v2(self): Regression test for #449. Test if Permission admin can handle objecttypes API V2 which added pagination """ - object_type = ObjectTypeFactory.create(uuid="71a2452a-66c3-4030-b5ec-a06035102e9e") + object_type = ObjectTypeFactory.create( + uuid="71a2452a-66c3-4030-b5ec-a06035102e9e" + ) response = self.client.get(self.url) self.assertEqual(response.status_code, 200) diff --git a/src/objects/utils/autoschema.py b/src/objects/utils/autoschema.py index 1e9a2a97..eb087a20 100644 --- a/src/objects/utils/autoschema.py +++ b/src/objects/utils/autoschema.py @@ -1,3 +1,5 @@ +from uuid import UUID + from django.conf import settings from django.utils.translation import gettext_lazy as _ @@ -5,6 +7,7 @@ from drf_spectacular.openapi import AutoSchema as _AutoSchema from drf_spectacular.plumbing import build_parameter_type, get_view_model from drf_spectacular.utils import OpenApiParameter +from rest_framework_nested.viewsets import NestedViewSetMixin from vng_api_common.constants import VERSION_HEADER from vng_api_common.geo import DEFAULT_CRS, HEADER_ACCEPT, HEADER_CONTENT from vng_api_common.schema import HTTP_STATUS_CODE_TITLES @@ -43,9 +46,15 @@ def get_override_parameters(self): geo_headers = self.get_geo_headers() content_type_headers = self.get_content_type_headers() version_headers = self.get_version_headers() + parent_path_headers = self.get_parent_path_headers() field_params = self.get_fields_params() return ( - params + geo_headers + content_type_headers + version_headers + field_params + params + + geo_headers + + content_type_headers + + version_headers + + parent_path_headers + + field_params ) def _get_filter_parameters(self): @@ -217,6 +226,29 @@ def get_filter_params_for_search(self): ) return parameters + def get_parent_path_headers(self) -> list: + """for nested viewsets""" + if not isinstance(self.view, NestedViewSetMixin): + return [] + + parent_lookup_kwargs = self.view._get_parent_lookup_kwargs() + path_params = list(parent_lookup_kwargs.keys()) + + return [ + OpenApiParameter( + name=path_param, + type=UUID if "uuid" in path_param else str, + location=OpenApiParameter.PATH, + required=True, + description=( + _("Unique identifier (UUID4)") + if "uuid" in path_param + else _("Unique identifier") + ), + ) + for path_param in path_params + ] + def _resolve_path_parameters(self, variables): object_path = "uuid" if variables == [object_path] and self.view.basename == "object": diff --git a/src/objects/utils/filters.py b/src/objects/utils/filters.py index c150d118..a56ac7d6 100644 --- a/src/objects/utils/filters.py +++ b/src/objects/utils/filters.py @@ -46,7 +46,7 @@ def to_python(self, value): return result -class ObjectTypeFilter(URLModelChoiceFilter): # TODO remove? +class ObjectTypeFilter(URLModelChoiceFilter): field_class = ObjectTypeField diff --git a/src/objects/utils/oas_extensions/__init__.py b/src/objects/utils/oas_extensions/__init__.py index e1d4dac0..94b01412 100644 --- a/src/objects/utils/oas_extensions/__init__.py +++ b/src/objects/utils/oas_extensions/__init__.py @@ -1,9 +1,9 @@ -from .fields import HyperlinkedIdentityFieldExtension +from .fields import HyperlinkedRelatedFieldExtension from .geojson import GeometryFieldExtension from .query import DjangoFilterExtension __all__ = ( "DjangoFilterExtension", "GeometryFieldExtension", - "HyperlinkedIdentityFieldExtension", + "HyperlinkedRelatedFieldExtension", ) diff --git a/src/objects/utils/oas_extensions/fields.py b/src/objects/utils/oas_extensions/fields.py index 36760bd5..bbfbac6b 100644 --- a/src/objects/utils/oas_extensions/fields.py +++ b/src/objects/utils/oas_extensions/fields.py @@ -3,9 +3,27 @@ from drf_spectacular.types import OpenApiTypes from rest_framework import serializers +# TODO +# class HyperlinkedIdentityFieldExtension(OpenApiSerializerFieldExtension): +# target_class = serializers.HyperlinkedIdentityField +# match_subclasses = True +# +# def map_serializer_field(self, auto_schema, direction): +# schema = build_basic_type(OpenApiTypes.URI) +# schema.update( +# { +# "minLength": 1, +# "maxLength": 1000, +# "description": "URL reference to this object. " +# "This is the unique identification and location of this object.", +# } +# ) +# +# return schema -class HyperlinkedIdentityFieldExtension(OpenApiSerializerFieldExtension): - target_class = serializers.HyperlinkedIdentityField + +class HyperlinkedRelatedFieldExtension(OpenApiSerializerFieldExtension): + target_class = serializers.HyperlinkedRelatedField match_subclasses = True def map_serializer_field(self, auto_schema, direction): diff --git a/src/objects/utils/test.py b/src/objects/utils/test.py index ab51f2f6..1a3c599a 100644 --- a/src/objects/utils/test.py +++ b/src/objects/utils/test.py @@ -8,7 +8,7 @@ class TokenAuthMixin: def setUpTestData(cls): super().setUpTestData() - cls.token_auth = TokenAuthFactory( + cls.token_auth = TokenAuthFactory.create( contact_person="testsuite", email="test@letmein.nl" ) From 8b77580d8f2a7558e0e582e4c3fd18cd72c91934 Mon Sep 17 00:00:00 2001 From: floris272 Date: Tue, 27 Jan 2026 15:55:29 +0100 Subject: [PATCH 10/15] :recycle: [#564] regenerate migration --- src/objects/api/v2/openapi.yaml | 18 ++++++++++++------ ...ter_objecttype_unique_together_and_more.py | 15 +++++++++++++-- src/objects/tests/v2/test_validation.py | 19 +++++++++++++++++++ 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/objects/api/v2/openapi.yaml b/src/objects/api/v2/openapi.yaml index 35ef1e2d..ef15b8ec 100644 --- a/src/objects/api/v2/openapi.yaml +++ b/src/objects/api/v2/openapi.yaml @@ -197,7 +197,8 @@ paths: - name: pageSize required: false in: query - description: 'Het aantal resultaten terug te geven per pagina. (default: 100).' + description: 'Het aantal resultaten terug te geven per pagina. (default: 100, + maximum: 500).' schema: type: integer - in: query @@ -639,7 +640,8 @@ paths: - name: pageSize required: false in: query - description: 'Het aantal resultaten terug te geven per pagina. (default: 100).' + description: 'Het aantal resultaten terug te geven per pagina. (default: 100, + maximum: 500).' schema: type: integer - in: path @@ -715,7 +717,8 @@ paths: - name: pageSize required: false in: query - description: 'Het aantal resultaten terug te geven per pagina. (default: 100).' + description: 'Het aantal resultaten terug te geven per pagina. (default: 100, + maximum: 500).' schema: type: integer tags: @@ -875,7 +878,8 @@ paths: - name: pageSize required: false in: query - description: 'Het aantal resultaten terug te geven per pagina. (default: 100).' + description: 'Het aantal resultaten terug te geven per pagina. (default: 100, + maximum: 500).' schema: type: integer tags: @@ -950,7 +954,8 @@ paths: - name: pageSize required: false in: query - description: 'Het aantal resultaten terug te geven per pagina. (default: 100).' + description: 'Het aantal resultaten terug te geven per pagina. (default: 100, + maximum: 500).' schema: type: integer tags: @@ -1324,7 +1329,8 @@ paths: - name: pageSize required: false in: query - description: 'Het aantal resultaten terug te geven per pagina. (default: 100).' + description: 'Het aantal resultaten terug te geven per pagina. (default: 100, + maximum: 500).' schema: type: integer tags: diff --git a/src/objects/core/migrations/0038_alter_objecttype_unique_together_and_more.py b/src/objects/core/migrations/0038_alter_objecttype_unique_together_and_more.py index 677e7e83..ab234add 100644 --- a/src/objects/core/migrations/0038_alter_objecttype_unique_together_and_more.py +++ b/src/objects/core/migrations/0038_alter_objecttype_unique_together_and_more.py @@ -1,5 +1,6 @@ -# Generated by Django 5.2.7 on 2025-12-19 15:41 +# Generated by Django 5.2.8 on 2026-01-27 14:55 +import django.db.models.deletion import django.utils.timezone import uuid from django.db import migrations, models @@ -16,6 +17,16 @@ class Migration(migrations.Migration): name='objecttype', unique_together=set(), ), + migrations.AlterField( + model_name='object', + name='object_type', + field=models.ForeignKey(help_text='OBJECTTYPE', on_delete=django.db.models.deletion.PROTECT, to='core.objecttype'), + ), + migrations.AlterField( + model_name='objectrecord', + name='_object_type', + field=models.ForeignKey(help_text='OBJECTTYPE', on_delete=django.db.models.deletion.PROTECT, to='core.objecttype'), + ), migrations.AlterField( model_name='objecttype', name='created_at', @@ -41,7 +52,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='objecttype', name='uuid', - field=models.UUIDField(default=uuid.uuid4, help_text='Unique identifier (UUID4) of the OBJECTTYPE in Objecttypes API', unique=True), + field=models.UUIDField(default=uuid.uuid4, help_text='Unique identifier (UUID4)', unique=True), ), migrations.AlterField( model_name='objecttypeversion', diff --git a/src/objects/tests/v2/test_validation.py b/src/objects/tests/v2/test_validation.py index 2af6e9d2..21524bca 100644 --- a/src/objects/tests/v2/test_validation.py +++ b/src/objects/tests/v2/test_validation.py @@ -34,6 +34,25 @@ def setUp(self): token_auth=self.token_auth, ) + def test_create_object_with_invalid_length(self): + data = { + "type": f"https://testserver/{'a' * 1000}/{reverse('objecttype-detail', args=[self.object_type.uuid])}", + "record": { + "typeVersion": 1, + "data": {"plantDate": "2020-04-12", "diameter": 30}, + "startAt": "2020-01-01", + }, + } + url = reverse("object-list") + + response = self.client.post(url, data, **GEO_WRITE_KWARGS) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(Object.objects.count(), 0) + + data = response.json() + self.assertEqual(data["type"], ["The value has too many characters"]) + def test_create_object_no_version(self): url = reverse("object-list") data = { From b4897baf4eef7240391969a758a339e7abedeb23 Mon Sep 17 00:00:00 2001 From: floris272 Date: Tue, 3 Feb 2026 10:13:16 +0100 Subject: [PATCH 11/15] :white_check_mark: [#564] remove linkable_to_zaken from tests --- src/objects/api/serializers.py | 2 -- src/objects/api/v2/openapi.yaml | 10 ---------- ... 0039_alter_objecttype_unique_together_and_more.py} | 2 +- src/objects/core/tests/test_import_objecttypes.py | 1 - src/objects/tests/v2/test_objecttype_api.py | 4 ---- 5 files changed, 1 insertion(+), 18 deletions(-) rename src/objects/core/migrations/{0038_alter_objecttype_unique_together_and_more.py => 0039_alter_objecttype_unique_together_and_more.py} (97%) diff --git a/src/objects/api/serializers.py b/src/objects/api/serializers.py index 26d55031..1b7fb496 100644 --- a/src/objects/api/serializers.py +++ b/src/objects/api/serializers.py @@ -128,7 +128,6 @@ class Meta: "providerOrganization", "documentationUrl", "labels", - "linkableToZaken", "createdAt", "modifiedAt", "allowGeometry", @@ -147,7 +146,6 @@ class Meta: "providerOrganization": {"source": "provider_organization"}, "documentationUrl": {"source": "documentation_url"}, "allowGeometry": {"source": "allow_geometry"}, - "linkableToZaken": {"source": "linkable_to_zaken"}, "createdAt": {"source": "created_at", "read_only": True}, "modifiedAt": {"source": "modified_at", "read_only": True}, } diff --git a/src/objects/api/v2/openapi.yaml b/src/objects/api/v2/openapi.yaml index ef15b8ec..7c9d61d6 100644 --- a/src/objects/api/v2/openapi.yaml +++ b/src/objects/api/v2/openapi.yaml @@ -1745,11 +1745,6 @@ components: additionalProperties: type: string description: Key-value pairs of keywords related for the object type - linkableToZaken: - type: boolean - description: |- - Objects of this type can have a link to 1 or more Zaken. - True indicates the lifetime of the object is linked to the lifetime of linked zaken, i.e., when all linked Zaken to an object are archived/destroyed, the object will also be archived/destroyed. createdAt: type: string format: date @@ -2070,11 +2065,6 @@ components: additionalProperties: type: string description: Key-value pairs of keywords related for the object type - linkableToZaken: - type: boolean - description: |- - Objects of this type can have a link to 1 or more Zaken. - True indicates the lifetime of the object is linked to the lifetime of linked zaken, i.e., when all linked Zaken to an object are archived/destroyed, the object will also be archived/destroyed. createdAt: type: string format: date diff --git a/src/objects/core/migrations/0038_alter_objecttype_unique_together_and_more.py b/src/objects/core/migrations/0039_alter_objecttype_unique_together_and_more.py similarity index 97% rename from src/objects/core/migrations/0038_alter_objecttype_unique_together_and_more.py rename to src/objects/core/migrations/0039_alter_objecttype_unique_together_and_more.py index ab234add..6a5910cd 100644 --- a/src/objects/core/migrations/0038_alter_objecttype_unique_together_and_more.py +++ b/src/objects/core/migrations/0039_alter_objecttype_unique_together_and_more.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ('core', '0037_reference'), + ('core', '0038_remove_objecttype_linkable_to_zaken'), ] operations = [ diff --git a/src/objects/core/tests/test_import_objecttypes.py b/src/objects/core/tests/test_import_objecttypes.py index 6001b25a..49dcdbe7 100644 --- a/src/objects/core/tests/test_import_objecttypes.py +++ b/src/objects/core/tests/test_import_objecttypes.py @@ -89,7 +89,6 @@ def test_new_objecttypes_are_created(self): self.assertEqual(objecttype.provider_organization, "") self.assertEqual(objecttype.documentation_url, "") self.assertEqual(objecttype.labels, {}) - self.assertEqual(objecttype.linkable_to_zaken, False) self.assertEqual(str(objecttype.created_at), "2020-12-01") self.assertEqual(str(objecttype.modified_at), "2020-12-01") self.assertEqual(objecttype.allow_geometry, True) diff --git a/src/objects/tests/v2/test_objecttype_api.py b/src/objects/tests/v2/test_objecttype_api.py index e7077dbb..46ac0923 100644 --- a/src/objects/tests/v2/test_objecttype_api.py +++ b/src/objects/tests/v2/test_objecttype_api.py @@ -40,7 +40,6 @@ def test_get_objecttypes(self): "providerOrganization": object_type.provider_organization, "documentationUrl": object_type.documentation_url, "labels": object_type.labels, - "linkableToZaken": False, "createdAt": "2020-01-01", "modifiedAt": "2020-01-01", "allowGeometry": object_type.allow_geometry, @@ -119,7 +118,6 @@ def test_create_objecttype(self): self.assertEqual(object_type.contact_person, "John Smith") self.assertEqual(object_type.contact_email, "John.Smith@objecttypes.nl") self.assertEqual(object_type.source, "tree system") - self.assertFalse(object_type.linkable_to_zaken) self.assertEqual(object_type.update_frequency, UpdateFrequencyChoices.monthly) self.assertEqual(object_type.provider_organization, "tree provider") self.assertEqual(object_type.documentation_url, "http://example.com/doc/trees") @@ -137,7 +135,6 @@ def test_update_objecttype(self): url, { "dataClassification": DataClassificationChoices.open, - "linkableToZaken": True, }, ) @@ -148,7 +145,6 @@ def test_update_objecttype(self): self.assertEqual( object_type.data_classification, DataClassificationChoices.open ) - self.assertTrue(object_type.linkable_to_zaken) def test_delete_objecttype(self): object_type = ObjectTypeFactory.create() From 9d401559e9c3f314ac51e2a73bc442b1f4772e7b Mon Sep 17 00:00:00 2001 From: floris272 Date: Mon, 9 Feb 2026 14:46:10 +0100 Subject: [PATCH 12/15] :recycle: [#564] add back objecttype config step --- docker/setup_configuration/data.yaml | 12 +- src/objects/conf/api.py | 1 - .../management/commands/import_objecttypes.py | 6 +- .../files/objecttypes_empty_database.yaml | 8 ++ .../objecttypes_existing_objecttype.yaml | 8 ++ .../tests/files/objecttypes_idempotent.yaml | 8 ++ .../tests/files/objecttypes_invalid_uuid.yaml | 8 ++ .../core/tests/test_objecttype_config.py | 123 ++++++++++++++++++ .../setup_configuration/models/objecttypes.py | 15 +++ .../setup_configuration/models/token_auth.py | 22 +--- .../setup_configuration/steps/objecttypes.py | 55 ++++++++ .../setup_configuration/steps/token_auth.py | 4 - .../token_auth/valid_setup_complete.yaml | 5 - .../tests/test_token_auth_config.py | 86 ------------ src/objects/utils/oas_extensions/fields.py | 18 --- 15 files changed, 232 insertions(+), 147 deletions(-) create mode 100644 src/objects/core/tests/files/objecttypes_empty_database.yaml create mode 100644 src/objects/core/tests/files/objecttypes_existing_objecttype.yaml create mode 100644 src/objects/core/tests/files/objecttypes_idempotent.yaml create mode 100644 src/objects/core/tests/files/objecttypes_invalid_uuid.yaml create mode 100644 src/objects/core/tests/test_objecttype_config.py create mode 100644 src/objects/setup_configuration/models/objecttypes.py create mode 100644 src/objects/setup_configuration/steps/objecttypes.py diff --git a/docker/setup_configuration/data.yaml b/docker/setup_configuration/data.yaml index 5b87eb79..3ad90671 100644 --- a/docker/setup_configuration/data.yaml +++ b/docker/setup_configuration/data.yaml @@ -36,15 +36,9 @@ tokenauth: organization: Organization 1 application: Application 1 administration: Administration 1 -# TODO -# permissions: -# - object_type: b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2 -# mode: read_only -# use_fields: true -# fields: -# '1': -# - record__data__leeftijd -# - record__data__kiemjaar + permissions: + - object_type: b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2 + mode: read_only # additional permissions can be added like this: # - object_type: b427ef84-189d-43aa-9efd-7bb2c459e281 # mode: read_and_write diff --git a/src/objects/conf/api.py b/src/objects/conf/api.py index a8d02dd8..cd4c6920 100644 --- a/src/objects/conf/api.py +++ b/src/objects/conf/api.py @@ -108,7 +108,6 @@ "drf_spectacular.hooks.postprocess_schema_enums", "maykin_common.drf_spectacular.hooks.remove_invalid_url_defaults", ], - # "GET_MOCK_REQUEST": "objecttypes.utils.autoschema.build_mock_request", # TODO "TAGS": [ {"name": "objects"}, {"name": "objecttypes"}, diff --git a/src/objects/core/management/commands/import_objecttypes.py b/src/objects/core/management/commands/import_objecttypes.py index 231cdee7..c1e62d0c 100644 --- a/src/objects/core/management/commands/import_objecttypes.py +++ b/src/objects/core/management/commands/import_objecttypes.py @@ -12,9 +12,9 @@ from objects.core.models import ObjectType, ObjectTypeVersion from objects.utils.client import get_objecttypes_client -MIN_OBJECTTYPES_API_VERSION = ( - "2.2.2" # added boolean field linkable_to_zaken to ObjectType -) +# Minimum Objecttypes application version is 3.4.0, because that version added the +# version header to the responses +MIN_OBJECTTYPES_API_VERSION = "2.2.2" class Command(BaseCommand): diff --git a/src/objects/core/tests/files/objecttypes_empty_database.yaml b/src/objects/core/tests/files/objecttypes_empty_database.yaml new file mode 100644 index 00000000..6f079f5c --- /dev/null +++ b/src/objects/core/tests/files/objecttypes_empty_database.yaml @@ -0,0 +1,8 @@ +objecttypes_config_enable: true +objecttypes: + items: + - uuid: b427ef84-189d-43aa-9efd-7bb2c459e281 + name: Object Type 1 + + - uuid: b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2 + name: Object Type 2 diff --git a/src/objects/core/tests/files/objecttypes_existing_objecttype.yaml b/src/objects/core/tests/files/objecttypes_existing_objecttype.yaml new file mode 100644 index 00000000..397cd055 --- /dev/null +++ b/src/objects/core/tests/files/objecttypes_existing_objecttype.yaml @@ -0,0 +1,8 @@ +objecttypes_config_enable: true +objecttypes: + items: + - uuid: b427ef84-189d-43aa-9efd-7bb2c459e281 + name: Object Type 1 + + - uuid: 7229549b-7b41-47d1-8106-414b2a69751b + name: Object Type 3 diff --git a/src/objects/core/tests/files/objecttypes_idempotent.yaml b/src/objects/core/tests/files/objecttypes_idempotent.yaml new file mode 100644 index 00000000..6f079f5c --- /dev/null +++ b/src/objects/core/tests/files/objecttypes_idempotent.yaml @@ -0,0 +1,8 @@ +objecttypes_config_enable: true +objecttypes: + items: + - uuid: b427ef84-189d-43aa-9efd-7bb2c459e281 + name: Object Type 1 + + - uuid: b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2 + name: Object Type 2 diff --git a/src/objects/core/tests/files/objecttypes_invalid_uuid.yaml b/src/objects/core/tests/files/objecttypes_invalid_uuid.yaml new file mode 100644 index 00000000..e461792c --- /dev/null +++ b/src/objects/core/tests/files/objecttypes_invalid_uuid.yaml @@ -0,0 +1,8 @@ +objecttypes_config_enable: true +objecttypes: + items: + - uuid: b427ef84-189d-43aa-9efd-7bb2c459e281 + name: Object Type 1 + + - uuid: foobar + name: Object Type 2 diff --git a/src/objects/core/tests/test_objecttype_config.py b/src/objects/core/tests/test_objecttype_config.py new file mode 100644 index 00000000..28587fe8 --- /dev/null +++ b/src/objects/core/tests/test_objecttype_config.py @@ -0,0 +1,123 @@ +from pathlib import Path + +from django.db.models import QuerySet +from django.test import TestCase + +from django_setup_configuration.exceptions import ConfigurationRunFailed +from django_setup_configuration.test_utils import execute_single_step + +from objects.core.models import ObjectType +from objects.core.tests.factories import ObjectTypeFactory +from objects.setup_configuration.steps.objecttypes import ObjectTypesConfigurationStep + +TEST_FILES = (Path(__file__).parent / "files").resolve() + + +class ObjectTypesConfigurationStepTests(TestCase): + def test_empty_database(self): + test_file_path = str(TEST_FILES / "objecttypes_empty_database.yaml") + + execute_single_step(ObjectTypesConfigurationStep, yaml_source=test_file_path) + + objecttypes: QuerySet[ObjectType] = ObjectType.objects.order_by("name") + + self.assertEqual(objecttypes.count(), 2) + + objecttype_1: ObjectType = objecttypes.first() + + self.assertEqual(str(objecttype_1.uuid), "b427ef84-189d-43aa-9efd-7bb2c459e281") + self.assertEqual(objecttype_1.name, "Object Type 1") + + objecttype_2: ObjectType = objecttypes.last() + + self.assertEqual(str(objecttype_2.uuid), "b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2") + self.assertEqual(objecttype_2.name, "Object Type 2") + + def test_existing_objecttype(self): + test_file_path = str(TEST_FILES / "objecttypes_existing_objecttype.yaml") + + objecttype_1: ObjectType = ObjectTypeFactory.create( + uuid="b427ef84-189d-43aa-9efd-7bb2c459e281", + name="Object Type 001", + ) + objecttype_2: ObjectType = ObjectTypeFactory.create( + uuid="b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2", + name="Object Type 002", + ) + + execute_single_step(ObjectTypesConfigurationStep, yaml_source=test_file_path) + + self.assertEqual(ObjectType.objects.count(), 3) + + objecttype_1.refresh_from_db() + + self.assertEqual(str(objecttype_1.uuid), "b427ef84-189d-43aa-9efd-7bb2c459e281") + self.assertEqual(objecttype_1.name, "Object Type 1") + + objecttype_2.refresh_from_db() + + self.assertEqual(str(objecttype_2.uuid), "b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2") + self.assertEqual(objecttype_2.name, "Object Type 002") + + objecttype_3: ObjectType = ObjectType.objects.get( + uuid="7229549b-7b41-47d1-8106-414b2a69751b" + ) + + self.assertEqual(str(objecttype_3.uuid), "7229549b-7b41-47d1-8106-414b2a69751b") + self.assertEqual(objecttype_3.name, "Object Type 3") + + def test_invalid_uuid(self): + test_file_path = str(TEST_FILES / "objecttypes_invalid_uuid.yaml") + + objecttype: ObjectType = ObjectTypeFactory.create( + uuid="b427ef84-189d-43aa-9efd-7bb2c459e281", + name="Object Type 001", + ) + + with self.assertRaises(ConfigurationRunFailed): + execute_single_step( + ObjectTypesConfigurationStep, yaml_source=test_file_path + ) + + self.assertEqual(ObjectType.objects.count(), 1) + + objecttype.refresh_from_db() + + self.assertEqual(str(objecttype.uuid), "b427ef84-189d-43aa-9efd-7bb2c459e281") + # Name should not be changed, because the error causes a rollback + self.assertEqual(objecttype.name, "Object Type 001") + + def test_idempotent_step(self): + test_file_path = str(TEST_FILES / "objecttypes_idempotent.yaml") + + execute_single_step(ObjectTypesConfigurationStep, yaml_source=test_file_path) + + objecttypes: QuerySet[ObjectType] = ObjectType.objects.order_by("name") + + self.assertEqual(objecttypes.count(), 2) + + objecttype_1: ObjectType = objecttypes.first() + + self.assertEqual(str(objecttype_1.uuid), "b427ef84-189d-43aa-9efd-7bb2c459e281") + self.assertEqual(objecttype_1.name, "Object Type 1") + + objecttype_2: ObjectType = objecttypes.last() + + self.assertEqual(str(objecttype_2.uuid), "b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2") + self.assertEqual(objecttype_2.name, "Object Type 2") + + # Rerun + execute_single_step(ObjectTypesConfigurationStep, yaml_source=test_file_path) + + objecttype_1.refresh_from_db() + objecttype_2.refresh_from_db() + + self.assertEqual(ObjectType.objects.count(), 2) + + # objecttype 1 + self.assertEqual(str(objecttype_1.uuid), "b427ef84-189d-43aa-9efd-7bb2c459e281") + self.assertEqual(objecttype_1.name, "Object Type 1") + + # objecttype 2 + self.assertEqual(str(objecttype_2.uuid), "b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2") + self.assertEqual(objecttype_2.name, "Object Type 2") diff --git a/src/objects/setup_configuration/models/objecttypes.py b/src/objects/setup_configuration/models/objecttypes.py new file mode 100644 index 00000000..0d42c86b --- /dev/null +++ b/src/objects/setup_configuration/models/objecttypes.py @@ -0,0 +1,15 @@ +from django_setup_configuration.fields import DjangoModelRef +from django_setup_configuration.models import ConfigurationModel + +from objects.core.models import ObjectType + + +class ObjectTypeConfigurationModel(ConfigurationModel): + name: str = DjangoModelRef(ObjectType, "name") + + class Meta: + django_model_refs = {ObjectType: ("uuid",)} + + +class ObjectTypesConfigurationModel(ConfigurationModel): + items: list[ObjectTypeConfigurationModel] diff --git a/src/objects/setup_configuration/models/token_auth.py b/src/objects/setup_configuration/models/token_auth.py index 7ae4ff6b..007079c6 100644 --- a/src/objects/setup_configuration/models/token_auth.py +++ b/src/objects/setup_configuration/models/token_auth.py @@ -1,4 +1,3 @@ -from django_setup_configuration.fields import DjangoModelRef from django_setup_configuration.models import ConfigurationModel from pydantic import UUID4, Field @@ -9,29 +8,10 @@ class TokenAuthPermissionConfigurationModel(ConfigurationModel): object_type: UUID4 = Field( description="The UUID of the objecttype for which permission will be configured for this token." ) - fields: dict[str, list[str]] | None = DjangoModelRef( - Permission, - "fields", - default=None, - examples=[{"1": ["record__data__leeftijd", "record__data__kiemjaar"]}], - description=( - "The fields to which this token has access (field-based authorization). " - "Note that this can only be specified if the permission mode is `read_only` " - "and use_fields is set to `true`." - ), - ) class Meta: django_model_refs = { - Permission: ( - "mode", - "use_fields", - ), - } - extra_kwargs = { - "use_fields": { - "examples": [True], - }, + Permission: ("mode",), } diff --git a/src/objects/setup_configuration/steps/objecttypes.py b/src/objects/setup_configuration/steps/objecttypes.py new file mode 100644 index 00000000..d2bc2b6b --- /dev/null +++ b/src/objects/setup_configuration/steps/objecttypes.py @@ -0,0 +1,55 @@ +from django.core.exceptions import ValidationError +from django.db import IntegrityError + +from django_setup_configuration.configuration import BaseConfigurationStep +from django_setup_configuration.exceptions import ConfigurationRunFailed + +from objects.core.models import ObjectType +from objects.setup_configuration.models.objecttypes import ObjectTypesConfigurationModel + + +class ObjectTypesConfigurationStep(BaseConfigurationStep): + """ + Configure references to objecttypes in the Objecttypes API. + + .. note:: Note that these objecttypes references should match instances in the Objecttypes API. Currently + there is no configuration step to do this automatically, so these have to be configured + manually or by loading fixtures. + """ + + config_model = ObjectTypesConfigurationModel + verbose_name = "Objecttypes Configuration" + + namespace = "objecttypes" + enable_setting = "objecttypes_config_enable" + + def execute(self, model: ObjectTypesConfigurationModel) -> None: + for item in model.items: + objecttype_kwargs = dict( + uuid=item.uuid, + name=item.name, + name_plural=f"{item.name}s", + ) + + objecttype_instance = ObjectType(**objecttype_kwargs) + + try: + objecttype_instance.full_clean(exclude=("id"), validate_unique=False) + except ValidationError as exception: + exception_message = ( + f"Validation error(s) occured for objecttype {item.uuid}." + ) + raise ConfigurationRunFailed(exception_message) from exception + + try: + ObjectType.objects.update_or_create( + uuid=item.uuid, + defaults={ + key: value + for key, value in objecttype_kwargs.items() + if key != "uuid" + }, + ) + except IntegrityError as exception: + exception_message = f"Failed configuring ObjectType {item.uuid}." + raise ConfigurationRunFailed(exception_message) from exception diff --git a/src/objects/setup_configuration/steps/token_auth.py b/src/objects/setup_configuration/steps/token_auth.py index 1b313296..45b0e447 100644 --- a/src/objects/setup_configuration/steps/token_auth.py +++ b/src/objects/setup_configuration/steps/token_auth.py @@ -51,8 +51,6 @@ def _configure_permissions(self, token: TokenAuth, permissions: list) -> None: "token_auth": token, "object_type": ObjectType.objects.get(uuid=permission.object_type), "mode": permission.mode, - "use_fields": permission.use_fields, - "fields": permission.fields, } except ObjectDoesNotExist as exception: raise ConfigurationRunFailed( @@ -68,8 +66,6 @@ def _configure_permissions(self, token: TokenAuth, permissions: list) -> None: object_type=permission_kwargs["object_type"], defaults={ "mode": permission_kwargs["mode"], - "use_fields": permission_kwargs["use_fields"], - "fields": permission_kwargs["fields"], }, ) except IntegrityError as exception: diff --git a/src/objects/setup_configuration/tests/files/token_auth/valid_setup_complete.yaml b/src/objects/setup_configuration/tests/files/token_auth/valid_setup_complete.yaml index 67557d7e..af9f864c 100644 --- a/src/objects/setup_configuration/tests/files/token_auth/valid_setup_complete.yaml +++ b/src/objects/setup_configuration/tests/files/token_auth/valid_setup_complete.yaml @@ -11,11 +11,6 @@ tokenauth: permissions: - object_type: 3a82fb7f-fc9b-4104-9804-993f639d6d0d mode: read_only - use_fields: true - fields: - '1': - - record__data__leeftijd - - record__data__kiemjaar - object_type: ca754b52-3f37-4c49-837c-130e8149e337 mode: read_and_write diff --git a/src/objects/setup_configuration/tests/test_token_auth_config.py b/src/objects/setup_configuration/tests/test_token_auth_config.py index 582f67e4..5f3cca9f 100644 --- a/src/objects/setup_configuration/tests/test_token_auth_config.py +++ b/src/objects/setup_configuration/tests/test_token_auth_config.py @@ -491,13 +491,6 @@ def test_valid_setup_complete(self): self.assertTrue(object_type in token.object_types.all()) self.assertTrue(permission in token.permissions.all()) self.assertEqual(permission.mode, "read_only") - self.assertTrue(permission.use_fields) - self.assertTrue(isinstance(permission.fields, dict)) - self.assertTrue(isinstance(permission.fields["1"], list)) - self.assertEqual(len(permission.fields.keys()), 1) - self.assertTrue("1" in permission.fields) - self.assertTrue("record__data__leeftijd" in permission.fields["1"]) - self.assertTrue("record__data__kiemjaar" in permission.fields["1"]) object_type = ObjectType.objects.get( uuid="ca754b52-3f37-4c49-837c-130e8149e337" ) @@ -505,8 +498,6 @@ def test_valid_setup_complete(self): self.assertTrue(object_type in token.object_types.all()) self.assertTrue(permission in token.permissions.all()) self.assertEqual(permission.mode, "read_and_write") - self.assertFalse(permission.use_fields) - self.assertIsNone(permission.fields) token = tokens.get(identifier="token-2") token_permissions = token.permissions.all() @@ -526,8 +517,6 @@ def test_valid_setup_complete(self): self.assertTrue(object_type in token.object_types.all()) self.assertTrue(permission in token.permissions.all()) self.assertEqual(permission.mode, "read_only") - self.assertFalse(permission.use_fields) - self.assertIsNone(permission.fields) token = tokens.get(identifier="token-3") self.assertEqual(token.contact_person, "Person 3") @@ -582,8 +571,6 @@ def test_valid_update_permissions(self): self.assertTrue(object_type in token.object_types.all()) self.assertTrue(permission in token.permissions.all()) self.assertEqual(permission.mode, "read_and_write") - self.assertFalse(permission.use_fields) - self.assertIsNone(permission.fields) # Update token permissions execute_single_step( @@ -604,13 +591,6 @@ def test_valid_update_permissions(self): self.assertTrue(object_type in token.object_types.all()) self.assertTrue(permission in token.permissions.all()) self.assertEqual(permission.mode, "read_only") - self.assertTrue(permission.use_fields) - self.assertTrue(isinstance(permission.fields, dict)) - self.assertTrue(isinstance(permission.fields["1"], list)) - self.assertEqual(len(permission.fields.keys()), 1) - self.assertTrue("1" in permission.fields) - self.assertTrue("record__data__leeftijd" in permission.fields["1"]) - self.assertTrue("record__data__kiemjaar" in permission.fields["1"]) def test_valid_idempotent_step(self): self.assertEqual(TokenAuth.objects.count(), 0) @@ -644,13 +624,6 @@ def test_valid_idempotent_step(self): self.assertTrue(object_type in old_token.object_types.all()) self.assertTrue(old_permission in old_token.permissions.all()) self.assertEqual(old_permission.mode, "read_only") - self.assertTrue(old_permission.use_fields) - self.assertTrue(isinstance(old_permission.fields, dict)) - self.assertTrue(isinstance(old_permission.fields["1"], list)) - self.assertEqual(len(old_permission.fields.keys()), 1) - self.assertTrue("1" in old_permission.fields) - self.assertTrue("record__data__leeftijd" in old_permission.fields["1"]) - self.assertTrue("record__data__kiemjaar" in old_permission.fields["1"]) execute_single_step( TokenAuthConfigurationStep, @@ -675,13 +648,6 @@ def test_valid_idempotent_step(self): self.assertTrue(object_type in new_token.object_types.all()) self.assertTrue(new_permission in new_token.permissions.all()) self.assertEqual(new_permission.mode, "read_only") - self.assertTrue(new_permission.use_fields) - self.assertTrue(isinstance(new_permission.fields, dict)) - self.assertTrue(isinstance(new_permission.fields["1"], list)) - self.assertEqual(len(new_permission.fields.keys()), 1) - self.assertTrue("1" in new_permission.fields) - self.assertTrue("record__data__leeftijd" in new_permission.fields["1"]) - self.assertTrue("record__data__kiemjaar" in new_permission.fields["1"]) def test_invalid_permissions_object_type_does_not_exist(self): self.assertFalse( @@ -705,13 +671,6 @@ def test_invalid_permissions_object_type_does_not_exist(self): { "object_type": "69feca90-6c3d-4628-ace8-19e4b0ae4065", "mode": "read_only", - "use_fields": True, - "fields": { - "1": [ - "record__data__leeftijd", - "record__data__kiemjaar", - ] - }, }, ], }, @@ -746,13 +705,6 @@ def test_invalid_permissions_mode_not_valid(self): { "object_type": "3a82fb7f-fc9b-4104-9804-993f639d6d0d", "mode": "test", - "use_fields": True, - "fields": { - "1": [ - "record__data__leeftijd", - "record__data__kiemjaar", - ] - }, }, ], }, @@ -767,41 +719,3 @@ def test_invalid_permissions_mode_not_valid(self): ) self.assertEqual(TokenAuth.objects.count(), 0) self.assertEqual(Permission.objects.count(), 0) - - def test_invalid_permissions_field_based_authorization(self): - object_source = { - "tokenauth_config_enable": True, - "tokenauth": { - "items": [ - { - "identifier": "token-1", - "token": "ba9d233e95e04c4a8a661a27daffe7c9bd019067", - "contact_person": "Person 1", - "email": "person-1@example.com", - "organization": "Organization 1", - "application": "Application 1", - "administration": "Administration 1", - "permissions": [ - { - "object_type": "3a82fb7f-fc9b-4104-9804-993f639d6d0d", - "mode": "read_and_write", - "use_fields": True, - "fields": { - "1": [ - "record__data__leeftijd", - "record__data__kiemjaar", - ] - }, - }, - ], - }, - ], - }, - } - with self.assertRaises(ConfigurationRunFailed) as command_error: - execute_single_step(TokenAuthConfigurationStep, object_source=object_source) - - self.assertTrue( - "Validation error(s) during instance cleaning" - in str(command_error.exception) - ) diff --git a/src/objects/utils/oas_extensions/fields.py b/src/objects/utils/oas_extensions/fields.py index bbfbac6b..085eb971 100644 --- a/src/objects/utils/oas_extensions/fields.py +++ b/src/objects/utils/oas_extensions/fields.py @@ -3,24 +3,6 @@ from drf_spectacular.types import OpenApiTypes from rest_framework import serializers -# TODO -# class HyperlinkedIdentityFieldExtension(OpenApiSerializerFieldExtension): -# target_class = serializers.HyperlinkedIdentityField -# match_subclasses = True -# -# def map_serializer_field(self, auto_schema, direction): -# schema = build_basic_type(OpenApiTypes.URI) -# schema.update( -# { -# "minLength": 1, -# "maxLength": 1000, -# "description": "URL reference to this object. " -# "This is the unique identification and location of this object.", -# } -# ) -# -# return schema - class HyperlinkedRelatedFieldExtension(OpenApiSerializerFieldExtension): target_class = serializers.HyperlinkedRelatedField From 79a344c2ed9434d1c25576ed28868bfa0db588e7 Mon Sep 17 00:00:00 2001 From: floris272 Date: Mon, 9 Feb 2026 15:14:53 +0100 Subject: [PATCH 13/15] :white_check_mark: [#564] fix upgrade check --- .../commands/check_for_external_objecttypes.py | 6 +++--- src/objects/core/tests/test_upgrade_check.py | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/objects/core/management/commands/check_for_external_objecttypes.py b/src/objects/core/management/commands/check_for_external_objecttypes.py index bfa87651..cedc8967 100644 --- a/src/objects/core/management/commands/check_for_external_objecttypes.py +++ b/src/objects/core/management/commands/check_for_external_objecttypes.py @@ -16,13 +16,13 @@ def handle(self, *args, **options): ObjectType = self._get_objecttype() external_object_count = 0 - service = set() + external_uuids = set() for objecttype in ObjectType.objects.iterator(): if not objecttype.is_imported: external_object_count += 1 - service.add(objecttype.service) + external_uuids.add(objecttype.uuid) - msg = f"{external_object_count} objectypes have not been imported from the service(s): {service}" + msg = f"{external_object_count} objectype(s) have not been imported: {external_uuids}" self.stdout.write(self.style.ERROR(msg)) diff --git a/src/objects/core/tests/test_upgrade_check.py b/src/objects/core/tests/test_upgrade_check.py index 2919ce78..0b87e635 100644 --- a/src/objects/core/tests/test_upgrade_check.py +++ b/src/objects/core/tests/test_upgrade_check.py @@ -11,7 +11,7 @@ from objects.token.tests.test_migrations import BaseMigrationTest -class TestUpgradeCheck(BaseMigrationTest): +class TestUpgradeCheckBefore40(BaseMigrationTest): app = "core" migrate_from = "0036_objecttype_is_imported" migrate_to = "0037_alter_objecttype_unique_together_and_more" @@ -96,6 +96,21 @@ def test_upgrade_from_37_to_41_with_all_imported(self): class TestUpgradeCheckAfter40(TestCase): + @override_settings(RELEASE="4.0.0") + def test_upgrade_from_36_to_40_with_non_imported(self): + Version.objects.create(version="3.6.0", git_sha="test") + ObjectTypeFactory.create(is_imported=False) + + with self.assertRaises(SystemCheckError): + call_command("check") + + @override_settings(RELEASE="4.0.0") + def test_upgrade_from_36_to_40_with_imported(self): + Version.objects.create(version="3.6.0", git_sha="test") + ObjectTypeFactory.create(is_imported=True) + + call_command("check") + @override_settings(RELEASE="4.1.0") def test_upgrade_from_40_to_41_with_all_imported(self): """ From eb8bd6bb341a1c48e8db04ff096f3bdcd13644c9 Mon Sep 17 00:00:00 2001 From: floris272 Date: Mon, 9 Feb 2026 15:15:44 +0100 Subject: [PATCH 14/15] :white_check_mark: [#564] add unique validator to objecttype uuid serializer --- src/objects/api/serializers.py | 7 +++++- src/objects/tests/v2/test_objecttype_api.py | 24 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/objects/api/serializers.py b/src/objects/api/serializers.py index 1b7fb496..332cdac2 100644 --- a/src/objects/api/serializers.py +++ b/src/objects/api/serializers.py @@ -135,7 +135,12 @@ class Meta: ) extra_kwargs = { "url": {"lookup_field": "uuid"}, - "uuid": {"validators": [IsImmutableValidator()]}, + "uuid": { + "validators": [ + IsImmutableValidator(), + UniqueValidator(queryset=ObjectType.objects.all()), + ] + }, "namePlural": {"source": "name_plural"}, "dataClassification": {"source": "data_classification"}, "maintainerOrganization": {"source": "maintainer_organization"}, diff --git a/src/objects/tests/v2/test_objecttype_api.py b/src/objects/tests/v2/test_objecttype_api.py index 46ac0923..5db49115 100644 --- a/src/objects/tests/v2/test_objecttype_api.py +++ b/src/objects/tests/v2/test_objecttype_api.py @@ -125,6 +125,30 @@ def test_create_objecttype(self): self.assertEqual(object_type.created_at, date(2020, 1, 1)) self.assertEqual(object_type.modified_at, date(2020, 1, 1)) + def test_create_objecttype_with_duplicate_uuid(self): + object_type = ObjectTypeFactory.create() + + url = reverse("objecttype-list") + data = { + "uuid": object_type.uuid, + "name": "boom", + "namePlural": "bomen", + "description": "tree type description", + "dataClassification": DataClassificationChoices.intern, + "maintainerOrganization": "tree municipality", + "maintainerDepartment": "object types department", + "contactPerson": "John Smith", + "contactEmail": "John.Smith@objecttypes.nl", + "source": "tree system", + "updateFrequency": UpdateFrequencyChoices.monthly, + "providerOrganization": "tree provider", + "documentationUrl": "http://example.com/doc/trees", + "labels": {"key1": "value1"}, + } + + response = self.client.post(url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def test_update_objecttype(self): object_type = ObjectTypeFactory.create( data_classification=DataClassificationChoices.intern From d9f47d4c71358a554705c078b2aad42aada34fd7 Mon Sep 17 00:00:00 2001 From: floris272 Date: Mon, 9 Feb 2026 15:54:25 +0100 Subject: [PATCH 15/15] :white_check_mark: [#564] enable ObjectTypesConfigurationStep --- docker/setup_configuration/data.yaml | 10 ++++++++++ src/objects/conf/base.py | 1 + src/objects/tests/v2/test_validation.py | 13 ++++--------- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/docker/setup_configuration/data.yaml b/docker/setup_configuration/data.yaml index 3ad90671..0e70002c 100644 --- a/docker/setup_configuration/data.yaml +++ b/docker/setup_configuration/data.yaml @@ -26,6 +26,16 @@ notifications_config: notification_delivery_retry_backoff_max: 3 +objecttypes_config_enable: true +objecttypes: + items: + - uuid: b427ef84-189d-43aa-9efd-7bb2c459e281 + name: Object Type 1 + + - uuid: b0e8553f-8b1a-4d55-ab90-6d02f1bcf2c2 + name: Object Type 2 + + tokenauth_config_enable: true tokenauth: items: diff --git a/src/objects/conf/base.py b/src/objects/conf/base.py index adaa080a..45a44a07 100644 --- a/src/objects/conf/base.py +++ b/src/objects/conf/base.py @@ -157,6 +157,7 @@ "zgw_consumers.contrib.setup_configuration.steps.ServiceConfigurationStep", "notifications_api_common.contrib.setup_configuration.steps.NotificationConfigurationStep", "mozilla_django_oidc_db.setup_configuration.steps.AdminOIDCConfigurationStep", + "objects.setup_configuration.steps.objecttypes.ObjectTypesConfigurationStep", "objects.setup_configuration.steps.token_auth.TokenAuthConfigurationStep", ) diff --git a/src/objects/tests/v2/test_validation.py b/src/objects/tests/v2/test_validation.py index 21524bca..29c1330b 100644 --- a/src/objects/tests/v2/test_validation.py +++ b/src/objects/tests/v2/test_validation.py @@ -312,22 +312,17 @@ def test_update_geometry_not_allowed(self): ["This object type doesn't support geometry"], ) - def test_create_object_with_duplicate_uuid_returns_400(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), - ) - + def test_create_object_with_duplicate_uuid_returns_400(self): + ObjectTypeVersionFactory.create(object_type=self.object_type) url = reverse("object-list") data = { "uuid": "11111111-1111-1111-1111-111111111111", - "type": self.object_type.url, + "type": f"https://testserver{reverse('objecttype-detail', args=[self.object_type.uuid])}", "record": { "typeVersion": 1, "data": {"diameter": 30}, - "startAt": "2026-02-05", + "startAt": "2020-01-01", }, }