From 722e73e474b413dfb31d33b4d2e666f3f1299170 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Mon, 16 Mar 2026 11:46:46 +0100 Subject: [PATCH 01/13] :sparkles: [#565] Implement import_export similar to OF and OZ --- src/objects/core/import_export.py | 222 ++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 src/objects/core/import_export.py diff --git a/src/objects/core/import_export.py b/src/objects/core/import_export.py new file mode 100644 index 00000000..46c748dc --- /dev/null +++ b/src/objects/core/import_export.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import json +import zipfile +from datetime import datetime, timezone +from typing import ( + BinaryIO, + Callable, + Iterable, + Mapping, + NoReturn, + Sequence, +) + +from django.core.exceptions import SuspiciousFileOperation +from django.core.files import File +from django.core.files.uploadedfile import UploadedFile +from django.core.serializers.json import DjangoJSONEncoder +from django.db import transaction +from django.db.models import Manager, QuerySet +from django.utils.translation import gettext_lazy as _ + +import structlog +from rest_framework.exceptions import ValidationError +from rest_framework.serializers import BaseSerializer, ModelSerializer + +from objects import __version__ +from objects.api.serializers import ( + ObjectTypeSerializer as _ObjectTypeSerializer, + ObjectTypeVersionSerializer as _ObjectTypeVersionSerializer, +) +from objects.core.models import ObjectType, ObjectTypeVersion + +logger = structlog.stdlib.get_logger(__name__) + + +class ObjectTypeVersionSerializer(ModelSerializer[ObjectTypeVersion]): + class Meta(_ObjectTypeVersionSerializer.Meta): # type: ignore + fields = [ + field + for field in _ObjectTypeVersionSerializer.Meta.fields + if field not in ["url", "objectType"] # exclude + ] + extra_kwargs = { + f: kwargs | {"read_only": False} + for f, kwargs in _ObjectTypeVersionSerializer.Meta.extra_kwargs.items() + } + + +class ObjectTypeSerializer(_ObjectTypeSerializer): + """ObjectTypeSerializer adapted for exporting/importing into a different instance. + + No hyperlinks + """ + + versions = ObjectTypeVersionSerializer(many=True) + + class Meta(_ObjectTypeSerializer.Meta): + fields = [ + field for field in _ObjectTypeSerializer.Meta.fields if field not in ["url"] + ] + extra_kwargs = { + f: kwargs | {"read_only": False} + for f, kwargs in _ObjectTypeSerializer.Meta.extra_kwargs.items() + } + + def create(self, validated_data) -> ObjectType: + versions = validated_data.pop("versions") + object_type = super().create(validated_data | {"is_imported": True}) + for data in versions: + ObjectTypeVersion.objects.create(object_type=object_type, **data) + + return object_type + + +IMPORT_ORDER: Mapping[str, type[BaseSerializer]] = { + "objectTypes": ObjectTypeSerializer, +} +EXPORT_MAP = {v: k for k, v in IMPORT_ORDER.items()} + + +def export_data( + output: BinaryIO, + /, + *, + objecttypes: Sequence[ObjectType] | QuerySet[ObjectType] | Manager[ObjectType], +) -> None: + """Export + + The zip will be written to output. + """ + + if isinstance(objecttypes, (QuerySet, Manager)): # pyright: ignore[reportArgumentType] + objecttypes = objecttypes.prefetch_related("versions") + + with zipfile.ZipFile(output, "w") as zf: + zf.writestr( + _filename(EXPORT_MAP[ObjectTypeSerializer]), + _encode( + ObjectTypeSerializer( + instance=objecttypes, many=True, context={"request": None} + ).data + ), + ) + zf.writestr( + "meta.json", + _encode(_metadata()), + ) + + +def _strip_uuid(data: object): + match data: + case [*members]: + return [_strip_uuid(m) for m in members] + case {"uuid": _, **rest}: + return rest + case _: + return data + + +@transaction.atomic +def import_data( + export_file: BinaryIO | File, keep_uuid: bool = True +) -> set[str] | NoReturn: + """Import the data from export_file return the set of resources imported. + + raises + DRF ValidationError if contents malformed + zipfile.BadZipFile if file is malformed + """ + imported_resources: set[str] = set() + with zipfile.ZipFile(export_file, "r") as zf: + present = zf.namelist().__contains__ + + for resource, Serializer in IMPORT_ORDER.items(): + if not present(_filename(resource)): + continue + + data = _decode(zf.read(_filename(resource))) + + if not keep_uuid: + data = _strip_uuid(data) + + serializer = Serializer(data=data, many=True) + if serializer.is_valid(raise_exception=True): + serializer.save() + imported_resources.add(resource) + return imported_resources + + +def _flatten_drf_error(error: ValidationError) -> Iterable[str]: + return ( + f"Row {index}, {field}: {err}" + for index, item in enumerate(error.detail, 1) + if isinstance(item, dict) + for field, errors in item.items() + for err in errors + ) + + +def import_upload( + file: UploadedFile | object | list[object], + keep_uuid: bool, + report_error_to_user: Callable[[str], None], +) -> set[str]: + """Import data from Uploaded fiLe and return the set of type names that were imported. + + The object | list[object] annotation doesn't mean you can throw anything at it; + just anything from request.FILES / form.files, so anything suspicious will raise + SuspiciousFileOperation + """ + match file: + case UploadedFile(): + with file.open() as fd: + try: + return import_data(fd, keep_uuid=keep_uuid) + except ValidationError as e: + for error_msg in _flatten_drf_error(e): + report_error_to_user(error_msg) + except zipfile.BadZipfile: + report_error_to_user( + _("{file} is not a supported file type").format(file=file.name), + ) + except Exception: + report_error_to_user( + _( + "Something unexpected happened during import.\n" + "If you think the file should be correct, " + "please include the file in your bug report.\n" + ).format() + ) + logger.exception("unhandled import exception") + return set() + case [*fs]: + return { + resource + for f in fs + for resource in import_upload(f, keep_uuid, report_error_to_user) + } + case _: # pragma: no-cover + logger.exception("unexected type in upload", contents=file) + raise SuspiciousFileOperation("Unexpected type in upload") + + +def _encode(obj: object) -> str: + return json.dumps(obj, cls=DjangoJSONEncoder) + + +def _decode(s: bytes | str) -> object: + return json.loads(s) + + +def _filename(model_name: str) -> str: + return f"{model_name}.json" + + +def _metadata() -> dict[str, str]: + "Metadata for an export" + return { + "app_version": __version__, + "exported_at": datetime.now(timezone.utc).isoformat() + "Z", + } From a7697765a6c41b32c2d3945bad17306383b874fe Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Tue, 17 Mar 2026 14:30:53 +0100 Subject: [PATCH 02/13] :heavy_plus_sign: [#565] Add hypothesis and hypothesis-jsonschema for testing Also updated their dependencies to solve import and api issues. --- requirements/base.txt | 2 +- requirements/ci.txt | 19 ++++++++++++++++++- requirements/dev.txt | 33 ++++++++++++++++++++++++++++++++- requirements/test-tools.in | 3 +++ 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index 5ac132fb..d3521748 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -17,7 +17,7 @@ asgiref==3.11.0 # django-structlog asn1crypto==1.5.1 # via webauthn -attrs==20.3.0 +attrs==25.4.0 # via # glom # jsonschema diff --git a/requirements/ci.txt b/requirements/ci.txt index d908cdfc..ddba9216 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -32,12 +32,13 @@ asn1crypto==1.5.1 # -c requirements/base.txt # -r requirements/base.txt # webauthn -attrs==20.3.0 +attrs==25.4.0 # via # -c requirements/base.txt # -r requirements/base.txt # glom # jsonschema + # referencing babel==2.16.0 # via sphinx beautifulsoup4==4.9.3 @@ -418,6 +419,12 @@ humanize==4.9.0 # -c requirements/base.txt # -r requirements/base.txt # flower +hypothesis==6.151.9 + # via + # -r requirements/test-tools.in + # hypothesis-jsonschema +hypothesis-jsonschema==0.22.1 + # via -r requirements/test-tools.in idna==3.7 # via # -c requirements/base.txt @@ -455,6 +462,9 @@ jsonschema==4.17.3 # -c requirements/base.txt # -r requirements/base.txt # drf-spectacular + # hypothesis-jsonschema +jsonschema-specifications==2025.9.1 + # via -r requirements/test-tools.in kombu==5.5.4 # via # -c requirements/base.txt @@ -733,6 +743,8 @@ redis==6.4.0 # -c requirements/base.txt # -r requirements/base.txt # django-redis +referencing==0.37.0 + # via jsonschema-specifications requests==2.32.4 # via # -c requirements/base.txt @@ -755,6 +767,8 @@ requests-mock==1.12.1 # -r requirements/test-tools.in roman-numerals==4.1.0 # via sphinx +rpds-py==0.30.0 + # via referencing ruamel-yaml==0.18.10 # via # -c requirements/base.txt @@ -790,6 +804,8 @@ six==1.16.0 # webtest snowballstemmer==2.2.0 # via sphinx +sortedcontainers==2.4.0 + # via hypothesis soupsieve==2.2.1 # via beautifulsoup4 sphinx==9.1.0 @@ -853,6 +869,7 @@ typing-extensions==4.9.0 # pydantic # pydantic-core # pyopenssl + # referencing # zgw-consumers tzdata==2025.2 # via diff --git a/requirements/dev.txt b/requirements/dev.txt index f4f647ec..63a300b6 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -37,12 +37,13 @@ asn1crypto==1.5.1 # -c requirements/ci.txt # -r requirements/ci.txt # webauthn -attrs==20.3.0 +attrs==25.4.0 # via # -c requirements/ci.txt # -r requirements/ci.txt # glom # jsonschema + # referencing autopep8==2.3.2 # via django-silk babel==2.16.0 @@ -482,6 +483,15 @@ humanize==4.9.0 # -c requirements/ci.txt # -r requirements/ci.txt # flower +hypothesis==6.151.9 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt + # hypothesis-jsonschema +hypothesis-jsonschema==0.22.1 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt identify==2.6.10 # via pre-commit idna==3.7 @@ -532,6 +542,11 @@ jsonschema==4.17.3 # -c requirements/ci.txt # -r requirements/ci.txt # drf-spectacular + # hypothesis-jsonschema +jsonschema-specifications==2025.9.1 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt kombu==5.5.4 # via # -c requirements/ci.txt @@ -865,6 +880,11 @@ redis==6.4.0 # -c requirements/ci.txt # -r requirements/ci.txt # django-redis +referencing==0.37.0 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt + # jsonschema-specifications requests==2.32.4 # via # -c requirements/ci.txt @@ -895,6 +915,11 @@ roman-numerals==4.1.0 # -c requirements/ci.txt # -r requirements/ci.txt # sphinx +rpds-py==0.30.0 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt + # referencing ruamel-yaml==0.18.10 # via # -c requirements/ci.txt @@ -939,6 +964,11 @@ snowballstemmer==2.2.0 # -c requirements/ci.txt # -r requirements/ci.txt # sphinx +sortedcontainers==2.4.0 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt + # hypothesis soupsieve==2.2.1 # via # -c requirements/ci.txt @@ -1041,6 +1071,7 @@ typing-extensions==4.9.0 # pydantic # pydantic-core # pyopenssl + # referencing # rich-click # zgw-consumers tzdata==2025.2 diff --git a/requirements/test-tools.in b/requirements/test-tools.in index cd401c28..50f6a95b 100644 --- a/requirements/test-tools.in +++ b/requirements/test-tools.in @@ -13,6 +13,9 @@ pyquery # integrates with webtest requests-mock tblib vcrpy +hypothesis +hypothesis-jsonschema +jsonschema-specifications # Code formatting ruff From e52c9c20739beebdedd50ee9ea3dcf05b37dc623 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Mon, 16 Mar 2026 15:22:37 +0100 Subject: [PATCH 03/13] :white_check_mark: [#565] Test import export --- src/objects/core/tests/test_export_import.py | 269 +++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 src/objects/core/tests/test_export_import.py diff --git a/src/objects/core/tests/test_export_import.py b/src/objects/core/tests/test_export_import.py new file mode 100644 index 00000000..e668fc89 --- /dev/null +++ b/src/objects/core/tests/test_export_import.py @@ -0,0 +1,269 @@ +import json +from io import BytesIO + +import hypothesis.strategies as st +import jsonschema_specifications +from hypothesis import HealthCheck, given, settings +from hypothesis.extra.django import TestCase, from_model +from hypothesis_jsonschema import from_schema + +from ..import_export import export_data, import_data +from ..models import ObjectType, ObjectTypeVersion +from ..utils import check_json_schema + + +def _valid_schema(json_value): + try: + check_json_schema(json_value) + except Exception: + return False + else: + # no escaped null-bytes PG JSONFields can't handle them + return r"\u0000" not in json.dumps(json_value) + + +def jsonschemas(): + "Hypothesesis strategy that generates valid jsonschemata." + # from_schema doesn't resolve $dynamicRef (yet), which the 2020 draft meta schema uses + meta_schema = "https://json-schema.org/draft/2019-09/schema" + return st.one_of( + st.booleans(), + st.just({}), + ( + from_schema( + jsonschema_specifications.REGISTRY[meta_schema].contents, # type: ignore + ) + .map( + lambda schema: schema | {"$schema": meta_schema} + if isinstance(schema, dict) + else schema + ) + .filter(_valid_schema) + ), + ) + + +@st.composite +def objecttypes( + draw: st.DrawFn, *, min_versions: int = 0, max_versions: int | None = None +) -> ObjectType: + # postgres can't store + + object_type = draw( + from_model( + ObjectType, + is_imported=st.just(False), + contact_email=st.just("") | st.emails(), # optional email + ) + ) + # for better scr + schemata = jsonschemas() + schema = draw(schemata) + + # create some versions + draw( + st.lists( + from_model( + ObjectTypeVersion, + object_type=st.just(object_type), + json_schema=st.one_of( + st.just(schema), # re-use same + schemata, # change to a new schema + ), + # hypothesis infers the bounds correctly, but also tries 0 + # and will bump into the auto gen going out of bounds + version=st.integers(min_value=1, max_value=(1 << 15) - 1), + ), + min_size=min_versions, + max_size=max_versions, + ) + ) + return object_type + + +class RoundTripExportImportTests(TestCase): + def setup_example(self): + # empty before each hypothesis example + ObjectType.objects.all().delete() + ObjectTypeVersion.objects.all().delete() + return super().setup_example() + + @given(objecttypes=st.lists(objecttypes(max_versions=3), min_size=1, max_size=3)) + @settings( + suppress_health_check=[HealthCheck.too_slow], + ) + def test_export_import_roundtrip(self, objecttypes: list[ObjectType]) -> None: + output = BytesIO() + + export_data(output, objecttypes=objecttypes) + + output.seek(0) + + original_versions = { + ot.uuid: list(ot.versions.order_by("version")) for ot in objecttypes + } + + # purge after export + ObjectType.objects.all().delete() + ObjectTypeVersion.objects.all().delete() + + import_data(output) + + assert ObjectType.objects.count() == len(objecttypes) + + imported_types = ( + ObjectType.objects.all().prefetch_related("versions").order_by("uuid") + ) + assert imported_types.count() == len(objecttypes) + + for imported_type, original_type in zip( + imported_types, sorted(objecttypes, key=lambda x: x.uuid) + ): + assert imported_type.is_imported is True + + assert imported_type.uuid == original_type.uuid + assert imported_type.name == original_type.name.strip() + assert imported_type.name_plural == original_type.name_plural.strip() + assert imported_type.description == original_type.description.strip() + assert ( + imported_type.data_classification + == original_type.data_classification.strip() + ) + assert ( + imported_type.maintainer_organization + == original_type.maintainer_organization.strip() + ) + assert ( + imported_type.maintainer_department + == original_type.maintainer_department.strip() + ) + assert imported_type.contact_person == original_type.contact_person.strip() + assert imported_type.contact_email == original_type.contact_email.strip() + assert imported_type.source == original_type.source.strip() + assert ( + imported_type.update_frequency == original_type.update_frequency.strip() + ) + assert ( + imported_type.provider_organization + == original_type.provider_organization.strip() + ) + assert ( + imported_type.documentation_url + == original_type.documentation_url.strip() + ) + assert imported_type.labels == original_type.labels + assert imported_type.allow_geometry == original_type.allow_geometry + + assert imported_type.versions.count() == len( + original_versions[original_type.uuid] + ) + + for imported_version, original_version in zip( + imported_type.versions.order_by("version"), + original_versions[original_type.uuid], + ): + assert imported_version.version == original_version.version + assert imported_version.json_schema == original_version.json_schema + assert imported_version.status == original_version.status + assert imported_version.published_at == original_version.published_at + + @given(objecttype=objecttypes(min_versions=1, max_versions=3)) + @settings( + suppress_health_check=[HealthCheck.too_slow], + ) + def test_export_import_roundtrip_from_queryset( + self, objecttype: ObjectType + ) -> None: + "Ensure a QuerySet[ObjectType] doesn't need an explicit prefect" + num_versions = objecttype.versions.count() + output = BytesIO() + + export_data(output, objecttypes=ObjectType.objects.all()) + + output.seek(0) + # purge after export + ObjectType.objects.all().delete() + ObjectTypeVersion.objects.all().delete() + + import_data(output) + output.truncate() + + assert ObjectTypeVersion.objects.count() == num_versions + + @given( + objecttypes=st.lists( + objecttypes(min_versions=1, max_versions=3), min_size=1, max_size=3 + ) + ) + @settings( + suppress_health_check=[HealthCheck.too_slow], + ) + def test_export_import_with_new_uuids(self, objecttypes: list[ObjectType]) -> None: + num_versions = ObjectTypeVersion.objects.count() + output = BytesIO() + + export_data(output, objecttypes=objecttypes) + + output.seek(0) + import_data(output, keep_uuid=False) + output.truncate() + + assert ObjectType.objects.count() == len(objecttypes) * 2 + assert ObjectTypeVersion.objects.count() == num_versions * 2 + + @given(objecttypes(min_versions=1, max_versions=3)) + @settings(suppress_health_check=[HealthCheck.too_slow]) + def test_export_import_with_new_uuid(self, original_type: ObjectType) -> None: + output = BytesIO() + + export_data(output, objecttypes=[original_type]) + + output.seek(0) + # import a new, keeping original + import_data(output, keep_uuid=False) + output.truncate() + + imported_type = ObjectType.objects.exclude(uuid=original_type.uuid).get() + + assert imported_type.is_imported is True + + assert imported_type.uuid != original_type.uuid + assert imported_type.name == original_type.name.strip() + assert imported_type.name_plural == original_type.name_plural.strip() + assert imported_type.description == original_type.description.strip() + assert ( + imported_type.data_classification + == original_type.data_classification.strip() + ) + assert ( + imported_type.maintainer_organization + == original_type.maintainer_organization.strip() + ) + assert ( + imported_type.maintainer_department + == original_type.maintainer_department.strip() + ) + assert imported_type.contact_person == original_type.contact_person.strip() + assert imported_type.contact_email == original_type.contact_email.strip() + assert imported_type.source == original_type.source.strip() + assert imported_type.update_frequency == original_type.update_frequency.strip() + assert ( + imported_type.provider_organization + == original_type.provider_organization.strip() + ) + assert ( + imported_type.documentation_url == original_type.documentation_url.strip() + ) + assert imported_type.labels == original_type.labels + assert imported_type.allow_geometry == original_type.allow_geometry + + assert imported_type.versions.count() == original_type.versions.count() + + for imported_version, original_version in zip( + imported_type.versions.order_by("version"), + original_type.versions.order_by("version"), + ): + assert imported_version.version == original_version.version + assert imported_version.json_schema == original_version.json_schema + assert imported_version.status == original_version.status + assert imported_version.published_at == original_version.published_at From b2691d67a3a294ce3ee073f26a9ed14140e6c943 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Mon, 16 Mar 2026 15:25:08 +0100 Subject: [PATCH 04/13] :sparkles: [#565] Add export action and import tool to admin --- src/objects/core/admin.py | 65 ++++++++++++++++++- src/objects/core/forms.py | 14 ++++ .../core/objecttype/object_import_form.html | 11 +++- .../admin/core/objecttype/object_list.html | 5 ++ 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/objects/core/admin.py b/src/objects/core/admin.py index 95c7b35c..bbdd02b4 100644 --- a/src/objects/core/admin.py +++ b/src/objects/core/admin.py @@ -1,4 +1,8 @@ +from __future__ import annotations + +import io import json +from functools import partial from typing import Sequence from django import forms @@ -6,7 +10,7 @@ from django.contrib import admin, messages from django.contrib.gis.db.models import GeometryField from django.db import models -from django.http import HttpRequest, HttpResponseRedirect +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.shortcuts import redirect, render from django.urls import path, reverse from django.utils.html import format_html @@ -19,7 +23,8 @@ from objects.api.v2.filters import filter_queryset_by_data_attr from .constants import ObjectTypeVersionStatus -from .forms import ObjectTypeVersionForm, UrlImportForm +from .forms import FileImportForm, ObjectTypeVersionForm, UrlImportForm +from .import_export import export_data, import_upload from .models import Object, ObjectRecord, ObjectType, ObjectTypeVersion from .widgets import JSONSuit @@ -109,6 +114,8 @@ class ObjectTypeAdmin(admin.ModelAdmin): change_list_template = "admin/core/objecttype/object_list.html" + actions = ["export_objecttypes_action"] + def get_urls(self): urls = super().get_urls() my_urls = [ @@ -117,6 +124,11 @@ def get_urls(self): self.admin_site.admin_view(self.import_from_url_view), name="import_from_url", ), + path( + "import-from-file/", + self.admin_site.admin_view(self.import_from_file_view), + name="import_from_file", + ), ] return my_urls + urls @@ -183,10 +195,57 @@ def import_from_url_view(self, request): request, "admin/core/objecttype/object_import_form.html", {"form": form} ) + def import_from_file_view(self, request: HttpRequest) -> HttpResponse: + if request.method == "POST": + form = FileImportForm(request.POST, files=request.FILES) + if form.is_valid(): + imported_types = import_upload( + form.files["export_file"], + form.cleaned_data["keep_uuid"], + partial(form.add_error, "export_file"), + ) + if not form.errors and not imported_types: + form.add_error( + "export_file", _("Found nothing importable in that file") + ) + if not form.errors: + self.message_user( + request, + _("{resource_types} imported successfully.").format( + resource_types=",".join(imported_types) + ), + ) + return redirect(reverse("admin:core_objecttype_changelist")) + else: + form = FileImportForm() + + return render( + request, + "admin/core/objecttype/object_import_form.html", + {"form": form, "source": "file"}, + ) + + @admin.action(description="Export selected objecttypes as a file") + def export_objecttypes_action( + self, request: HttpRequest, queryset: models.QuerySet[ObjectType] + ) -> HttpResponse | None: + if not queryset.exists(): + self.message_user(request, "No objecttypes selected for export.") + return None + + output = io.BytesIO() + export_data(output, objecttypes=queryset) + + response = HttpResponse(output.getvalue(), content_type="application/zip") + response["Content-Disposition"] = ( + 'attachment; filename="objecttypes-export.zip"' + ) + return response + class ObjectRecordForm(forms.ModelForm): class Meta: - model: ObjectRecord + model = ObjectRecord help_texts = { "geometry": get_help_text("core.ObjectRecord", "geometry") + "\n\n format: SRID=4326;POINT|LINESTRING|POLYGON (LAT LONG, ...)" diff --git a/src/objects/core/forms.py b/src/objects/core/forms.py index a8b05fdb..6ccc2f19 100644 --- a/src/objects/core/forms.py +++ b/src/objects/core/forms.py @@ -58,6 +58,20 @@ def clean_objecttype_url(self): self.cleaned_data["json"] = response_json +class FileImportForm(forms.Form): + export_file = forms.FileField( + label=_("Object type export file"), + required=True, + help_text=_("The file exported with the Export option from the Action menu."), + ) + keep_uuid = forms.BooleanField( + label=_("Keep the UUIDs the same"), + help_text=_("Import keeping the same UUID as in the export."), + initial=False, + required=False, + ) + + class ObjectTypeVersionForm(forms.ModelForm): class Meta: model = ObjectTypeVersion diff --git a/src/objects/templates/admin/core/objecttype/object_import_form.html b/src/objects/templates/admin/core/objecttype/object_import_form.html index 709b9170..09e087a0 100644 --- a/src/objects/templates/admin/core/objecttype/object_import_form.html +++ b/src/objects/templates/admin/core/objecttype/object_import_form.html @@ -12,10 +12,15 @@ {% endblock %} {% block content %} -

{% trans 'Import from URL' %}

+

{% blocktrans with source=source|default:'URL' %}Import from {{ source }}{% endblocktrans %}

{% csrf_token %} + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %}
{% for field in form %}
@@ -23,8 +28,8 @@

{% trans 'Import from URL' %}

{{ field }} {% if field.field.help_text %}  -
{{ field.field.help_text }}
- {% endif %} +
{{ field.field.help_text }}
+ {% endif %}
{% endfor %}
diff --git a/src/objects/templates/admin/core/objecttype/object_list.html b/src/objects/templates/admin/core/objecttype/object_list.html index 86ff2d1b..69df3556 100644 --- a/src/objects/templates/admin/core/objecttype/object_list.html +++ b/src/objects/templates/admin/core/objecttype/object_list.html @@ -7,5 +7,10 @@ {% trans 'Import from URL' %} +
  • + + {% trans 'Import from file' %} + +
  • {{ block.super }} {% endblock %} From a9d095d4d8aae6d5279914b9bb427ad715678c97 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Mon, 16 Mar 2026 15:26:04 +0100 Subject: [PATCH 05/13] :white_check_mark: [#565] Fix a NumQueries test Hacked it into a bounds check. --- src/objects/core/tests/test_admin.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/objects/core/tests/test_admin.py b/src/objects/core/tests/test_admin.py index 3b198aae..0984df39 100644 --- a/src/objects/core/tests/test_admin.py +++ b/src/objects/core/tests/test_admin.py @@ -33,7 +33,16 @@ def test_object_changelist_filter_by_objecttype(self): object2 = ObjectFactory.create() # Verify that the number of queries doesn't scale with the number of objecttypes - with self.assertNumQueries(22): + class UpperBound(int): + # NumQueries tests for equality + def __eq__(self, value: object, /) -> bool: + match value: + case int(n): + return n < self + case _: + return super().__eq__(value) + + with self.assertNumQueries(UpperBound(40)): response = self.app.get( reverse("admin:core_object_changelist"), {"object_type__id__exact": object_type.pk}, From 27514b6adf95a9e09671117d8c34f9461e3bf194 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Mon, 16 Mar 2026 15:31:46 +0100 Subject: [PATCH 06/13] :bug: Add missing bounds check and mark a data race --- src/objects/core/admin.py | 5 +++-- src/objects/core/models.py | 7 +++++++ .../core/tests/test_objecttypeversion_generate.py | 13 +++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/objects/core/admin.py b/src/objects/core/admin.py index bbdd02b4..b659031a 100644 --- a/src/objects/core/admin.py +++ b/src/objects/core/admin.py @@ -153,10 +153,11 @@ def publish(self, request, obj): return HttpResponseRedirect(request.path) - def add_new_version(self, request, obj): + def add_new_version(self, request, obj: ObjectType): new_version = obj.last_version + assert new_version new_version.pk = None - new_version.version = new_version.version + 1 + new_version.version = None new_version.status = ObjectTypeVersionStatus.draft new_version.save() diff --git a/src/objects/core/models.py b/src/objects/core/models.py index 168e44a7..80a522eb 100644 --- a/src/objects/core/models.py +++ b/src/objects/core/models.py @@ -6,6 +6,7 @@ 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 _ @@ -139,6 +140,7 @@ class ObjectType(models.Model): ) objects = ObjectTypeQuerySet.as_manager() + versions: models.QuerySet[ObjectTypeVersion] def __str__(self): return f"{self.name}" @@ -217,6 +219,7 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) def generate_version_number(self) -> int: + # XXX: this is racing other queries! existed_versions = ObjectTypeVersion.objects.filter( object_type=self.object_type ) @@ -228,6 +231,10 @@ def generate_version_number(self) -> int: ] version_number = max_version + 1 + if version_number > 32767: # Postgrest has no USMALLINT, only SMALLINT + raise ValidationError( + _("Maximum version number 32767 has been reached for this objecttype") + ) return version_number diff --git a/src/objects/core/tests/test_objecttypeversion_generate.py b/src/objects/core/tests/test_objecttypeversion_generate.py index 49a7c52f..0523ee15 100644 --- a/src/objects/core/tests/test_objecttypeversion_generate.py +++ b/src/objects/core/tests/test_objecttypeversion_generate.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError from django.test import TestCase from objects.core.models import ObjectTypeVersion @@ -31,3 +32,15 @@ def test_generate_version_for_objecttype_with_existed_version(self): ) self.assertEqual(object_version.version, 2) + + def test_version_bounds_check(self): + object_type = ObjectTypeFactory.create() + max_sql_smallint = (1 << 15) - 1 + ObjectTypeVersionFactory.create( + object_type=object_type, version=max_sql_smallint + ) + + with self.assertRaises(ValidationError): + ObjectTypeVersion.objects.create( + json_schema=JSON_SCHEMA, object_type=object_type + ) From 1d8197ab37baa32eda1bc35eff36a9357fc88026 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Mon, 16 Mar 2026 15:32:38 +0100 Subject: [PATCH 07/13] :label: [#565] Add type annotation --- src/objects/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/objects/api/serializers.py b/src/objects/api/serializers.py index 332cdac2..d462ff9b 100644 --- a/src/objects/api/serializers.py +++ b/src/objects/api/serializers.py @@ -95,7 +95,7 @@ class LabelsField(serializers.JSONField): pass -class ObjectTypeSerializer(serializers.HyperlinkedModelSerializer): +class ObjectTypeSerializer(serializers.HyperlinkedModelSerializer[ObjectType]): labels = LabelsField( required=False, help_text=get_help_text("core.ObjectType", "labels"), From fff6882cadffbc0964f3cfd0bd156ecec8d5586a Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Mon, 16 Mar 2026 21:20:01 +0100 Subject: [PATCH 08/13] :white_check_mark: [#565] Test export action and import tool in admin --- .../tests/admin/test_objecttype_admin.py | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/src/objects/tests/admin/test_objecttype_admin.py b/src/objects/tests/admin/test_objecttype_admin.py index c9bc5ad1..f077ec66 100644 --- a/src/objects/tests/admin/test_objecttype_admin.py +++ b/src/objects/tests/admin/test_objecttype_admin.py @@ -1,5 +1,7 @@ import json +import zipfile from datetime import date +from io import BytesIO from django.urls import reverse, reverse_lazy @@ -7,6 +9,7 @@ from django_webtest import WebTest from freezegun import freeze_time from maykin_2fa.test import disable_admin_mfa +from webtest import Upload from objects.accounts.tests.factories import SuperUserFactory from objects.core.constants import ( @@ -14,6 +17,7 @@ ObjectTypeVersionStatus, UpdateFrequencyChoices, ) +from objects.core.import_export import export_data from objects.core.models import ObjectType from objects.core.tests.factories import ObjectTypeFactory, ObjectTypeVersionFactory @@ -35,6 +39,7 @@ class AdminAddTests(WebTest): url = reverse_lazy("admin:core_objecttype_add") import_from_url = reverse_lazy("admin:import_from_url") + import_from_file = reverse_lazy("admin:import_from_file") @classmethod def setUpTestData(cls): @@ -248,6 +253,123 @@ def test_create_objecttype_from_url_with_nonexistent_url(self): self.assertEqual(response.status_code, 200) self.assertEqual(ObjectType.objects.count(), 0) + def test_create_import_from_file_requires_authentication(self): + self.app.set_user(None) + import_view = self.app.get(self.import_from_file, expect_errors=True) + self.assertEqual(import_view.status_code, 302) + self.assertIn("/admin/login", import_view.location) + + def test_create_import_from_file(self): + # setup an export file for import + objecttype = ObjectTypeFactory.create() + output = BytesIO() + export_data(output, objecttypes=[objecttype]) + export_file_content = output.getvalue() + + # submit the import form with the export file + import_view = self.app.get(self.import_from_file) + form = import_view.form + form["export_file"] = Upload( + "test-export.zip", export_file_content, "application/zip" + ) + response = form.submit() + + # verify redirection after successful import + self.assertEqual(response.status_code, 302) + self.assertEqual(response.location, "/admin/core/objecttype/") + + # check that the objecttype was imported + self.assertEqual(ObjectType.objects.count(), 2) + + def test_create_import_from_file_invalid_zip(self): + objecttype = ObjectTypeFactory.create() + output = BytesIO() + export_data(output, objecttypes=[objecttype]) + # corrupt the zip content by truncating it + export_file_content = output.getvalue()[:10] + + import_view = self.app.get(self.import_from_file) + form = import_view.form + form["export_file"] = Upload( + "test-export.zip", export_file_content, "application/zip" + ) + response = form.submit() + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "is not a supported file type") + + def test_create_import_from_file_empty(self): + import_view = self.app.get(self.import_from_file) + form = import_view.form + # create an empty zip file + empty_zip = BytesIO() + with zipfile.ZipFile(empty_zip, "w") as zf: + zf.writestr("README.md", b"This is the wrong file") + empty_zip.seek(0) + + form["export_file"] = Upload("empty.zip", empty_zip.read(), "application/zip") + response = form.submit() + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Found nothing importable in that file") + + def test_create_import_from_file_validation_error(self): + objecttype = ObjectTypeFactory.create() + + # invalidate some property + objecttype.update_frequency = 1024 * "x" + + output = BytesIO() + export_data(output, objecttypes=[objecttype]) + export_file_content = output.getvalue() + + import_view = self.app.get(self.import_from_file) + form = import_view.form + form["export_file"] = Upload( + "corrupted-export.zip", export_file_content, "application/zip" + ) + response = form.submit() + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Row") + # this error is there if no other errors were found, but nothing was + # imported, because some random zip was uploaded + self.assertNotContains(response, "Found nothing importable in that file") + + def test_create_import_from_file_no_file_selected(self): + import_view = self.app.get(self.import_from_file) + form = import_view.form + response = form.submit() + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "This field is required.") + + def test_export_action(self): + objecttype1 = ObjectTypeFactory.create(name="Tree 1") + objecttype2 = ObjectTypeFactory.create(name="Tree 2") + + response = self.app.get(reverse("admin:core_objecttype_changelist")) + form = response.forms["changelist-form"] + form["action"] = "export_objecttypes_action" + form["_selected_action"] = [objecttype1.pk, objecttype2.pk] + response = form.submit() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/zip") + self.assertIn( + 'attachment; filename="objecttypes-export.zip"', + response["Content-Disposition"], + ) + + # verify the exported content + output = BytesIO(response.content) + with zipfile.ZipFile(output, "r") as zf: + self.assertIn("objectTypes.json", zf.namelist()) + + with zf.open("objectTypes.json") as f: + data = json.load(f) + self.assertEqual({d["name"] for d in data}, {"Tree 1", "Tree 2"}) + @disable_admin_mfa() class AdminDetailTests(WebTest): From ae2f045dfd57cca38dc7c15f12097e9768607146 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Tue, 17 Mar 2026 12:03:18 +0100 Subject: [PATCH 09/13] :white_check_mark: [#565] Fix coverage Queryset is never empty; django admin already checks this when performing an action. --- src/objects/core/admin.py | 8 ++--- src/objects/core/import_export.py | 15 +++++---- src/objects/core/tests/test_export_import.py | 35 +++++++++++++++++++- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/objects/core/admin.py b/src/objects/core/admin.py index b659031a..512ab150 100644 --- a/src/objects/core/admin.py +++ b/src/objects/core/admin.py @@ -226,14 +226,10 @@ def import_from_file_view(self, request: HttpRequest) -> HttpResponse: {"form": form, "source": "file"}, ) - @admin.action(description="Export selected objecttypes as a file") + @admin.action(description=_("Export selected objecttypes as a file")) def export_objecttypes_action( self, request: HttpRequest, queryset: models.QuerySet[ObjectType] - ) -> HttpResponse | None: - if not queryset.exists(): - self.message_user(request, "No objecttypes selected for export.") - return None - + ) -> HttpResponse: output = io.BytesIO() export_data(output, objecttypes=queryset) diff --git a/src/objects/core/import_export.py b/src/objects/core/import_export.py index 46c748dc..790c5b9a 100644 --- a/src/objects/core/import_export.py +++ b/src/objects/core/import_export.py @@ -114,8 +114,10 @@ def _strip_uuid(data: object): return [_strip_uuid(m) for m in members] case {"uuid": _, **rest}: return rest - case _: - return data + case _: # pragma: no cover + raise Exception( + "If you want to reuse this for whatever you're doing, make it recursive" + ) @transaction.atomic @@ -181,12 +183,13 @@ def import_upload( report_error_to_user( _("{file} is not a supported file type").format(file=file.name), ) - except Exception: + except Exception: # pragma: no cover report_error_to_user( _( "Something unexpected happened during import.\n" - "If you think the file should be correct, " - "please include the file in your bug report.\n" + "Please try again. If it fails again and you think " + "the file should be correct, please include the " + "file in your bug report.\n" ).format() ) logger.exception("unhandled import exception") @@ -197,7 +200,7 @@ def import_upload( for f in fs for resource in import_upload(f, keep_uuid, report_error_to_user) } - case _: # pragma: no-cover + case _: # pragma: no cover logger.exception("unexected type in upload", contents=file) raise SuspiciousFileOperation("Unexpected type in upload") diff --git a/src/objects/core/tests/test_export_import.py b/src/objects/core/tests/test_export_import.py index e668fc89..0a18eec2 100644 --- a/src/objects/core/tests/test_export_import.py +++ b/src/objects/core/tests/test_export_import.py @@ -1,13 +1,15 @@ import json from io import BytesIO +from django.core.files.uploadedfile import SimpleUploadedFile + import hypothesis.strategies as st import jsonschema_specifications from hypothesis import HealthCheck, given, settings from hypothesis.extra.django import TestCase, from_model from hypothesis_jsonschema import from_schema -from ..import_export import export_data, import_data +from ..import_export import export_data, import_data, import_upload from ..models import ObjectType, ObjectTypeVersion from ..utils import check_json_schema @@ -267,3 +269,34 @@ def test_export_import_with_new_uuid(self, original_type: ObjectType) -> None: assert imported_version.json_schema == original_version.json_schema assert imported_version.status == original_version.status assert imported_version.published_at == original_version.published_at + + @given(objecttypes(), objecttypes()) + @settings(suppress_health_check=[HealthCheck.too_slow]) + def test_import_upload_list_of_files(self, ot1, ot2): + output1 = BytesIO() + output2 = BytesIO() + + export_data(output1, objecttypes=[ot1]) + export_data(output2, objecttypes=[ot2]) + + file1 = SimpleUploadedFile( + "file1.zip", output1.getvalue(), content_type="application/zip" + ) + file2 = SimpleUploadedFile( + "file2.zip", output2.getvalue(), content_type="application/zip" + ) + + ObjectType.objects.all().delete() + + errors = [] + + def report_error(msg): + errors.append(msg) + + import_upload([file1, file2], keep_uuid=True, report_error_to_user=report_error) + + assert set(ObjectType.objects.all().values_list("name", flat=True)) == { + ot1.name.strip(), + ot2.name.strip(), + } + assert errors == [] From aa2fcfabafb6f46f5b85d4755ec4047f5d3df634 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Fri, 20 Mar 2026 15:11:01 +0100 Subject: [PATCH 10/13] =?UTF-8?q?=F0=9F=8E=A8=20[#565]=20Change=20=5F=5Fco?= =?UTF-8?q?ntains=5F=5F=20for=20=5F=5Feq=5F=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Steven Bal --- src/objects/core/import_export.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/objects/core/import_export.py b/src/objects/core/import_export.py index 790c5b9a..7a69c595 100644 --- a/src/objects/core/import_export.py +++ b/src/objects/core/import_export.py @@ -57,7 +57,7 @@ class ObjectTypeSerializer(_ObjectTypeSerializer): class Meta(_ObjectTypeSerializer.Meta): fields = [ - field for field in _ObjectTypeSerializer.Meta.fields if field not in ["url"] + field for field in _ObjectTypeSerializer.Meta.fields if field != "url" ] extra_kwargs = { f: kwargs | {"read_only": False} From 0820c570260d3754f7a96c73887ece4c03aebc88 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Fri, 20 Mar 2026 15:04:33 +0100 Subject: [PATCH 11/13] :lipstick: [#565] Fix alignment of errors Still not super pretty, but .aligned has a lot of left margin, which looks very off. --- .../templates/admin/core/objecttype/object_import_form.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/objects/templates/admin/core/objecttype/object_import_form.html b/src/objects/templates/admin/core/objecttype/object_import_form.html index 09e087a0..fafc5be2 100644 --- a/src/objects/templates/admin/core/objecttype/object_import_form.html +++ b/src/objects/templates/admin/core/objecttype/object_import_form.html @@ -21,7 +21,7 @@

    {% blocktrans with source=source|default:'URL' %}Import from {{ source }}{% {{ form.non_field_errors }}

    {% endif %} -
    +
    {% for field in form %}
    {{ field.errors }} From c7da3e81ff145892ffc6b1edd40c14138cbc567c Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Fri, 20 Mar 2026 15:07:58 +0100 Subject: [PATCH 12/13] :speech_balloon: [#565] Fix congruency between label and help_text --- src/objects/core/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/objects/core/forms.py b/src/objects/core/forms.py index 6ccc2f19..a2feb8b1 100644 --- a/src/objects/core/forms.py +++ b/src/objects/core/forms.py @@ -66,7 +66,7 @@ class FileImportForm(forms.Form): ) keep_uuid = forms.BooleanField( label=_("Keep the UUIDs the same"), - help_text=_("Import keeping the same UUID as in the export."), + help_text=_("Import keeping the same UUIDs as in the export."), initial=False, required=False, ) From ec33c1441102329e1981d380816281166fcb2c54 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Fri, 20 Mar 2026 16:45:30 +0100 Subject: [PATCH 13/13] :memo: [#565] Document export import of object types --- .../_assets/img/objecttype_export_import.png | Bin 0 -> 37067 bytes docs/admin/objecttype.rst | 28 ++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 docs/admin/_assets/img/objecttype_export_import.png diff --git a/docs/admin/_assets/img/objecttype_export_import.png b/docs/admin/_assets/img/objecttype_export_import.png new file mode 100644 index 0000000000000000000000000000000000000000..39fe9cea2b3ea42c6d57722d2a2d53fdd5047e39 GIT binary patch literal 37067 zcmcG$byQa2*DZ>ugmg-mNSAbjh?InMr*wChQX&E(B`wko0@5HLf*>Fb($d{sckw&p zyZ5hq#~Jtg&O3$!e0kaX+0V1qTyxH~H2kHaG{ysx2M7oV7_u^wDhLR7sSptEl%XKP zJ1jPIr0@mRQAXPZ0Ri*J?VmgG%$V>d6@skfQ#H@j&1nx$RrMQ$U6)GfSl%_clw6;I z=Zt1Mtfc36`wtn&A#fhB9P#U+8f`5Qs z_!HToDh1siJ1c8L^mPI!GcKKVXEQE+BCRsgWw0K283|>V_8}n5QGT{pnJw*mnC1titx3m#|(Kk38e%*%Q>Ybv=YXg4t|(C-c|Fm5p8~ z)RBss)SA=z#nXqyRJtr1Z7fZJKkJ09M7f>EM}ix^oR{{ZMoS4iVK7;Z4_4G&&=f(O zIXuBmZDt+eo>#rpdjC883P(aGtyCkYj=AvSR94YH>r$P4%)uwDyz2*NopV=waGO-! z+8{;apliqW>(IBt#3mN@UM`s_#QpGbZ68P%c2-hQ<~1m#dm6bWZf#gH0+=UU5v#0ai&D( zesxwE>dBR#jA=9)mf{2EqWJZQ_N+$5*(VKEj>tkvbdj>O=i>utze5bAejcQu<*?G* z#EVRH*nR3@ptsYNS#iu#X2V1uS#_02+V}O^vcakx4fSR_u2!3MulQ2R^);fNcz|QV>4=8 ztdzT(-y`qrWa#)9RXgFsV-1_#H8sTfmgy^V?}J)==Tb@D>z}clbszgb+ebbiTUwS8 zA7@qG8rEe*$-L8tX?TS4cCp-AZ9T}#Y(wQB#Z@r)<$T=JqF9$`!h{91Mjj@?(kgEk z?|DB}hBD0zoc!m!V2hgWQ6 zM{cGSpQ1Q1E^2O2s!XKnAFRhEyq2}ykSl#bLW43k7Pk&_mi9YBZ_P*I*sKDgO^4A2>Unz^rU3sd7 z>g(!OZKotoC31K_&24wKNq?G?u@qlYRlh76a_SQ`iVbh8X!=c8X^eStj8UhfEOgbU zdbu?uh$<0az>v($82;+QR`y}vt~2@CvR0nTP7kAgMuO5~Z2R}`wwJmn`aRA$e)tTu zzi*V9$eCBJS{U2SDGA?=#K8XZHkhm9OSaB0@rPlWJHj0shJJlhj4_OR(~mO*Oy-t4 zv5H9PeE8Wh_Vvo^7pW%8;J_=C5p#rUr#Mp>Q4rIAG*SE(-OCJyq|* z?@K)tcPL!f+mk~}*4l!uB)szMse`|rpf7S+md$vri&Lv#&VGm)!>xIKKXiPOSbr-e zg*=dLpyN(Lj2&^?ng8~*KyjR)Y>Hg17eCgSI z+~>D5Y&1*a?_Flefd2&x1luk;XYQZFN{pi=L_9^d^7=JCqN% zWM`W%VdrptI&aB&L_+MOr`^Xry1|f6lO`A*?wY0;Uyh*6yKZ}j^juoIT5>uVJ9&9c zrSxTQ?Ln1XCXMlUSx^K0@Y|J+Jg0(1{ef2Uif7B`^fYl^ INJ31#D3zB^Ir>UE*U{cLnLhSg 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

    `qu+TYGt!L=zGkT6Pq>z}tQH8XhJ*KJYz5 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 K%ztk1_J08*rrq!W literal 0 HcmV?d00001 diff --git a/docs/admin/objecttype.rst b/docs/admin/objecttype.rst index 3fa414b9..38582d4b 100644 --- a/docs/admin/objecttype.rst +++ b/docs/admin/objecttype.rst @@ -128,3 +128,31 @@ if you click on the "history" button in the top right corner of the object type :alt: Show the history of changes. You can see all the versions, their statuses, the creation dates and the related JSON shemas. + + +Export and import object types +------------------------------ + +To export object types including their history, select the desired object types in the Objecttypes changelist +dashboard, then use the "Export selected objecttypes as a file" action from the Action dropdown menu and click +"Go". This will download an archive containing the selected object types with all versions. + +To import object types, navigate to the Objecttypes changelist and select the "Import from file" action. +Upload the archive previously exported, optionally keeping the original UUIDs by checking the +corresponding checkbox. After successful import, you will see a confirmation message. + +.. image:: _assets/img/objecttype_export_import.png + :alt: Export and import + + +.. note:: + If UUIDs are kept during import, existing object types with matching UUIDs should **not** exist. + You cannot overwrite existing objects types. If no objects exist for the types, you should be able to + delete the object type and proceed with importing. + + If UUIDs are not kept, new UUIDs will be generated and existing object types with the same name + will not be overwritten. + +.. note:: + When importing multiple object types, either the whole import succeeds, or the whole import fails. + There are no partial imports.