From 4abab64b0c9472e70068442ec37a62ba4a2bef56 Mon Sep 17 00:00:00 2001 From: Tim de Beer Date: Wed, 12 Nov 2025 11:33:39 +0100 Subject: [PATCH 01/13] :zap: [#621] add key value search to data --- src/objects/core/admin.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/objects/core/admin.py b/src/objects/core/admin.py index 1ba7977d..452f17fb 100644 --- a/src/objects/core/admin.py +++ b/src/objects/core/admin.py @@ -5,6 +5,9 @@ from django.contrib import admin from django.contrib.admin import SimpleListFilter from django.contrib.gis.db.models import GeometryField +from django.db.models import CharField +from django.db.models.fields.json import KeyTextTransform +from django.db.models.functions import Cast from django.http import HttpRequest, JsonResponse from django.urls import path @@ -141,7 +144,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 +152,26 @@ def get_search_fields(self, request: HttpRequest) -> Sequence[str]: if settings.OBJECTS_ADMIN_SEARCH_DISABLED: return () - return ( - "uuid", - "records__data", - ) + return ("uuid",) + + def get_search_results(self, request, queryset, search_term): + if settings.OBJECTS_ADMIN_SEARCH_DISABLED: + return queryset, False + + if ":" in search_term: + key, _, value = search_term.partition(":") + key = key.strip() + value = value.strip() + + queryset = queryset.filter(records__data__has_key=key) + + queryset = queryset.annotate( + key_text=Cast(KeyTextTransform(key, "records__data"), CharField()) + ).filter(key_text__icontains=value) + + return queryset.distinct(), False + + return super().get_search_results(request, queryset, search_term) @admin.display(description="Object type UUID") def get_object_type_uuid(self, obj): From ba7f4e192c514478787ff5172f117b76ee6de5a5 Mon Sep 17 00:00:00 2001 From: Tim de Beer Date: Wed, 12 Nov 2025 12:01:21 +0100 Subject: [PATCH 02/13] :white_check_mark: [#621] update tests --- src/objects/core/tests/test_admin.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/objects/core/tests/test_admin.py b/src/objects/core/tests/test_admin.py index edd78eab..239b2893 100644 --- a/src/objects/core/tests/test_admin.py +++ b/src/objects/core/tests/test_admin.py @@ -72,7 +72,7 @@ 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:bar"}, user=self.user) self.assertEqual(get_num_results(response), 1) @@ -84,7 +84,9 @@ def get_num_results(response) -> int: self.assertIsNone(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:bar"}, user=self.user + ) self.assertEqual(get_num_results(response), 2) From 6ae851c9bd873d09570ee6135ff8c33dd52b1bac Mon Sep 17 00:00:00 2001 From: Tim de Beer Date: Thu, 13 Nov 2025 15:46:10 +0100 Subject: [PATCH 03/13] :recycle: [#621] refactor logic and added tests --- src/objects/core/admin.py | 59 +++++++++++++++++---- src/objects/core/tests/test_admin.py | 76 +++++++++++++++++++++++++++- 2 files changed, 122 insertions(+), 13 deletions(-) diff --git a/src/objects/core/admin.py b/src/objects/core/admin.py index 452f17fb..5884ad6b 100644 --- a/src/objects/core/admin.py +++ b/src/objects/core/admin.py @@ -1,3 +1,4 @@ +from datetime import date from typing import Sequence from django import forms @@ -5,7 +6,7 @@ from django.contrib import admin from django.contrib.admin import SimpleListFilter from django.contrib.gis.db.models import GeometryField -from django.db.models import CharField +from django.db.models import CharField, DateField, FloatField, Q from django.db.models.fields.json import KeyTextTransform from django.db.models.functions import Cast from django.http import HttpRequest, JsonResponse @@ -15,6 +16,8 @@ import structlog from vng_api_common.utils import get_help_text +from objects.api.utils import string_to_value +from objects.api.v2.filters import build_nested_dict from objects.utils.client import get_objecttypes_client from .models import Object, ObjectRecord, ObjectType @@ -158,20 +161,54 @@ def get_search_results(self, request, queryset, search_term): if settings.OBJECTS_ADMIN_SEARCH_DISABLED: return queryset, False - if ":" in search_term: - key, _, value = search_term.partition(":") - key = key.strip() - value = value.strip() - - queryset = queryset.filter(records__data__has_key=key) + if "__" not in search_term: + return super().get_search_results(request, queryset, search_term) + + parts = search_term.split("__", 2) + if len(parts) == 3: + key, operator, str_value = parts + elif len(parts) == 2: + key, str_value = parts + operator = "icontains" + else: + return super().get_search_results(request, queryset, search_term) + + key, str_value = key.strip(), str_value.strip() + value = string_to_value(str_value) + + if operator == "exact": + in_vals = [str_value] + if value != str_value: + in_vals.append(value) + + query = Q() + for val in in_vals: + nested_dict = build_nested_dict(key, val) + query |= Q(records__data__contains=nested_dict) + queryset = queryset.filter(query) + else: + cast_field = CharField() + if isinstance(value, (int, float)): + cast_field = FloatField() + elif isinstance(value, date): + cast_field = DateField() queryset = queryset.annotate( - key_text=Cast(KeyTextTransform(key, "records__data"), CharField()) - ).filter(key_text__icontains=value) + key_value=Cast(KeyTextTransform(key, "records__data"), cast_field) + ) - return queryset.distinct(), False + if operator == "icontains": + queryset = queryset.filter(key_value__icontains=str_value) + elif operator == "in": + if isinstance(value, str): + value = value.split("|") + queryset = queryset.filter(key_value__in=value) + elif operator in ("gt", "gte", "lt", "lte"): + queryset = queryset.filter(**{f"key_value__{operator}": value}) + else: + queryset = queryset.filter(key_value__icontains=str_value) - return super().get_search_results(request, queryset, search_term) + 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 239b2893..94744f91 100644 --- a/src/objects/core/tests/test_admin.py +++ b/src/objects/core/tests/test_admin.py @@ -72,7 +72,7 @@ def get_num_results(response) -> int: self.assertIsNotNone(response.html.find("input", {"id": "searchbar"})) - response = self.app.get(list_url, params={"q": "foo:bar"}, user=self.user) + response = self.app.get(list_url, params={"q": "foo__bar"}, user=self.user) self.assertEqual(get_num_results(response), 1) @@ -85,7 +85,7 @@ def get_num_results(response) -> int: self.assertIsNone(response.html.find("input", {"id": "searchbar"})) response = self.app.get( - list_url, params={"q": "foo:bar"}, user=self.user + list_url, params={"q": "foo__bar"}, user=self.user ) self.assertEqual(get_num_results(response), 2) @@ -128,3 +128,75 @@ 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"}, + ) + + list_url = reverse("admin:core_object_changelist") + + def get_num_results(response) -> int: + result_list = response.html.find("table", {"id": "result_list"}) + return len(result_list.find("tbody").find_all("tr")) + + with self.subTest("Exact match"): + response = self.app.get( + list_url, params={"q": "id_nummer__exact__1"}, user=self.user + ) + self.assertEqual(get_num_results(response), 1) + + with self.subTest("icontains"): + response = self.app.get( + list_url, params={"q": "naam__icontains__boom"}, user=self.user + ) + self.assertEqual(get_num_results(response), 2) + + with self.subTest("Default operator"): + response = self.app.get( + list_url, params={"q": "naam__Boomgaard"}, user=self.user + ) + self.assertEqual(get_num_results(response), 1) + + with self.subTest("Numeric comparison gt"): + response = self.app.get( + list_url, params={"q": "id_nummer__gt__1"}, user=self.user + ) + self.assertEqual(get_num_results(response), 2) + + with self.subTest("IN operator"): + response = self.app.get( + list_url, params={"q": "id_nummer__in__1|3"}, user=self.user + ) + self.assertEqual(get_num_results(response), 2) + + with self.subTest("Date exact"): + response = self.app.get( + list_url, params={"q": "plantDate__exact__2025-06-15"}, user=self.user + ) + self.assertEqual(get_num_results(response), 1) + + with self.subTest("Date gt"): + response = self.app.get( + list_url, params={"q": "plantDate__gt__2025-01-01"}, user=self.user + ) + self.assertEqual(get_num_results(response), 2) + + with self.subTest("Date lt"): + response = self.app.get( + list_url, params={"q": "plantDate__lt__2025-12-01"}, user=self.user + ) + self.assertEqual(get_num_results(response), 2) From 60bc5ff305c66b4da7fbdb86d694ab2fa602ad3e Mon Sep 17 00:00:00 2001 From: Tim de Beer Date: Tue, 18 Nov 2025 16:45:01 +0100 Subject: [PATCH 04/13] :recycle: [#621] refactor admin with search info --- src/objects/core/admin.py | 4 +- src/objects/core/tests/test_admin.py | 34 ++++++++++++++++- src/objects/js/components/admin/index.js | 6 +++ .../js/components/admin/permissions/index.js | 2 +- .../js/components/admin/search-toggle.js | 38 +++++++++++++++++++ src/objects/js/components/index.js | 2 + .../admin/core/object_change_list.html | 29 ++++++++++++++ 7 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 src/objects/js/components/admin/index.js create mode 100644 src/objects/js/components/admin/search-toggle.js create mode 100644 src/objects/templates/admin/core/object_change_list.html diff --git a/src/objects/core/admin.py b/src/objects/core/admin.py index 5884ad6b..d1a5842a 100644 --- a/src/objects/core/admin.py +++ b/src/objects/core/admin.py @@ -151,6 +151,8 @@ class ObjectAdmin(admin.ModelAdmin): inlines = (ObjectRecordInline,) list_filter = (ObjectTypeFilter, "created_on", "modified_on") + change_list_template = "admin/core/object_change_list.html" + def get_search_fields(self, request: HttpRequest) -> Sequence[str]: if settings.OBJECTS_ADMIN_SEARCH_DISABLED: return () @@ -164,7 +166,7 @@ def get_search_results(self, request, queryset, search_term): if "__" not in search_term: return super().get_search_results(request, queryset, search_term) - parts = search_term.split("__", 2) + parts = search_term.rsplit("__", 2) if len(parts) == 3: key, operator, str_value = parts elif len(parts) == 2: diff --git a/src/objects/core/tests/test_admin.py b/src/objects/core/tests/test_admin.py index 94744f91..296a00f5 100644 --- a/src/objects/core/tests/test_admin.py +++ b/src/objects/core/tests/test_admin.py @@ -146,6 +146,16 @@ def test_object_admin_search_json_key_operator_value(self): 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") @@ -159,6 +169,14 @@ def get_num_results(response) -> int: ) self.assertEqual(get_num_results(response), 1) + 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_num_results(response), 1) + with self.subTest("icontains"): response = self.app.get( list_url, params={"q": "naam__icontains__boom"}, user=self.user @@ -175,7 +193,7 @@ def get_num_results(response) -> int: response = self.app.get( list_url, params={"q": "id_nummer__gt__1"}, user=self.user ) - self.assertEqual(get_num_results(response), 2) + self.assertEqual(get_num_results(response), 3) with self.subTest("IN operator"): response = self.app.get( @@ -193,10 +211,22 @@ def get_num_results(response) -> int: response = self.app.get( list_url, params={"q": "plantDate__gt__2025-01-01"}, user=self.user ) - self.assertEqual(get_num_results(response), 2) + self.assertEqual(get_num_results(response), 3) with self.subTest("Date lt"): response = self.app.get( list_url, params={"q": "plantDate__lt__2025-12-01"}, user=self.user ) + self.assertEqual(get_num_results(response), 3) + + with self.subTest("Date comparison gte"): + response = self.app.get( + list_url, params={"q": "plantDate__gte__2025-06-15"}, user=self.user + ) + self.assertEqual(get_num_results(response), 3) + + with self.subTest("Date comparison lte"): + response = self.app.get( + list_url, params={"q": "plantDate__lte__2025-06-15"}, user=self.user + ) self.assertEqual(get_num_results(response), 2) diff --git a/src/objects/js/components/admin/index.js b/src/objects/js/components/admin/index.js new file mode 100644 index 00000000..e3e14ef7 --- /dev/null +++ b/src/objects/js/components/admin/index.js @@ -0,0 +1,6 @@ +import React from "react"; +import { mountSearchHelpToggle } from "./search-toggle"; + +document.addEventListener("DOMContentLoaded", () => { + mountSearchHelpToggle(); +}); \ No newline at end of file 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..5027f2cb --- /dev/null +++ b/src/objects/js/components/admin/search-toggle.js @@ -0,0 +1,38 @@ +import React, { useEffect } from "react"; +import { createRoot } from "react-dom/client"; + +const SearchHelpToggle = () => { + useEffect(() => { + const toggleLink = document.getElementById("toggle-search-help"); + const content = document.getElementById("search-help-content"); + + if (!toggleLink || !content) return; + + const handleClick = (e) => { + e.preventDefault(); + if (content.style.display === "none" || content.style.display === "") { + content.style.display = "block"; + toggleLink.textContent = "Hide search information"; + } else { + content.style.display = "none"; + toggleLink.textContent = "Show search information"; + } + }; + + toggleLink.addEventListener("click", handleClick); + + return () => toggleLink.removeEventListener("click", handleClick); + }, []); + + return null; +}; + +const mountSearchHelpToggle = () => { + const node = document.getElementById("search-help-toggle-root"); + if (!node) return; + + const root = createRoot(node); + root.render(); +}; + +export { mountSearchHelpToggle }; diff --git a/src/objects/js/components/index.js b/src/objects/js/components/index.js index 76bdb41e..2ddc5c5f 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/'; import './admin/permissions'; 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..44137bea --- /dev/null +++ b/src/objects/templates/admin/core/object_change_list.html @@ -0,0 +1,29 @@ +{% extends "admin/change_list.html" %} + +{% block search %} +
+
{# React mount point #} + Search information + +
+ +{{ block.super }} +{% endblock %} \ No newline at end of file From 04de2ea70296d4e20ce4a894b4c03fa5b32bbc6a Mon Sep 17 00:00:00 2001 From: Tim de Beer Date: Wed, 19 Nov 2025 09:47:34 +0100 Subject: [PATCH 05/13] :white_check_mark: [#621] update tests to check pk --- src/objects/core/tests/test_admin.py | 50 ++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/src/objects/core/tests/test_admin.py b/src/objects/core/tests/test_admin.py index 296a00f5..896b88cb 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 @@ -159,15 +161,19 @@ def test_object_admin_search_json_key_operator_value(self): list_url = reverse("admin:core_object_changelist") - def get_num_results(response) -> int: - result_list = response.html.find("table", {"id": "result_list"}) - return len(result_list.find("tbody").find_all("tr")) + 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_num_results(response), 1) + self.assertEqual(get_row_pks(response), [object1.pk]) with self.subTest("Nested JSON value match"): response = self.app.get( @@ -175,58 +181,74 @@ def get_num_results(response) -> int: params={"q": "location__city__exact__Amsterdam"}, user=self.user, ) - self.assertEqual(get_num_results(response), 1) + 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.assertEqual(get_num_results(response), 2) + 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_num_results(response), 1) + 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.assertEqual(get_num_results(response), 3) + self.assertCountEqual( + get_row_pks(response), [object2.pk, object3.pk, object4.pk] + ) with self.subTest("IN operator"): response = self.app.get( list_url, params={"q": "id_nummer__in__1|3"}, user=self.user ) - self.assertEqual(get_num_results(response), 2) + self.assertCountEqual(get_row_pks(response), [object1.pk, object3.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_num_results(response), 1) + 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.assertEqual(get_num_results(response), 3) + 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.assertEqual(get_num_results(response), 3) + 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.assertEqual(get_num_results(response), 3) + 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.assertEqual(get_num_results(response), 2) + self.assertCountEqual(get_row_pks(response), [object1.pk, object2.pk]) From ea3399b37947d93abd5d54da8a09ae5a49c7bde2 Mon Sep 17 00:00:00 2001 From: Tim de Beer Date: Wed, 19 Nov 2025 11:39:15 +0100 Subject: [PATCH 06/13] :recycle: [#621] reuse filters --- docker-compose.yml | 5 +- src/objects/api/v2/filters.py | 43 ++++++++++---- src/objects/core/admin.py | 58 +++++-------------- src/objects/js/components/admin/index.js | 6 -- .../js/components/admin/search-toggle.js | 58 +++++++------------ src/objects/js/components/index.js | 2 +- 6 files changed, 73 insertions(+), 99 deletions(-) delete mode 100644 src/objects/js/components/admin/index.js diff --git a/docker-compose.yml b/docker-compose.yml index 7c92a589..061d60e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,8 +81,9 @@ services: objecttypes-web: image: maykinmedia/objecttypes-api:latest environment: &app-env - DB_NAME: objecttypes - DB_USER: objecttypes + DB_NAME: postgres + DB_USER: objects + DB_PASSWORD: objects DJANGO_SETTINGS_MODULE: objecttypes.conf.docker SECRET_KEY: ${SECRET_KEY:-fgv=c0hz&tl*8*3m3893@m+1pstrvidc9e^5@fpspmg%cyf15d} ALLOWED_HOSTS: '*' 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 d1a5842a..07094636 100644 --- a/src/objects/core/admin.py +++ b/src/objects/core/admin.py @@ -1,4 +1,3 @@ -from datetime import date from typing import Sequence from django import forms @@ -6,9 +5,6 @@ from django.contrib import admin from django.contrib.admin import SimpleListFilter from django.contrib.gis.db.models import GeometryField -from django.db.models import CharField, DateField, FloatField, Q -from django.db.models.fields.json import KeyTextTransform -from django.db.models.functions import Cast from django.http import HttpRequest, JsonResponse from django.urls import path @@ -16,8 +12,7 @@ import structlog from vng_api_common.utils import get_help_text -from objects.api.utils import string_to_value -from objects.api.v2.filters import build_nested_dict +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 @@ -160,6 +155,8 @@ def get_search_fields(self, request: HttpRequest) -> Sequence[str]: return ("uuid",) def get_search_results(self, request, queryset, search_term): + VALID_OPERATORS = {"exact", "icontains", "in", "gt", "gte", "lt", "lte"} + if settings.OBJECTS_ADMIN_SEARCH_DISABLED: return queryset, False @@ -167,49 +164,26 @@ def get_search_results(self, request, queryset, search_term): return super().get_search_results(request, queryset, search_term) parts = search_term.rsplit("__", 2) - if len(parts) == 3: + + if len(parts) == 3 and parts[1] in VALID_OPERATORS: key, operator, str_value = parts + elif len(parts) == 3: + key = "__".join(parts[:-1]) + operator = "exact" + str_value = parts[-1] elif len(parts) == 2: key, str_value = parts operator = "icontains" else: return super().get_search_results(request, queryset, search_term) - key, str_value = key.strip(), str_value.strip() - value = string_to_value(str_value) - - if operator == "exact": - in_vals = [str_value] - if value != str_value: - in_vals.append(value) - - query = Q() - for val in in_vals: - nested_dict = build_nested_dict(key, val) - query |= Q(records__data__contains=nested_dict) - queryset = queryset.filter(query) - else: - cast_field = CharField() - if isinstance(value, (int, float)): - cast_field = FloatField() - elif isinstance(value, date): - cast_field = DateField() - - queryset = queryset.annotate( - key_value=Cast(KeyTextTransform(key, "records__data"), cast_field) - ) - - if operator == "icontains": - queryset = queryset.filter(key_value__icontains=str_value) - elif operator == "in": - if isinstance(value, str): - value = value.split("|") - queryset = queryset.filter(key_value__in=value) - elif operator in ("gt", "gte", "lt", "lte"): - queryset = queryset.filter(**{f"key_value__{operator}": value}) - else: - queryset = queryset.filter(key_value__icontains=str_value) - + 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") diff --git a/src/objects/js/components/admin/index.js b/src/objects/js/components/admin/index.js deleted file mode 100644 index e3e14ef7..00000000 --- a/src/objects/js/components/admin/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import React from "react"; -import { mountSearchHelpToggle } from "./search-toggle"; - -document.addEventListener("DOMContentLoaded", () => { - mountSearchHelpToggle(); -}); \ No newline at end of file diff --git a/src/objects/js/components/admin/search-toggle.js b/src/objects/js/components/admin/search-toggle.js index 5027f2cb..d2f16b8f 100644 --- a/src/objects/js/components/admin/search-toggle.js +++ b/src/objects/js/components/admin/search-toggle.js @@ -1,38 +1,20 @@ -import React, { useEffect } from "react"; -import { createRoot } from "react-dom/client"; - -const SearchHelpToggle = () => { - useEffect(() => { - const toggleLink = document.getElementById("toggle-search-help"); - const content = document.getElementById("search-help-content"); - - if (!toggleLink || !content) return; - - const handleClick = (e) => { - e.preventDefault(); - if (content.style.display === "none" || content.style.display === "") { - content.style.display = "block"; - toggleLink.textContent = "Hide search information"; - } else { - content.style.display = "none"; - toggleLink.textContent = "Show search information"; - } - }; - - toggleLink.addEventListener("click", handleClick); - - return () => toggleLink.removeEventListener("click", handleClick); - }, []); - - return null; -}; - -const mountSearchHelpToggle = () => { - const node = document.getElementById("search-help-toggle-root"); - if (!node) return; - - const root = createRoot(node); - root.render(); -}; - -export { mountSearchHelpToggle }; +function mountSearchHelpToggle() { + const toggleLink = document.getElementById("toggle-search-help"); + const content = document.getElementById("search-help-content"); + + if (!toggleLink || !content) return; + + toggleLink.addEventListener("click", function (e) { + e.preventDefault(); + + if (content.style.display === "none" || content.style.display === "") { + content.style.display = "block"; + toggleLink.textContent = "Hide search information"; + } else { + content.style.display = "none"; + toggleLink.textContent = "Show search information"; + } + }); +} + +document.addEventListener("DOMContentLoaded", mountSearchHelpToggle); diff --git a/src/objects/js/components/index.js b/src/objects/js/components/index.js index 2ddc5c5f..6482b450 100644 --- a/src/objects/js/components/index.js +++ b/src/objects/js/components/index.js @@ -1,5 +1,5 @@ // Use this file to include individual components. -import './admin/'; import './admin/permissions'; +import './admin/search-toggle'; import './nav/'; From cef45e3a7f2c008b33b6c09c35090926f1c42eb9 Mon Sep 17 00:00:00 2001 From: Tim de Beer Date: Mon, 24 Nov 2025 09:50:33 +0100 Subject: [PATCH 07/13] :fire: [#621] remove IN for admin info --- docker-compose.yml | 5 ++--- src/objects/core/tests/test_admin.py | 6 ------ src/objects/templates/admin/core/object_change_list.html | 5 ++--- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 061d60e1..7c92a589 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,9 +81,8 @@ services: objecttypes-web: image: maykinmedia/objecttypes-api:latest environment: &app-env - DB_NAME: postgres - DB_USER: objects - DB_PASSWORD: objects + DB_NAME: objecttypes + DB_USER: objecttypes DJANGO_SETTINGS_MODULE: objecttypes.conf.docker SECRET_KEY: ${SECRET_KEY:-fgv=c0hz&tl*8*3m3893@m+1pstrvidc9e^5@fpspmg%cyf15d} ALLOWED_HOSTS: '*' diff --git a/src/objects/core/tests/test_admin.py b/src/objects/core/tests/test_admin.py index 896b88cb..7c7ff1f4 100644 --- a/src/objects/core/tests/test_admin.py +++ b/src/objects/core/tests/test_admin.py @@ -211,12 +211,6 @@ def get_row_pks(response): get_row_pks(response), [object2.pk, object3.pk, object4.pk] ) - with self.subTest("IN operator"): - response = self.app.get( - list_url, params={"q": "id_nummer__in__1|3"}, user=self.user - ) - self.assertCountEqual(get_row_pks(response), [object1.pk, object3.pk]) - with self.subTest("Date exact"): response = self.app.get( list_url, params={"q": "plantDate__exact__2025-06-15"}, user=self.user diff --git a/src/objects/templates/admin/core/object_change_list.html b/src/objects/templates/admin/core/object_change_list.html index 44137bea..e3b41393 100644 --- a/src/objects/templates/admin/core/object_change_list.html +++ b/src/objects/templates/admin/core/object_change_list.html @@ -7,13 +7,12 @@ +{% endif %} {{ block.super }} {% endblock %} \ No newline at end of file From c936e008016642ca17a42c3db8eff257fd6e01fa Mon Sep 17 00:00:00 2001 From: Tim de Beer Date: Thu, 27 Nov 2025 15:38:40 +0100 Subject: [PATCH 12/13] :recycle: [#621] fix requested changes --- src/objects/core/admin.py | 3 ++ src/objects/core/tests/test_admin.py | 36 ++++++++++++++++++- .../admin/core/object_change_list.html | 2 ++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/objects/core/admin.py b/src/objects/core/admin.py index e68ab2ba..9fd84cef 100644 --- a/src/objects/core/admin.py +++ b/src/objects/core/admin.py @@ -166,6 +166,9 @@ 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) diff --git a/src/objects/core/tests/test_admin.py b/src/objects/core/tests/test_admin.py index 1683d655..d09fd4fe 100644 --- a/src/objects/core/tests/test_admin.py +++ b/src/objects/core/tests/test_admin.py @@ -1,6 +1,6 @@ import re -from django.test import tag +from django.test import override_settings, tag from django.urls import reverse import requests_mock @@ -58,6 +58,40 @@ def test_object_changelist_filter_by_objecttype(self): self.assertIn(str(object1.uuid), response.text) self.assertNotIn(str(object2.uuid), response.text) + @tag("gh-621") + def test_object_admin_search_disabled(self): + list_url = reverse("admin:core_object_changelist") + + ObjectRecordFactory.create(data={"foo": "bar"}) + ObjectRecordFactory.create(data={"foo": "baz"}) + + def get_num_results(response) -> int: + result_list = response.html.find("table", {"id": "result_list"}) + return len(result_list.find("tbody").find_all("tr")) + + with self.subTest("search is enabled by default"): + response = self.app.get(list_url, user=self.user) + + self.assertIsNotNone(response.html.find("input", {"id": "searchbar"})) + + response = self.app.get( + list_url, params={"q": "foo__icontains__bar"}, user=self.user + ) + + self.assertEqual(get_num_results(response), 1) + + with self.subTest("search is disabled if OBJECTS_ADMIN_SEARCH_DISABLED=True"): + with override_settings(OBJECTS_ADMIN_SEARCH_DISABLED=True): + response = self.app.get( + reverse("admin:core_object_changelist"), user=self.user + ) + + self.assertIsNone(response.html.find("input", {"id": "searchbar"})) + + response = self.app.get(list_url, params={"q": "bar"}, user=self.user) + + self.assertEqual(get_num_results(response), 2) + @tag("gh-677") def test_add_new_objectrecord(self): service = ServiceFactory( diff --git a/src/objects/templates/admin/core/object_change_list.html b/src/objects/templates/admin/core/object_change_list.html index 5bd7f547..eadc28d8 100644 --- a/src/objects/templates/admin/core/object_change_list.html +++ b/src/objects/templates/admin/core/object_change_list.html @@ -34,6 +34,8 @@
{% blocktranslate %}Examples:{% endblocktranslate %}
    +
  • 0233da1f-32c1-4e7d-9896-2eecc7d24288
  • + {% blocktranslate %} (searching directly by object UUID) {% endblocktranslate %}
  • id__exact__1
  • naam__icontains__boom
  • date__gt__2025-01-01
  • From fc1b627b74866f86566ebedac074dd1dadfb931d Mon Sep 17 00:00:00 2001 From: Tim de Beer Date: Thu, 27 Nov 2025 16:12:19 +0100 Subject: [PATCH 13/13] :memo: [#621] added search docs --- docs/admin/object.rst | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) 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`` +