Explorar el Código

Closes #23238: Support the empty lookup for boolean custom fields (#23458)

Register the empty lookup (cf_<name>__empty) for boolean custom field
filters, which previously ignored it and returned every object. An object
is empty when it holds a null for the field or carries no key for it;
false is a value.

Also offer the "is empty" and "is not empty" modifiers for NullBooleanField
in filter forms. As for other field types, a modifier appears only when the
filterset provides the matching filter, so built-in boolean fields are
unaffected.
Jeremy Stretch hace 2 días
padre
commit
4e40ba0ced

+ 6 - 0
docs/reference/filtering.md

@@ -50,6 +50,12 @@ GET /api/dcim/sites/?cf_foo=123
 
 Custom fields can be mixed with built-in fields to further narrow results. When creating a custom string field, the type of filtering selected (loose versus exact) determines whether partial or full matching is used.
 
+Boolean custom fields support the `empty` [lookup expression](#lookup-expressions), which matches objects holding no value for the field. A value of `false` is not considered empty. For example, the following query will return only sites where a boolean custom field named `managed` has not been set:
+
+```no-highlight
+GET /api/dcim/sites/?cf_managed__empty=true
+```
+
 ## Lookup Expressions
 
 Certain model fields also support filtering using additional lookup expressions. This allows

+ 9 - 0
netbox/extras/tests/test_customfields.py

@@ -2878,6 +2878,15 @@ class CustomFieldModelFilterTestCase(TestCase):
     def test_filter_boolean(self):
         self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf3': False}, self.queryset).qs.count(), 1)
+        # False is a value, so only the object carrying no value for the field is empty
+        self.assertEqual(self.filterset({'cf_cf3__empty': True}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf3__empty': False}, self.queryset).qs.count(), 3)
+
+    def test_filter_boolean_empty_null(self):
+        # An explicit null is empty, just as a missing key is
+        Site.objects.create(name='Site 5', slug='site-5', custom_field_data={'cf3': None})
+        self.assertEqual(self.filterset({'cf_cf3__empty': True}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf3__empty': False}, self.queryset).qs.count(), 3)
 
     def test_filter_text_strict(self):
         self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 1)

+ 5 - 0
netbox/netbox/filtersets.py

@@ -20,6 +20,7 @@ from utilities import filters
 from utilities.constants import (
     FILTER_ARRAY_BASED_LOOKUP_MAP,
     FILTER_CHAR_BASED_LOOKUP_MAP,
+    FILTER_EMPTY_LOOKUP_MAP,
     FILTER_NEGATION_LOOKUP_MAP,
     FILTER_NUMERIC_BASED_LOOKUP_MAP,
     FILTER_TAG_LOOKUP_MAP,
@@ -194,6 +195,10 @@ class BaseFilterSet(django_filters.FilterSet):
         )):
             return FILTER_CHAR_BASED_LOOKUP_MAP
 
+        if isinstance(existing_filter, django_filters.BooleanFilter) and hasattr(existing_filter, 'custom_field'):
+            # A boolean custom field may hold no value at all, so it supports only the empty lookup
+            return FILTER_EMPTY_LOOKUP_MAP
+
         return None
 
     @classmethod

+ 4 - 0
netbox/utilities/constants.py

@@ -45,6 +45,10 @@ FILTER_TREENODE_NEGATION_LOOKUP_MAP = dict(
     n='in'
 )
 
+FILTER_EMPTY_LOOKUP_MAP = dict(
+    empty='empty'
+)
+
 #
 # HTTP Request META safe copy
 #

+ 5 - 1
netbox/utilities/forms/mixins.py

@@ -26,7 +26,11 @@ __all__ = (
 FORM_FIELD_LOOKUPS = {
     QueryField: [],
     forms.BooleanField: [],
-    forms.NullBooleanField: [],
+    forms.NullBooleanField: [
+        ('exact', _('is')),
+        (MODIFIER_EMPTY_TRUE, _('is empty')),
+        (MODIFIER_EMPTY_FALSE, _('is not empty')),
+    ],
     forms.CharField: [
         ('exact', _('is')),
         ('n', _('is not')),

+ 61 - 0
netbox/utilities/tests/test_filter_modifiers.py

@@ -196,6 +196,16 @@ class FilterModifierMixinTestCase(TestCase):
 
         self.assertNotIsInstance(form.fields['boolean_field'].widget, FilterModifierWidget)
 
+    def test_mixin_skips_null_boolean_field_without_empty_lookup(self):
+        """NullBooleanField should not be enhanced when the filterset offers no empty lookup for it."""
+        class TestForm(FilterModifierMixin, forms.Form):
+            boolean_field = forms.NullBooleanField(required=False)
+            model = TestModel
+
+        form = TestForm()
+
+        self.assertNotIsInstance(form.fields['boolean_field'].widget, FilterModifierWidget)
+
     def test_mixin_enhances_tag_filter_field(self):
         """TagFilterField should be enhanced even though it's a MultipleChoiceField."""
         class TestForm(FilterModifierMixin, forms.Form):
@@ -408,3 +418,54 @@ class ObjectCustomFieldEmptyLookupTestCase(TestCase):
         _, result = self._make_form_and_result('cf_test_obj_cf__empty=false')
         self.assertGreater(len(result['applied_filters']), 0)
         self.assertIn('not empty', result['applied_filters'][0]['link_text'].lower())
+
+
+class BooleanCustomFieldEmptyLookupTestCase(TestCase):
+    """
+    A boolean custom field may hold no value at all, so its filter form field offers the empty
+    modifiers alongside an exact match (see #23238).
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = User.objects.create(username='test_user_bool_cf')
+        cf = CustomField(name='test_bool_cf', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
+        cf.save()
+        cf.object_types.set([ObjectType.objects.get_for_model(Site)])
+
+    def _make_form_and_result(self, querystring):
+        query_params = QueryDict(querystring)
+        form = SiteFilterForm(query_params)
+        request = RequestFactory().get('/', query_params)
+        request.user = self.user
+        context = Context({'request': request})
+        result = applied_filters(context, Site, form, query_params)
+        return form, result
+
+    def test_field_enhanced_with_empty_modifiers(self):
+        """The boolean custom field should offer exact and empty modifiers."""
+        form = SiteFilterForm()
+        widget = form.fields['cf_test_bool_cf'].widget
+
+        self.assertIsInstance(widget, FilterModifierWidget)
+        lookup_codes = [lookup[0] for lookup in widget.lookups]
+        self.assertEqual(lookup_codes, ['exact', 'empty_true', 'empty_false'])
+
+    def test_filter_pill_appears_for_exact(self):
+        """An exact match on the boolean custom field still produces a single filter pill."""
+        _, result = self._make_form_and_result('cf_test_bool_cf=False')
+        self.assertEqual(len(result['applied_filters']), 1)
+        self.assertEqual(result['applied_filters'][0]['name'], 'cf_test_bool_cf')
+
+    def test_filter_pill_appears_for_empty_true(self):
+        """A filter pill showing 'is empty' must be generated for a boolean CF with __empty=true."""
+        form, result = self._make_form_and_result('cf_test_bool_cf__empty=true')
+        self.assertNotIn('cf_test_bool_cf', form.errors)
+        self.assertEqual(len(result['applied_filters']), 1)
+        self.assertIn('is empty', result['applied_filters'][0]['link_text'].lower())
+
+    def test_filter_pill_appears_for_empty_false(self):
+        """A filter pill showing 'is not empty' must be generated for a boolean CF with __empty=false."""
+        _, result = self._make_form_and_result('cf_test_bool_cf__empty=false')
+        self.assertEqual(len(result['applied_filters']), 1)
+        self.assertIn('is not empty', result['applied_filters'][0]['link_text'].lower())