Skip to content

Commit 6272c8c

Browse files
authored
Merge pull request #699 from maykinmedia/feature/621-search-capabilities
⚡ [#621] add key value search to data
2 parents 0880d2b + fc1b627 commit 6272c8c

File tree

9 files changed

+314
-17
lines changed

9 files changed

+314
-17
lines changed

docs/admin/object.rst

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,35 @@ corrected in the "Correction" field of the next record.
6464

6565
In the Objects API you always see one record, which contains data of a certain time (by default
6666
the latest one). However in the admin interface you can see all the records created for the object.
67+
68+
69+
Search objects in the admin
70+
---------------------------
71+
72+
You can search by **UUID** or inside object data using the format:
73+
74+
.. code-block:: text
75+
76+
field__operator__value
77+
78+
Operators:
79+
80+
- ``exact`` - exact match
81+
- ``icontains`` - case insensitive substring match
82+
- ``gt`` - greater than
83+
- ``gte`` - greater than or equal to
84+
- ``lt`` - less than
85+
- ``lte`` - less than or equal to
86+
87+
Examples:
88+
89+
- ``0233da1f-32c1-4e7d-9896-2eecc7d24288`` - searching directly by object UUID
90+
- ``id__exact__1``
91+
- ``naam__icontains__boom``
92+
- ``date__gt__2025-01-01``
93+
- ``date__gte__2025-06-15``
94+
- ``date__lt__2025-12-31``
95+
- ``date__lte__2025-06-15``
96+
- ``location__city__exact__Amsterdam``
97+
- ``location__region__icontains__Noord``
98+

docs/examples/objecttype-boom.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ The "Boom" objecttype is based on the open source `Gemeentelijk Gegevensmodel`_
1212
A `small script`_ was used to convert the GGM EAP model to JSON schema.
1313

1414
.. _`Gemeentelijk Gegevensmodel`: https://github.com/Gemeente-Delft/Gemeentelijk-Gegevensmodel
15-
.. _`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
15+
.. _`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
1616
.. _`Basisregistratie Grootschalige Topografie`: https://www.kadaster.nl/zakelijk/registraties/basisregistraties/bgt
1717
.. _`Informatiemodel geografie`: https://www.geonovum.nl/geo-standaarden/bgt-imgeo
1818
.. _`small script`: https://github.com/maykinmedia/imvertor-lite

src/objects/api/v2/filters.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,19 @@ def build_nested_dict(path: str, value: Any) -> dict[str, Any]:
7878
return nested
7979

8080

81-
def filter_data_attr_value_part(value_part: str, queryset: QuerySet) -> QuerySet:
81+
def filter_queryset_by_data_attr(
82+
queryset: QuerySet,
83+
key: str,
84+
operator: str,
85+
str_value: str,
86+
field_prefix: str,
87+
) -> QuerySet:
8288
"""
83-
filter one value part for data_attr and data_attrs filters
89+
Generic filter helper.
90+
Can be used in API FilterSet or Admin search.
8491
"""
85-
variable, operator, str_value = value_part.rsplit("__", 2)
8692
real_value = string_to_value(str_value)
93+
full_field = f"{field_prefix}__{key}" if key else field_prefix
8794

8895
if operator == "exact":
8996
# 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
93100

94101
query = Q()
95102
for val in in_vals:
96-
nested_dict = build_nested_dict(variable, val)
97-
query |= Q(data__contains=nested_dict)
103+
nested_dict = build_nested_dict(key, val)
104+
query |= Q(**{f"{field_prefix}__contains": nested_dict})
98105

99106
# Make sure containment operator is used (`@>`) via __contains, to ensure the
100107
# GINIndex on `data` is utilized
101108
queryset = queryset.filter(query)
109+
102110
elif operator == "icontains":
103-
# icontains treats everything like strings
104-
queryset = queryset.filter(**{f"data__{variable}__icontains": str_value})
111+
queryset = queryset.filter(**{f"{full_field}__icontains": str_value})
112+
105113
elif operator == "in":
106114
# in must be a list
107115
values = str_value.split("|")
108-
queryset = queryset.filter(**{f"data__{variable}__in": values})
116+
queryset = queryset.filter(**{f"{full_field}__in": values})
117+
118+
elif operator in ("gt", "gte", "lt", "lte"):
119+
queryset = queryset.filter(**{f"{full_field}__{operator}": real_value})
109120

110121
else:
111-
# gt, gte, lt, lte operators
112-
queryset = queryset.filter(**{f"data__{variable}__{operator}": real_value})
122+
queryset = queryset.filter(**{f"{full_field}__icontains": str_value})
123+
113124
return queryset
114125

115126

127+
def filter_data_attr_value_part(
128+
value_part: str, queryset: QuerySet, field_prefix: str = "data"
129+
) -> QuerySet:
130+
"""
131+
Wrapper to handle a single value part of data_attr or data_attrs.
132+
"""
133+
key, operator, str_value = value_part.rsplit("__", 2)
134+
return filter_queryset_by_data_attr(
135+
queryset, key, operator, str_value, field_prefix=field_prefix
136+
)
137+
138+
116139
class ObjectRecordFilterForm(forms.Form):
117140
def clean(self):
118141
cleaned_data = super().clean()

src/objects/core/admin.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
from django.contrib.gis.db.models import GeometryField
88
from django.http import HttpRequest, JsonResponse
99
from django.urls import path
10+
from django.utils.translation import gettext_lazy as _
1011

1112
import requests
1213
import structlog
1314
from vng_api_common.utils import get_help_text
1415

16+
from objects.api.v2.filters import filter_queryset_by_data_attr
1517
from objects.utils.client import get_objecttypes_client
1618

1719
from .models import Object, ObjectRecord, ObjectType
@@ -141,18 +143,60 @@ class ObjectAdmin(admin.ModelAdmin):
141143
"modified_on",
142144
"created_on",
143145
)
144-
search_fields = ("uuid", "records__data")
146+
search_fields = ("uuid",)
145147
inlines = (ObjectRecordInline,)
146148
list_filter = (ObjectTypeFilter, "created_on", "modified_on")
147149

148150
def get_search_fields(self, request: HttpRequest) -> Sequence[str]:
149151
if settings.OBJECTS_ADMIN_SEARCH_DISABLED:
150152
return ()
151153

152-
return (
153-
"uuid",
154-
"records__data",
154+
return ("uuid",)
155+
156+
change_list_template = "admin/core/object_change_list.html"
157+
158+
def changelist_view(self, request, extra_context=None):
159+
extra_context = extra_context or {}
160+
extra_context["toggle_show"] = _("Show search instructions")
161+
extra_context["toggle_hide"] = _("Hide search instructions")
162+
extra_context["search_enabled"] = bool(self.get_search_fields(request))
163+
return super().changelist_view(request, extra_context=extra_context)
164+
165+
def get_search_results(self, request, queryset, search_term):
166+
VALID_OPERATORS = {"exact", "icontains", "in", "gt", "gte", "lt", "lte"}
167+
DEFAULT_OPERATOR = "icontains"
168+
169+
if settings.OBJECTS_ADMIN_SEARCH_DISABLED:
170+
return queryset, False
171+
172+
if "__" not in search_term:
173+
return super().get_search_results(request, queryset, search_term)
174+
175+
parts = search_term.rsplit("__", 2)
176+
177+
if len(parts) == 3 and parts[1] in VALID_OPERATORS:
178+
key, operator, str_value = parts
179+
elif len(parts) == 3:
180+
key = "__".join(parts[:-1])
181+
operator = DEFAULT_OPERATOR
182+
str_value = parts[-1]
183+
elif len(parts) == 2:
184+
key, str_value = parts
185+
operator = DEFAULT_OPERATOR
186+
else:
187+
return super().get_search_results(request, queryset, search_term)
188+
189+
if not key or not str_value:
190+
return super().get_search_results(request, queryset, search_term)
191+
192+
queryset = filter_queryset_by_data_attr(
193+
queryset,
194+
key.strip(),
195+
operator,
196+
str_value.strip(),
197+
field_prefix="records__data",
155198
)
199+
return queryset.distinct(), False
156200

157201
@admin.display(description="Object type UUID")
158202
def get_object_type_uuid(self, obj):

src/objects/core/tests/test_admin.py

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import re
2+
13
from django.test import override_settings, tag
24
from django.urls import reverse
35

@@ -72,7 +74,9 @@ def get_num_results(response) -> int:
7274

7375
self.assertIsNotNone(response.html.find("input", {"id": "searchbar"}))
7476

75-
response = self.app.get(list_url, params={"q": "bar"}, user=self.user)
77+
response = self.app.get(
78+
list_url, params={"q": "foo__icontains__bar"}, user=self.user
79+
)
7680

7781
self.assertEqual(get_num_results(response), 1)
7882

@@ -126,3 +130,119 @@ def test_add_new_objectrecord(self):
126130
response = form.submit()
127131

128132
self.assertEqual(object.records.count(), 1)
133+
134+
@tag("gh-621")
135+
def test_object_admin_search_json_key_operator_value(self):
136+
object1 = ObjectFactory()
137+
ObjectRecordFactory(
138+
object=object1,
139+
data={"id_nummer": 1, "naam": "Boomgaard", "plantDate": "2025-01-01"},
140+
)
141+
object2 = ObjectFactory()
142+
ObjectRecordFactory(
143+
object=object2,
144+
data={"id_nummer": 2, "naam": "Appelboom", "plantDate": "2025-06-15"},
145+
)
146+
object3 = ObjectFactory()
147+
ObjectRecordFactory(
148+
object=object3,
149+
data={"id_nummer": 3, "naam": "Peren", "plantDate": "2025-12-31"},
150+
)
151+
object4 = ObjectFactory()
152+
ObjectRecordFactory(
153+
object=object4,
154+
data={
155+
"id_nummer": 4,
156+
"naam": "Kersen",
157+
"plantDate": "2025-07-20",
158+
"location": {"city": "Amsterdam", "region": "Noord-Holland"},
159+
},
160+
)
161+
162+
list_url = reverse("admin:core_object_changelist")
163+
164+
def get_row_pks(response):
165+
rows = response.html.select("#result_list tbody tr")
166+
pks = []
167+
for row in rows:
168+
href = row.select_one("th a")["href"]
169+
pks.append(int(re.search(r"\d+", href).group()))
170+
return pks
171+
172+
with self.subTest("Exact match"):
173+
response = self.app.get(
174+
list_url, params={"q": "id_nummer__exact__1"}, user=self.user
175+
)
176+
self.assertEqual(get_row_pks(response), [object1.pk])
177+
178+
with self.subTest("Nested JSON value match"):
179+
response = self.app.get(
180+
list_url,
181+
params={"q": "location__city__exact__Amsterdam"},
182+
user=self.user,
183+
)
184+
self.assertEqual(get_row_pks(response), [object4.pk])
185+
186+
with self.subTest("Nested"):
187+
response = self.app.get(
188+
list_url,
189+
params={"q": "location__city__Amsterdam"},
190+
user=self.user,
191+
)
192+
self.assertEqual(get_row_pks(response), [object4.pk])
193+
194+
with self.subTest("icontains"):
195+
response = self.app.get(
196+
list_url, params={"q": "naam__icontains__boom"}, user=self.user
197+
)
198+
self.assertCountEqual(get_row_pks(response), [object1.pk, object2.pk])
199+
200+
with self.subTest("Default operator"):
201+
response = self.app.get(
202+
list_url, params={"q": "naam__Boomgaard"}, user=self.user
203+
)
204+
self.assertEqual(get_row_pks(response), [object1.pk])
205+
206+
with self.subTest("Numeric comparison gt"):
207+
response = self.app.get(
208+
list_url, params={"q": "id_nummer__gt__1"}, user=self.user
209+
)
210+
self.assertCountEqual(
211+
get_row_pks(response), [object2.pk, object3.pk, object4.pk]
212+
)
213+
214+
with self.subTest("Date exact"):
215+
response = self.app.get(
216+
list_url, params={"q": "plantDate__exact__2025-06-15"}, user=self.user
217+
)
218+
self.assertEqual(get_row_pks(response), [object2.pk])
219+
220+
with self.subTest("Date gt"):
221+
response = self.app.get(
222+
list_url, params={"q": "plantDate__gt__2025-01-01"}, user=self.user
223+
)
224+
self.assertCountEqual(
225+
get_row_pks(response), [object2.pk, object3.pk, object4.pk]
226+
)
227+
228+
with self.subTest("Date lt"):
229+
response = self.app.get(
230+
list_url, params={"q": "plantDate__lt__2025-12-01"}, user=self.user
231+
)
232+
self.assertCountEqual(
233+
get_row_pks(response), [object1.pk, object2.pk, object4.pk]
234+
)
235+
236+
with self.subTest("Date comparison gte"):
237+
response = self.app.get(
238+
list_url, params={"q": "plantDate__gte__2025-06-15"}, user=self.user
239+
)
240+
self.assertCountEqual(
241+
get_row_pks(response), [object2.pk, object3.pk, object4.pk]
242+
)
243+
244+
with self.subTest("Date comparison lte"):
245+
response = self.app.get(
246+
list_url, params={"q": "plantDate__lte__2025-06-15"}, user=self.user
247+
)
248+
self.assertCountEqual(get_row_pks(response), [object1.pk, object2.pk])

src/objects/js/components/admin/permissions/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const mount = () => {
1818
formData={jsonScriptToVar('form-data')}
1919
/>,
2020
);
21+
2122
};
2223

23-
2424
mount();
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
function mountSearchHelpToggle() {
2+
const toggleLink = document.getElementById("toggle-search-help");
3+
const content = document.getElementById("search-help-content");
4+
5+
if (!toggleLink || !content) return;
6+
7+
const showText = toggleLink.dataset.showText;
8+
const hideText = toggleLink.dataset.hideText;
9+
10+
toggleLink.addEventListener("click", function (e) {
11+
e.preventDefault();
12+
13+
if (content.style.display === "none" || content.style.display === "") {
14+
content.style.display = "block";
15+
toggleLink.textContent = hideText;
16+
} else {
17+
content.style.display = "none";
18+
toggleLink.textContent = showText;
19+
}
20+
});
21+
}
22+
23+
document.addEventListener("DOMContentLoaded", mountSearchHelpToggle);

src/objects/js/components/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
// Use this file to include individual components.
22
import './admin/permissions';
3+
import './admin/search-toggle';
34
import './nav/';
5+

0 commit comments

Comments
 (0)