From 420384f7a7f201572cec4c91312ee83b4f80ccce Mon Sep 17 00:00:00 2001 From: floris272 Date: Fri, 19 Dec 2025 15:15:30 +0100 Subject: [PATCH 1/8] :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 | 171 +- 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 | 6 +- 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, 3592 insertions(+), 61 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 d59e91c7..043e53b9 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -82,6 +82,7 @@ django==5.2.8 # 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 a734bc9e..f78791cd 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -145,6 +145,7 @@ django==5.2.8 # 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 31c77624..9fc7c838 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -184,6 +184,7 @@ django==5.2.8 # 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 41263016..b8db7d9e 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 e845cf90..9c7aad01 100644 --- a/src/objects/api/serializers.py +++ b/src/objects/api/serializers.py @@ -2,20 +2,149 @@ 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_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 +from objects.core.models import Object, ObjectRecord, ObjectType, ObjectTypeVersion 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 ObjectRecordSerializer(serializers.ModelSerializer): 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 d89c0762..9eacb350 100644 --- a/src/objects/api/v2/views.py +++ b/src/objects/api/v2/views.py @@ -3,7 +3,9 @@ from django.conf import settings from django.db import models from django.utils.dateparse import parse_date +from django.utils.translation import gettext_lazy as _ +import structlog from drf_spectacular.utils import ( OpenApiParameter, OpenApiTypes, @@ -12,14 +14,22 @@ ) from rest_framework import mixins, 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.search import SearchMixin -from objects.core.models import ObjectRecord -from objects.token.models import Permission -from objects.token.permissions import ObjectTypeBasedPermission +from objects.api.metrics import ( + objecttype_create_counter, + objecttype_delete_counter, + objecttype_update_counter, +) +from objects.core.constants import ObjectTypeVersionStatus +from objects.core.models import 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 @@ -28,16 +38,25 @@ objects_delete_counter, objects_update_counter, ) -from ..mixins import GeoMixin, ObjectNotificationMixin +from ..mixins import GeoMixin, NestedViewSetMixin, ObjectNotificationMixin from ..pagination import DynamicPageSizePagination 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( @@ -58,6 +77,148 @@ ) +@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 5407a355..e4a110cb 100644 --- a/src/objects/conf/base.py +++ b/src/objects/conf/base.py @@ -36,6 +36,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 d795b06f..d0fa2e15 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 d9929a79..a6ca04ec 100644 --- a/src/objects/core/management/commands/import_objecttypes.py +++ b/src/objects/core/management/commands/import_objecttypes.py @@ -42,7 +42,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) @@ -134,7 +134,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 dd95aa62..4a2f469a 100644 --- a/src/objects/core/models.py +++ b/src/objects/core/models.py @@ -17,7 +17,7 @@ from .constants import ( DataClassificationChoices, - ObjectVersionStatus, + ObjectTypeVersionStatus, UpdateFrequencyChoices, ) from .query import ObjectQuerySet, ObjectRecordQuerySet, ObjectTypeQuerySet @@ -172,6 +172,17 @@ class Meta: def __str__(self): return f"{self.service.label}: {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 @@ -232,8 +243,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"), ) @@ -257,7 +268,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 427bce95..e21cd523 100644 --- a/src/objects/core/tests/factories.py +++ b/src/objects/core/tests/factories.py @@ -12,9 +12,9 @@ class ObjectTypeFactory(factory.django.DjangoModelFactory): - service = factory.SubFactory(ServiceFactory) - uuid = factory.LazyFunction(uuid.uuid4) - _name = factory.Faker("word") + 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") 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 819bb877..098a4aac 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 5eacf147..57ae8495 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/" @@ -1082,3 +1083,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 17787540..fe902a48 100644 --- a/src/objects/tests/v2/test_metrics.py +++ b/src/objects/tests/v2/test_metrics.py @@ -3,12 +3,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.models import ObjectType from objects.core.tests.factories import ( @@ -113,3 +117,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 c6b23407..06db1152 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 @@ -535,3 +540,113 @@ def test_update_geometry_not_allowed(self, m): response.json()["non_field_errors"], ["This object type doesn't support geometry"], ) + + # 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 6c95b1d77203fac1475228755cf7afe4563b91eb Mon Sep 17 00:00:00 2001 From: floris272 Date: Tue, 23 Dec 2025 14:36:20 +0100 Subject: [PATCH 2/8] :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 | 72 ++---------- 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, 228 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 e4a110cb..7bda0f5f 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 open_api_framework.conf.base import * # noqa @@ -166,3 +173,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 4a2f469a..705eca86 100644 --- a/src/objects/core/models.py +++ b/src/objects/core/models.py @@ -1,20 +1,12 @@ import datetime import uuid -from typing import Iterable 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, @@ -25,35 +17,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"), @@ -130,16 +109,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( @@ -166,9 +141,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}" @@ -183,32 +155,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( @@ -219,16 +165,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 e21cd523..8e34ed84 100644 --- a/src/objects/core/tests/factories.py +++ b/src/objects/core/tests/factories.py @@ -1,21 +1,15 @@ 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 ..models import Object, ObjectRecord, ObjectType, ObjectTypeVersion class ObjectTypeFactory(factory.django.DjangoModelFactory): - 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 c4233dc09c756d755da0e01f516599d063386df3 Mon Sep 17 00:00:00 2001 From: floris272 Date: Wed, 24 Dec 2025 12:19:59 +0100 Subject: [PATCH 3/8] :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/openapi.yaml | 897 +++++++++++++++++- 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 | 10 +- 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 | 20 +- src/objects/tests/v2/test_filters.py | 44 +- src/objects/tests/v2/test_geo_headers.py | 8 +- src/objects/tests/v2/test_geo_search.py | 12 +- src/objects/tests/v2/test_jsonschema.py | 39 +- src/objects/tests/v2/test_metrics.py | 41 +- .../tests/v2/test_notifications_send.py | 55 +- src/objects/tests/v2/test_object_api.py | 71 +- .../tests/v2/test_object_api_fields.py | 10 +- src/objects/tests/v2/test_ordering.py | 6 +- src/objects/tests/v2/test_pagination.py | 4 +- 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 -- 49 files changed, 1151 insertions(+), 1432 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 9c7aad01..9cdc486c 100644 --- a/src/objects/api/serializers.py +++ b/src/objects/api/serializers.py @@ -13,12 +13,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, ) @@ -41,11 +42,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": { @@ -96,7 +97,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"), @@ -127,7 +128,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"}, @@ -226,12 +227,13 @@ class ObjectSerializer(DynamicFieldsMixin, serializers.HyperlinkedModelSerialize validators=[IsImmutableValidator()], 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( @@ -244,7 +246,7 @@ class Meta: extra_kwargs = { "url": {"lookup_field": "object.uuid"}, } - validators = [JsonSchemaValidator(), GeometryValidator()] + validators = [ObjectTypeSchemaValidator(), GeometryValidator()] @transaction.atomic def create(self, validated_data): @@ -304,12 +306,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/openapi.yaml b/src/objects/api/v2/openapi.yaml index a135c907..23d1069d 100644 --- a/src/objects/api/v2/openapi.yaml +++ b/src/objects/api/v2/openapi.yaml @@ -816,6 +816,462 @@ 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: Number of results to return per page. + 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 + 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: Number of results to return per page. + 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 + 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 + 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 + 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 + 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 + 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) of the OBJECTTYPE in Objecttypes + API + 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) of the OBJECTTYPE in Objecttypes + API + 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) of the OBJECTTYPE in Objecttypes + API + 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) of the OBJECTTYPE in Objecttypes + API + 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 @@ -853,6 +1309,18 @@ paths: description: OK components: schemas: + 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 @@ -1049,9 +1517,7 @@ components: type: type: string 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' @@ -1118,6 +1584,179 @@ 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) of the OBJECTTYPE in Objecttypes + API + 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 + 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 + readOnly: true + version: + type: integer + readOnly: true + description: Integer version of the OBJECTTYPE + objectType: + type: string + format: uri + 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: @@ -1164,6 +1803,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: @@ -1208,22 +1893,188 @@ components: type: type: string 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 - Permission: + PatchedObjectType: type: object properties: - type: + url: type: string format: uri + readOnly: true minLength: 1 maxLength: 1000 - description: Url reference to OBJECTTYPE in Objecttypes API + 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) of the OBJECTTYPE in Objecttypes + API + 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 + 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 + readOnly: true + version: + type: integer + readOnly: true + description: Integer version of the OBJECTTYPE + objectType: + type: string + format: uri + 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: + type: + type: string + format: uri + description: Url reference to OBJECTTYPE mode: allOf: - $ref: '#/components/schemas/ModeEnum' @@ -1283,6 +2134,16 @@ components: type: array items: $ref: '#/components/schemas/Point2D' + StatusEnum: + enum: + - published + - draft + - deprecated + type: string + description: |- + * `published` - Published + * `draft` - Draft + * `deprecated` - Deprecated TypeEnum: type: string enum: @@ -1295,6 +2156,24 @@ components: - Feature - FeatureCollection - GeometryCollection + 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 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 9eacb350..c665e644 100644 --- a/src/objects/api/v2/views.py +++ b/src/objects/api/v2/views.py @@ -249,7 +249,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 a6ca04ec..e5818fe5 100644 --- a/src/objects/core/management/commands/import_objecttypes.py +++ b/src/objects/core/management/commands/import_objecttypes.py @@ -34,7 +34,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)) @@ -81,10 +81,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_plural", "description", @@ -123,14 +121,12 @@ 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") - objecttype["service"] = service - objecttype["is_imported"] = True data.append(ObjectType(**underscoreize(objecttype))) return data diff --git a/src/objects/core/models.py b/src/objects/core/models.py index 705eca86..28c57e1f 100644 --- a/src/objects/core/models.py +++ b/src/objects/core/models.py @@ -13,7 +13,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): @@ -142,7 +142,7 @@ class ObjectType(models.Model): objects = ObjectTypeQuerySet.as_manager() def __str__(self): - return f"{self.service.label}: {self._name}" + return f"{self.name}" @property def last_version(self): @@ -357,7 +357,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 d09fd4fe..e6f05fde 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( - api_root="http://objecttypes.local/api/v1/", - auth_type=AuthTypes.api_key, - header_key="Authorization", - header_value="Token 5cebbb33ffa725b6ed5e9e98300061218ba98d71", - ) - object_type = ObjectTypeFactory( - service=service, uuid="71a2452a-66c3-4030-b5ec-a06035102e9e" - ) - # Create 100 unused ObjectTypes, which creates 100 Services as well + object_type = ObjectTypeFactory(uuid="71a2452a-66c3-4030-b5ec-a06035102e9e") + # Create 100 unused ObjectTypes ObjectTypeFactory.create_batch(100) object1 = ObjectFactory(object_type=object_type) object2 = ObjectFactory() @@ -94,19 +83,8 @@ def get_num_results(response) -> int: @tag("gh-677") def test_add_new_objectrecord(self): - service = ServiceFactory( - api_root="http://objecttypes.local/api/v1/", - auth_type=AuthTypes.api_key, - header_key="Authorization", - header_value="Token 5cebbb33ffa725b6ed5e9e98300061218ba98d71", - ) - object_type = ObjectTypeFactory( - 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(uuid="71a2452a-66c3-4030-b5ec-a06035102e9e") + ObjectTypeVersionFactory(object_type=object_type) object = ObjectFactory(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 d10c1779..6a555e45 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_plural, "Meldingen") self.assertEqual(objecttype.description, "") @@ -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(service=self.service) - objecttype2 = ObjectTypeFactory(service=self.service) + objecttype1 = ObjectTypeFactory() + objecttype2 = ObjectTypeFactory() 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") version = ObjectTypeVersion.objects.get(object_type=objecttype, version=1) 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 94d3848e..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(slug="service-1") - service_2 = ServiceFactory(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(slug="service-1") - service_2: Service = ServiceFactory(slug="service-2") - - objecttype_1: ObjectType = ObjectTypeFactory( - service=service_1, - uuid="b427ef84-189d-43aa-9efd-7bb2c459e281", - _name="Object Type 001", - ) - objecttype_2: ObjectType = ObjectTypeFactory( - 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(slug="service-1") - - objecttype: ObjectType = ObjectTypeFactory( - 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(slug="service-1") - - objecttype: ObjectType = ObjectTypeFactory( - 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(slug="service-1") - service_2 = ServiceFactory(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 e5eb0877..398ee732 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(slug="service") ObjectTypeFactory( - service=self.service, uuid="3a82fb7f-fc9b-4104-9804-993f639d6d0d", - _name="Object Type 001", + name="Object Type 001", ) ObjectTypeFactory( - service=self.service, uuid="ca754b52-3f37-4c49-837c-130e8149e337", - _name="Object Type 002", + name="Object Type 002", ) ObjectTypeFactory( - 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 1730250b..cddf2b29 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(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() 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 098a4aac..22165be0 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(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() 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(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() 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(service__api_root=OBJECT_TYPES_API) + def test_update_superuser(self): + object_type = ObjectTypeFactory() + 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(service__api_root=OBJECT_TYPES_API) + def test_patch_superuser(self): + object_type = ObjectTypeFactory() + 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 5ebe0d4e..d1b4ae08 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(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() 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()}, }, ) @@ -87,7 +85,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"]}}, }, ) @@ -135,7 +133,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( @@ -170,7 +168,7 @@ class ListAuthFieldsTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() cls.other_object_type = ObjectTypeFactory() def test_list_without_query_different_object_types(self): @@ -220,7 +218,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, @@ -237,7 +235,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): @@ -388,7 +386,7 @@ class SearchAuthFieldsTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() def test_search_with_fields_auth(self): PermissionFactory.create( @@ -423,7 +421,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 57ae8495..108dfe99 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,8 +28,8 @@ class FilterObjectTypeTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory(service__api_root=OBJECT_TYPES_API) - cls.another_object_type = ObjectTypeFactory(service=cls.object_type.service) + cls.object_type = ObjectTypeFactory() + cls.another_object_type = ObjectTypeFactory() PermissionFactory.create( object_type=cls.object_type, @@ -49,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) @@ -67,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") @@ -96,7 +78,7 @@ class FilterDataAttrsTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_only, @@ -444,7 +426,7 @@ class FilterDataAttrTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_only, @@ -837,7 +819,7 @@ class FilterDateTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_only, @@ -972,7 +954,7 @@ class FilterDataIcontainsTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_only, @@ -1050,7 +1032,7 @@ class FilterTypeVersionTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() 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 3dab9547..aa2e18cf 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(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() 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 6c4d45c0..fa623bc1 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,8 +19,8 @@ class GeoSearchTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory(service__api_root=OBJECT_TYPES_API) - cls.another_object_type = ObjectTypeFactory(service=cls.object_type.service) + cls.object_type = ObjectTypeFactory() + cls.another_object_type = ObjectTypeFactory() PermissionFactory.create( object_type=cls.object_type, @@ -88,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, ) @@ -108,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 bae51e6e..50cc4a1c 100644 --- a/src/objects/tests/v2/test_jsonschema.py +++ b/src/objects/tests/v2/test_jsonschema.py @@ -1,23 +1,18 @@ from typing import cast -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 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""" @@ -25,27 +20,21 @@ class JsonSchemaTests(TokenAuthMixin, ClearCachesMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = cast( - ObjectType, ObjectTypeFactory(service__api_root=OBJECT_TYPES_API) - ) + cls.object_type = cast(ObjectType, ObjectTypeFactory()) 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"}, @@ -57,18 +46,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 fe902a48..ecebb0be 100644 --- a/src/objects/tests/v2/test_metrics.py +++ b/src/objects/tests/v2/test_metrics.py @@ -1,7 +1,6 @@ from typing import cast 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 @@ -19,26 +18,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 = cast( - ObjectType, ObjectTypeFactory(service__api_root=OBJECT_TYPES_API) - ) + cls.object_type = cast(ObjectType, ObjectTypeFactory()) PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_and_write, @@ -55,19 +50,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}, @@ -78,15 +67,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]) @@ -101,15 +84,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 43621815..41bdcf6f 100644 --- a/src/objects/tests/v2/test_notifications_send.py +++ b/src/objects/tests/v2/test_notifications_send.py @@ -2,7 +2,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 @@ -14,27 +13,26 @@ 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("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(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() + ObjectTypeVersionFactory.create(object_type=cls.object_type) + PermissionFactory.create( object_type=cls.object_type, mode=PermissionModes.read_and_write, @@ -59,20 +57,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}, @@ -100,29 +92,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}, @@ -150,29 +136,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}, @@ -200,21 +180,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) @@ -237,7 +212,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 822c97d7..436c9773 100644 --- a/src/objects/tests/v2/test_object_api.py +++ b/src/objects/tests/v2/test_object_api.py @@ -1,29 +1,26 @@ import json import uuid from datetime import date, timedelta -from typing import cast import requests_mock from freezegun import freeze_time from rest_framework import status from rest_framework.test import APITestCase -from objects.core.models import Object, ObjectType +from objects.core.models import Object from objects.core.tests.factories import ( 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, reverse_lazy -OBJECT_TYPES_API = "https://example.com/objecttypes/v1/" - @freeze_time("2020-08-08") @requests_mock.Mocker() @@ -34,9 +31,11 @@ class ObjectApiTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = cast( - ObjectType, ObjectTypeFactory(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, @@ -71,7 +70,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, @@ -108,7 +107,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 +185,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 +201,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 +217,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 +228,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 +270,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 +313,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 +409,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 +434,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 +463,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 +472,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}, @@ -554,7 +513,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 9caaef7a..357dab3d 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(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() 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 dfe9158d..8d2ef906 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(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() PermissionFactory.create( object_type=cls.object_type, @@ -128,7 +126,7 @@ class OrderingAllowedTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() 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 920711ec..1282d8ee 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,7 +18,7 @@ class FilterObjectTypeTests(TokenAuthMixin, APITestCase): def setUpTestData(cls): super().setUpTestData() - cls.object_type = ObjectTypeFactory(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() PermissionFactory( object_type=cls.object_type, mode=PermissionModes.read_only, 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 5c1cdfb2..02bb46ec 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(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() 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(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() 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(service__api_root=OBJECT_TYPES_API) + cls.object_type = ObjectTypeFactory() 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 06db1152..d30ed90f 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(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() - 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(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) + 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_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) - - 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(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(service=self.object_type.service) + def test_update_object_type_invalid(self): + old_object_type = ObjectTypeFactory() 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], @@ -541,7 +293,6 @@ def test_update_geometry_not_allowed(self, m): ["This object type doesn't support geometry"], ) - # 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 0397321d..753ab549 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( - api_root=self.object_types_api.format(version="v2"), - auth_type=AuthTypes.api_key, - header_key="Authorization", - header_value="Token 5cebbb33ffa725b6ed5e9e98300061218ba98d71", - ) - object_type = ObjectTypeFactory( - service=v2_service, uuid="71a2452a-66c3-4030-b5ec-a06035102e9e" - ) + object_type = ObjectTypeFactory(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 c7604460..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(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 1f758098758303de4dd25cb1846e2737ccc867a4 Mon Sep 17 00:00:00 2001 From: floris272 Date: Wed, 24 Dec 2025 13:20:56 +0100 Subject: [PATCH 4/8] :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 7bda0f5f..f0d2a91d 100644 --- a/src/objects/conf/base.py +++ b/src/objects/conf/base.py @@ -151,7 +151,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 d30ed90f..194822b2 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 18c89172097bdc883d7744fb7de6cb7cd2c92551 Mon Sep 17 00:00:00 2001 From: floris272 Date: Wed, 24 Dec 2025 13:38:25 +0100 Subject: [PATCH 5/8] :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 2e1e09a2..4213d1d6 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 f0d2a91d..aa62a28e 100644 --- a/src/objects/conf/base.py +++ b/src/objects/conf/base.py @@ -64,17 +64,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 8e34ed84..4c536c77 100644 --- a/src/objects/core/tests/factories.py +++ b/src/objects/core/tests/factories.py @@ -25,7 +25,11 @@ class ObjectTypeVersionFactory(factory.django.DjangoModelFactory): "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 436c9773..c3ffc884 100644 --- a/src/objects/tests/v2/test_object_api.py +++ b/src/objects/tests/v2/test_object_api.py @@ -23,7 +23,6 @@ @freeze_time("2020-08-08") -@requests_mock.Mocker() class ObjectApiTests(TokenAuthMixin, APITestCase): maxDiff = None @@ -42,7 +41,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(), @@ -87,7 +86,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, @@ -122,7 +121,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), @@ -184,7 +183,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])}", @@ -216,7 +215,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( @@ -269,7 +268,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, @@ -312,7 +311,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, @@ -345,7 +344,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]) @@ -355,7 +354,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), @@ -408,7 +407,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") @@ -462,7 +461,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 746ec202ae4571121b918cfcb71974328df966bc Mon Sep 17 00:00:00 2001 From: floris272 Date: Wed, 7 Jan 2026 09:49:58 +0100 Subject: [PATCH 6/8] :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 4213d1d6..c7de0dfd 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 4c536c77..e913f2c0 100644 --- a/src/objects/core/tests/factories.py +++ b/src/objects/core/tests/factories.py @@ -25,11 +25,14 @@ class ObjectTypeVersionFactory(factory.django.DjangoModelFactory): "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 c3ffc884..98aa8bc5 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 587f7e58a26a019324c233997f3f4d0db9b8c1a4 Mon Sep 17 00:00:00 2001 From: floris272 Date: Wed, 7 Jan 2026 14:24:04 +0100 Subject: [PATCH 7/8] :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 e913f2c0..d33ca4a5 100644 --- a/src/objects/core/tests/factories.py +++ b/src/objects/core/tests/factories.py @@ -20,20 +20,22 @@ class Meta: class ObjectTypeVersionFactory(factory.django.DjangoModelFactory): 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 98aa8bc5..c83f761a 100644 --- a/src/objects/tests/v2/test_object_api.py +++ b/src/objects/tests/v2/test_object_api.py @@ -287,7 +287,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() @@ -331,7 +331,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 b4b24a273631179b13e95eb8fb0b65d5b6b845c3 Mon Sep 17 00:00:00 2001 From: floris272 Date: Wed, 7 Jan 2026 15:26:39 +0100 Subject: [PATCH 8/8] :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