diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index ec52d9010..0bf50dc00 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1091,6 +1091,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo # Multiselect elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: + # Do not pin lookup_expr: FILTER_ARRAY_BASED_LOOKUP_MAP preserves the class default under negation filter_class = filters.MultiValueArrayFilter # Object diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index cb8308eaf..21ac32134 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -2752,6 +2752,7 @@ class CustomFieldModelFilterTestCase(TestCase): 'cf4': None, 'cf6': None, 'cf7': None, + 'cf10': None, }) for filter_name, value in ( @@ -2766,6 +2767,7 @@ class CustomFieldModelFilterTestCase(TestCase): ('cf_cf7__nic', 'a'), ('cf_cf7__nisw', 'http://'), ('cf_cf7__niew', '.com'), + ('cf_cf10__n', 'A'), ): with self.subTest(filter_name): pks = set( @@ -2836,6 +2838,15 @@ class CustomFieldModelFilterTestCase(TestCase): def test_filter_multiselect(self): self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2) + # Negation excludes the objects whose array holds the value, not those whose array equals it + self.assertEqual( + set(self.filterset({'cf_cf10__n': ['A']}, self.queryset).qs.values_list('slug', flat=True)), + {'site-2', 'site-3', 'site-4'} + ) + self.assertEqual( + set(self.filterset({'cf_cf10__n': ['A', 'C']}, self.queryset).qs.values_list('slug', flat=True)), + {'site-3', 'site-4'} + ) # Matches both the object holding a literal null and the one carrying no key, as `empty` does self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf10__empty': True}, self.queryset).qs.count(), 2) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 090fb9028..024faad17 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -18,6 +18,7 @@ from extras.models import CustomField, SavedFilter from users.filterset_mixins import OwnerFilterMixin from utilities import filters from utilities.constants import ( + FILTER_ARRAY_BASED_LOOKUP_MAP, FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP, @@ -170,6 +171,12 @@ class BaseFilterSet(django_filters.FilterSet): # These filter types support only negation return FILTER_NEGATION_LOOKUP_MAP + if isinstance(existing_filter, ( + filters.MultiValueArrayFilter, + )): + # Must precede the char-based branch below, which would otherwise shadow this subclass + return FILTER_ARRAY_BASED_LOOKUP_MAP + if isinstance(existing_filter, ( django_filters.filters.CharFilter, django_filters.ChoiceFilter, diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 108ef225d..c370a8e91 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -17,6 +17,12 @@ FILTER_CHAR_BASED_LOOKUP_MAP = dict( iregex='iregex', ) +# A member is a scalar inside a stored array, so negation cannot fall back to equality +FILTER_ARRAY_BASED_LOOKUP_MAP = { + **FILTER_CHAR_BASED_LOOKUP_MAP, + 'n': 'contains', +} + FILTER_NUMERIC_BASED_LOOKUP_MAP = dict( n='exact', lte='lte', diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 5e9318d80..8e2f2e436 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -28,6 +28,7 @@ from ipam.filtersets import ASNFilterSet from ipam.models import ASN, RIR from netbox.filtersets import BaseFilterSet from utilities.filters import ( + MultiValueArrayFilter, MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter, @@ -209,6 +210,9 @@ class BaseFilterSetTestCase(TestCase): multiplechoicefield = django_filters.MultipleChoiceFilter( field_name='choicefield' ) + multivaluearrayfield = MultiValueArrayFilter( + field_name='charfield' # We're pretending this is an array field + ) multivaluecharfield = MultiValueCharFilter( field_name='charfield' ) @@ -326,6 +330,13 @@ class BaseFilterSetTestCase(TestCase): self.assertEqual(self.filters['modelmultiplechoicefield__n'].lookup_expr, 'exact') self.assertEqual(self.filters['modelmultiplechoicefield__n'].exclude, True) + def test_multi_value_array_filter(self): + self.assertIsInstance(self.filters['multivaluearrayfield'], MultiValueArrayFilter) + self.assertEqual(self.filters['multivaluearrayfield'].lookup_expr, 'contains') + self.assertEqual(self.filters['multivaluearrayfield'].exclude, False) + self.assertEqual(self.filters['multivaluearrayfield__n'].lookup_expr, 'contains') + self.assertEqual(self.filters['multivaluearrayfield__n'].exclude, True) + def test_multi_value_char_filter(self): self.assertIsInstance(self.filters['multivaluecharfield'], MultiValueCharFilter) self.assertEqual(self.filters['multivaluecharfield'].lookup_expr, 'exact')