diff --git a/docs/admin/object.rst b/docs/admin/object.rst index e23f4254..2712e0b2 100644 --- a/docs/admin/object.rst +++ b/docs/admin/object.rst @@ -64,3 +64,35 @@ corrected in the "Correction" field of the next record. In the Objects API you always see one record, which contains data of a certain time (by default the latest one). However in the admin interface you can see all the records created for the object. + + +Search objects in the admin +--------------------------- + +You can search by **UUID** or inside object data using the format: + +.. code-block:: text + + field__operator__value + +Operators: + +- ``exact`` - exact match +- ``icontains`` - case insensitive substring match +- ``gt`` - greater than +- ``gte`` - greater than or equal to +- ``lt`` - less than +- ``lte`` - less than or equal to + +Examples: + +- ``0233da1f-32c1-4e7d-9896-2eecc7d24288`` - searching directly by object UUID +- ``id__exact__1`` +- ``naam__icontains__boom`` +- ``date__gt__2025-01-01`` +- ``date__gte__2025-06-15`` +- ``date__lt__2025-12-31`` +- ``date__lte__2025-06-15`` +- ``location__city__exact__Amsterdam`` +- ``location__region__icontains__Noord`` + diff --git a/docs/examples/objecttype-boom.rst b/docs/examples/objecttype-boom.rst index fdbaae64..743be797 100644 --- a/docs/examples/objecttype-boom.rst +++ b/docs/examples/objecttype-boom.rst @@ -12,7 +12,7 @@ The "Boom" objecttype is based on the open source `Gemeentelijk Gegevensmodel`_ A `small script`_ was used to convert the GGM EAP model to JSON schema. .. _`Gemeentelijk Gegevensmodel`: https://github.com/Gemeente-Delft/Gemeentelijk-Gegevensmodel -.. _`Informatiemodel Beheer Openbare Ruimte`: https://www.crow.nl/Onderwerpen/Assetmanagement-en-beheer-openbare-ruimte/Data-en-informatie/imbor-de-standaard-voor-beheer-van-de-openbare-ruimte +.. _`Informatiemodel Beheer Openbare Ruimte`: https://www.crow.nl/Onderwerpen/assetmanagement-en-beheer-openbare-ruimte/Data-en-informatie/imbor-de-standaard-voor-beheer-van-de-openbare-ruimte .. _`Basisregistratie Grootschalige Topografie`: https://www.kadaster.nl/zakelijk/registraties/basisregistraties/bgt .. _`Informatiemodel geografie`: https://www.geonovum.nl/geo-standaarden/bgt-imgeo .. _`small script`: https://github.com/maykinmedia/imvertor-lite diff --git a/src/objects/api/v2/filters.py b/src/objects/api/v2/filters.py index 25aac9c1..e97b851f 100644 --- a/src/objects/api/v2/filters.py +++ b/src/objects/api/v2/filters.py @@ -78,12 +78,19 @@ def build_nested_dict(path: str, value: Any) -> dict[str, Any]: return nested -def filter_data_attr_value_part(value_part: str, queryset: QuerySet) -> QuerySet: +def filter_queryset_by_data_attr( + queryset: QuerySet, + key: str, + operator: str, + str_value: str, + field_prefix: str, +) -> QuerySet: """ - filter one value part for data_attr and data_attrs filters + Generic filter helper. + Can be used in API FilterSet or Admin search. """ - variable, operator, str_value = value_part.rsplit("__", 2) real_value = string_to_value(str_value) + full_field = f"{field_prefix}__{key}" if key else field_prefix if operator == "exact": # for exact operator try to filter on string and numeric values @@ -93,26 +100,42 @@ def filter_data_attr_value_part(value_part: str, queryset: QuerySet) -> QuerySet query = Q() for val in in_vals: - nested_dict = build_nested_dict(variable, val) - query |= Q(data__contains=nested_dict) + nested_dict = build_nested_dict(key, val) + query |= Q(**{f"{field_prefix}__contains": nested_dict}) # Make sure containment operator is used (`@>`) via __contains, to ensure the # GINIndex on `data` is utilized queryset = queryset.filter(query) + elif operator == "icontains": - # icontains treats everything like strings - queryset = queryset.filter(**{f"data__{variable}__icontains": str_value}) + queryset = queryset.filter(**{f"{full_field}__icontains": str_value}) + elif operator == "in": # in must be a list values = str_value.split("|") - queryset = queryset.filter(**{f"data__{variable}__in": values}) + queryset = queryset.filter(**{f"{full_field}__in": values}) + + elif operator in ("gt", "gte", "lt", "lte"): + queryset = queryset.filter(**{f"{full_field}__{operator}": real_value}) else: - # gt, gte, lt, lte operators - queryset = queryset.filter(**{f"data__{variable}__{operator}": real_value}) + queryset = queryset.filter(**{f"{full_field}__icontains": str_value}) + return queryset +def filter_data_attr_value_part( + value_part: str, queryset: QuerySet, field_prefix: str = "data" +) -> QuerySet: + """ + Wrapper to handle a single value part of data_attr or data_attrs. + """ + key, operator, str_value = value_part.rsplit("__", 2) + return filter_queryset_by_data_attr( + queryset, key, operator, str_value, field_prefix=field_prefix + ) + + class ObjectRecordFilterForm(forms.Form): def clean(self): cleaned_data = super().clean() diff --git a/src/objects/core/admin.py b/src/objects/core/admin.py index 1ba7977d..9fd84cef 100644 --- a/src/objects/core/admin.py +++ b/src/objects/core/admin.py @@ -7,11 +7,13 @@ from django.contrib.gis.db.models import GeometryField from django.http import HttpRequest, JsonResponse from django.urls import path +from django.utils.translation import gettext_lazy as _ import requests import structlog 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 @@ -141,7 +143,7 @@ class ObjectAdmin(admin.ModelAdmin): "modified_on", "created_on", ) - search_fields = ("uuid", "records__data") + search_fields = ("uuid",) inlines = (ObjectRecordInline,) list_filter = (ObjectTypeFilter, "created_on", "modified_on") @@ -149,10 +151,52 @@ def get_search_fields(self, request: HttpRequest) -> Sequence[str]: if settings.OBJECTS_ADMIN_SEARCH_DISABLED: return () - return ( - "uuid", - "records__data", + return ("uuid",) + + change_list_template = "admin/core/object_change_list.html" + + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + extra_context["toggle_show"] = _("Show search instructions") + extra_context["toggle_hide"] = _("Hide search instructions") + extra_context["search_enabled"] = bool(self.get_search_fields(request)) + return super().changelist_view(request, extra_context=extra_context) + + def get_search_results(self, request, queryset, search_term): + VALID_OPERATORS = {"exact", "icontains", "in", "gt", "gte", "lt", "lte"} + DEFAULT_OPERATOR = "icontains" + + if settings.OBJECTS_ADMIN_SEARCH_DISABLED: + return queryset, False + + if "__" not in search_term: + return super().get_search_results(request, queryset, search_term) + + parts = search_term.rsplit("__", 2) + + if len(parts) == 3 and parts[1] in VALID_OPERATORS: + key, operator, str_value = parts + elif len(parts) == 3: + key = "__".join(parts[:-1]) + operator = DEFAULT_OPERATOR + str_value = parts[-1] + elif len(parts) == 2: + key, str_value = parts + operator = DEFAULT_OPERATOR + else: + return super().get_search_results(request, queryset, search_term) + + if not key or not str_value: + return super().get_search_results(request, queryset, search_term) + + queryset = filter_queryset_by_data_attr( + queryset, + key.strip(), + operator, + str_value.strip(), + field_prefix="records__data", ) + return queryset.distinct(), False @admin.display(description="Object type UUID") def get_object_type_uuid(self, obj): diff --git a/src/objects/core/tests/test_admin.py b/src/objects/core/tests/test_admin.py index edd78eab..d09fd4fe 100644 --- a/src/objects/core/tests/test_admin.py +++ b/src/objects/core/tests/test_admin.py @@ -1,3 +1,5 @@ +import re + from django.test import override_settings, tag from django.urls import reverse @@ -72,7 +74,9 @@ def get_num_results(response) -> int: self.assertIsNotNone(response.html.find("input", {"id": "searchbar"})) - response = self.app.get(list_url, params={"q": "bar"}, user=self.user) + response = self.app.get( + list_url, params={"q": "foo__icontains__bar"}, user=self.user + ) self.assertEqual(get_num_results(response), 1) @@ -126,3 +130,119 @@ def test_add_new_objectrecord(self): response = form.submit() self.assertEqual(object.records.count(), 1) + + @tag("gh-621") + def test_object_admin_search_json_key_operator_value(self): + object1 = ObjectFactory() + ObjectRecordFactory( + object=object1, + data={"id_nummer": 1, "naam": "Boomgaard", "plantDate": "2025-01-01"}, + ) + object2 = ObjectFactory() + ObjectRecordFactory( + object=object2, + data={"id_nummer": 2, "naam": "Appelboom", "plantDate": "2025-06-15"}, + ) + object3 = ObjectFactory() + ObjectRecordFactory( + object=object3, + data={"id_nummer": 3, "naam": "Peren", "plantDate": "2025-12-31"}, + ) + object4 = ObjectFactory() + ObjectRecordFactory( + object=object4, + data={ + "id_nummer": 4, + "naam": "Kersen", + "plantDate": "2025-07-20", + "location": {"city": "Amsterdam", "region": "Noord-Holland"}, + }, + ) + + list_url = reverse("admin:core_object_changelist") + + def get_row_pks(response): + rows = response.html.select("#result_list tbody tr") + pks = [] + for row in rows: + href = row.select_one("th a")["href"] + pks.append(int(re.search(r"\d+", href).group())) + return pks + + with self.subTest("Exact match"): + response = self.app.get( + list_url, params={"q": "id_nummer__exact__1"}, user=self.user + ) + self.assertEqual(get_row_pks(response), [object1.pk]) + + with self.subTest("Nested JSON value match"): + response = self.app.get( + list_url, + params={"q": "location__city__exact__Amsterdam"}, + user=self.user, + ) + self.assertEqual(get_row_pks(response), [object4.pk]) + + with self.subTest("Nested"): + response = self.app.get( + list_url, + params={"q": "location__city__Amsterdam"}, + user=self.user, + ) + self.assertEqual(get_row_pks(response), [object4.pk]) + + with self.subTest("icontains"): + response = self.app.get( + list_url, params={"q": "naam__icontains__boom"}, user=self.user + ) + self.assertCountEqual(get_row_pks(response), [object1.pk, object2.pk]) + + with self.subTest("Default operator"): + response = self.app.get( + list_url, params={"q": "naam__Boomgaard"}, user=self.user + ) + self.assertEqual(get_row_pks(response), [object1.pk]) + + with self.subTest("Numeric comparison gt"): + response = self.app.get( + list_url, params={"q": "id_nummer__gt__1"}, user=self.user + ) + self.assertCountEqual( + get_row_pks(response), [object2.pk, object3.pk, object4.pk] + ) + + with self.subTest("Date exact"): + response = self.app.get( + list_url, params={"q": "plantDate__exact__2025-06-15"}, user=self.user + ) + self.assertEqual(get_row_pks(response), [object2.pk]) + + with self.subTest("Date gt"): + response = self.app.get( + list_url, params={"q": "plantDate__gt__2025-01-01"}, user=self.user + ) + self.assertCountEqual( + get_row_pks(response), [object2.pk, object3.pk, object4.pk] + ) + + with self.subTest("Date lt"): + response = self.app.get( + list_url, params={"q": "plantDate__lt__2025-12-01"}, user=self.user + ) + self.assertCountEqual( + get_row_pks(response), [object1.pk, object2.pk, object4.pk] + ) + + with self.subTest("Date comparison gte"): + response = self.app.get( + list_url, params={"q": "plantDate__gte__2025-06-15"}, user=self.user + ) + self.assertCountEqual( + get_row_pks(response), [object2.pk, object3.pk, object4.pk] + ) + + with self.subTest("Date comparison lte"): + response = self.app.get( + list_url, params={"q": "plantDate__lte__2025-06-15"}, user=self.user + ) + self.assertCountEqual(get_row_pks(response), [object1.pk, object2.pk]) diff --git a/src/objects/js/components/admin/permissions/index.js b/src/objects/js/components/admin/permissions/index.js index af810d48..bb3d5258 100644 --- a/src/objects/js/components/admin/permissions/index.js +++ b/src/objects/js/components/admin/permissions/index.js @@ -18,7 +18,7 @@ const mount = () => { formData={jsonScriptToVar('form-data')} />, ); + }; - mount(); diff --git a/src/objects/js/components/admin/search-toggle.js b/src/objects/js/components/admin/search-toggle.js new file mode 100644 index 00000000..2e912609 --- /dev/null +++ b/src/objects/js/components/admin/search-toggle.js @@ -0,0 +1,23 @@ +function mountSearchHelpToggle() { + const toggleLink = document.getElementById("toggle-search-help"); + const content = document.getElementById("search-help-content"); + + if (!toggleLink || !content) return; + + const showText = toggleLink.dataset.showText; + const hideText = toggleLink.dataset.hideText; + + toggleLink.addEventListener("click", function (e) { + e.preventDefault(); + + if (content.style.display === "none" || content.style.display === "") { + content.style.display = "block"; + toggleLink.textContent = hideText; + } else { + content.style.display = "none"; + toggleLink.textContent = showText; + } + }); +} + +document.addEventListener("DOMContentLoaded", mountSearchHelpToggle); diff --git a/src/objects/js/components/index.js b/src/objects/js/components/index.js index 76bdb41e..6482b450 100644 --- a/src/objects/js/components/index.js +++ b/src/objects/js/components/index.js @@ -1,3 +1,5 @@ // Use this file to include individual components. import './admin/permissions'; +import './admin/search-toggle'; import './nav/'; + diff --git a/src/objects/templates/admin/core/object_change_list.html b/src/objects/templates/admin/core/object_change_list.html new file mode 100644 index 00000000..eadc28d8 --- /dev/null +++ b/src/objects/templates/admin/core/object_change_list.html @@ -0,0 +1,53 @@ +{% extends "admin/change_list.html" %} +{% load i18n %} +{% block search %} + +{% if search_enabled %} +
+
+ + + {% blocktranslate %}Search instructions{% endblocktranslate %} + + + +
+{% endif %} + +{{ block.super }} +{% endblock %} \ No newline at end of file