Skip to content

Commit 8b397f3

Browse files
Fixes #20012: Fix support for empty filter for custom fields (#20072)
1 parent f2b2927 commit 8b397f3

File tree

4 files changed

+55
-4
lines changed

4 files changed

+55
-4
lines changed

netbox/extras/lookups.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from django.db.models import CharField, Lookup
1+
from django.db.models import CharField, JSONField, Lookup
2+
from django.db.models.fields.json import KeyTextTransform
23

34
from .fields import CachedValueField
45

@@ -18,6 +19,30 @@ def as_sql(self, compiler, connection):
1819
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
1920

2021

22+
class JSONEmpty(Lookup):
23+
"""
24+
Support "empty" lookups for JSONField keys.
25+
26+
A key is considered empty if it is "", null, or does not exist.
27+
"""
28+
lookup_name = "empty"
29+
30+
def as_sql(self, compiler, connection):
31+
# self.lhs.lhs is the parent expression (could be a JSONField or another KeyTransform)
32+
# Rebuild the expression using KeyTextTransform to guarantee ->> (text)
33+
text_expr = KeyTextTransform(self.lhs.key_name, self.lhs.lhs)
34+
lhs_sql, lhs_params = compiler.compile(text_expr)
35+
36+
value = self.rhs
37+
if value not in (True, False):
38+
raise ValueError("The 'empty' lookup only accepts True or False.")
39+
40+
condition = '' if value else 'NOT '
41+
sql = f"(NULLIF({lhs_sql}, '') IS {condition}NULL)"
42+
43+
return sql, lhs_params
44+
45+
2146
class NetHost(Lookup):
2247
"""
2348
Similar to ipam.lookups.NetHost, but casts the field to INET.
@@ -45,5 +70,6 @@ def as_sql(self, qn, connection):
4570

4671

4772
CharField.register_lookup(Empty)
73+
JSONField.register_lookup(JSONEmpty)
4874
CachedValueField.register_lookup(NetHost)
4975
CachedValueField.register_lookup(NetContainsOrEquals)

netbox/extras/models/customfields.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,11 +600,19 @@ def to_filter(self, lookup_expr=None):
600600
kwargs = {
601601
'field_name': f'custom_field_data__{self.name}'
602602
}
603+
# Native numeric filters will use `isnull` by default for empty lookups, but
604+
# JSON fields require `empty` (see bug #20012).
605+
if lookup_expr == 'isnull':
606+
lookup_expr = 'empty'
603607
if lookup_expr is not None:
604608
kwargs['lookup_expr'] = lookup_expr
605609

610+
# 'Empty' lookup is always a boolean
611+
if lookup_expr == 'empty':
612+
filter_class = django_filters.BooleanFilter
613+
606614
# Text/URL
607-
if self.type in (
615+
elif self.type in (
608616
CustomFieldTypeChoices.TYPE_TEXT,
609617
CustomFieldTypeChoices.TYPE_LONGTEXT,
610618
CustomFieldTypeChoices.TYPE_URL,

netbox/extras/tests/test_customfields.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1615,6 +1615,7 @@ def setUpTestData(cls):
16151615
'cf11': manufacturers[2].pk,
16161616
'cf12': [manufacturers[2].pk, manufacturers[3].pk],
16171617
}),
1618+
Site(name='Site 4', slug='site-4'),
16181619
])
16191620

16201621
def test_filter_integer(self):
@@ -1624,6 +1625,7 @@ def test_filter_integer(self):
16241625
self.assertEqual(self.filterset({'cf_cf1__gte': [200]}, self.queryset).qs.count(), 2)
16251626
self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1)
16261627
self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2)
1628+
self.assertEqual(self.filterset({'cf_cf1__empty': True}, self.queryset).qs.count(), 1)
16271629

16281630
def test_filter_decimal(self):
16291631
self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2)
@@ -1632,6 +1634,7 @@ def test_filter_decimal(self):
16321634
self.assertEqual(self.filterset({'cf_cf2__gte': [200.2]}, self.queryset).qs.count(), 2)
16331635
self.assertEqual(self.filterset({'cf_cf2__lt': [200.2]}, self.queryset).qs.count(), 1)
16341636
self.assertEqual(self.filterset({'cf_cf2__lte': [200.2]}, self.queryset).qs.count(), 2)
1637+
self.assertEqual(self.filterset({'cf_cf2__empty': True}, self.queryset).qs.count(), 1)
16351638

16361639
def test_filter_boolean(self):
16371640
self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2)
@@ -1648,6 +1651,7 @@ def test_filter_text_strict(self):
16481651
self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 1)
16491652
self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1)
16501653
self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 2)
1654+
self.assertEqual(self.filterset({'cf_cf4__empty': True}, self.queryset).qs.count(), 1)
16511655

16521656
def test_filter_text_loose(self):
16531657
self.assertEqual(self.filterset({'cf_cf5': ['foo']}, self.queryset).qs.count(), 2)
@@ -1659,6 +1663,7 @@ def test_filter_date(self):
16591663
self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2)
16601664
self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1)
16611665
self.assertEqual(self.filterset({'cf_cf6__lte': ['2016-06-27']}, self.queryset).qs.count(), 2)
1666+
self.assertEqual(self.filterset({'cf_cf6__empty': True}, self.queryset).qs.count(), 1)
16621667

16631668
def test_filter_url_strict(self):
16641669
self.assertEqual(
@@ -1674,24 +1679,28 @@ def test_filter_url_strict(self):
16741679
self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 0)
16751680
self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1)
16761681
self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2)
1682+
self.assertEqual(self.filterset({'cf_cf7__empty': True}, self.queryset).qs.count(), 1)
16771683

16781684
def test_filter_url_loose(self):
16791685
self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
16801686

16811687
def test_filter_select(self):
16821688
self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
1689+
self.assertEqual(self.filterset({'cf_cf9__empty': True}, self.queryset).qs.count(), 1)
16831690

16841691
def test_filter_multiselect(self):
16851692
self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
16861693
self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2)
1687-
self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1)
1694+
self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1) # Contains a literal null
1695+
self.assertEqual(self.filterset({'cf_cf10__empty': True}, self.queryset).qs.count(), 2)
16881696

16891697
def test_filter_object(self):
16901698
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
16911699
self.assertEqual(
16921700
self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(),
16931701
2
16941702
)
1703+
self.assertEqual(self.filterset({'cf_cf11__empty': True}, self.queryset).qs.count(), 1)
16951704

16961705
def test_filter_multiobject(self):
16971706
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
@@ -1703,3 +1712,4 @@ def test_filter_multiobject(self):
17031712
self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(),
17041713
3
17051714
)
1715+
self.assertEqual(self.filterset({'cf_cf12__empty': True}, self.queryset).qs.count(), 1)

netbox/netbox/filtersets.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@
2929
'OrganizationalModelFilterSet',
3030
)
3131

32+
STANDARD_LOOKUPS = (
33+
'exact',
34+
'iexact',
35+
'in',
36+
'contains',
37+
)
38+
3239

3340
#
3441
# FilterSets
@@ -159,7 +166,7 @@ def get_additional_lookups(cls, existing_filter_name, existing_filter):
159166
return {}
160167

161168
# Skip nonstandard lookup expressions
162-
if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'iexact', 'in']:
169+
if existing_filter.method is not None or existing_filter.lookup_expr not in STANDARD_LOOKUPS:
163170
return {}
164171

165172
# Choose the lookup expression map based on the filter type

0 commit comments

Comments
 (0)