Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/admin/object.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``

2 changes: 1 addition & 1 deletion docs/examples/objecttype-boom.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 33 additions & 10 deletions src/objects/api/v2/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
52 changes: 48 additions & 4 deletions src/objects/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -141,18 +143,60 @@ 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")

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):
Expand Down
122 changes: 121 additions & 1 deletion src/objects/core/tests/test_admin.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import re

from django.test import override_settings, tag
from django.urls import reverse

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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])
2 changes: 1 addition & 1 deletion src/objects/js/components/admin/permissions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const mount = () => {
formData={jsonScriptToVar('form-data')}
/>,
);

};


mount();
23 changes: 23 additions & 0 deletions src/objects/js/components/admin/search-toggle.js
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 2 additions & 0 deletions src/objects/js/components/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Use this file to include individual components.
import './admin/permissions';
import './admin/search-toggle';
import './nav/';

Loading
Loading