Ver Fonte

fix(filters): Preserve contains lookup for negated multiselect filters

Add FILTER_ARRAY_BASED_LOOKUP_MAP to maintain 'contains' lookup under
negation for MultiValueArrayFilter, preventing fallback to exact match.
Negation now correctly excludes objects whose array contains the value
rather than matching it exactly.

Fixes #23117
Martin Hauser há 17 horas atrás
pai
commit
62da27e0dd

+ 1 - 0
netbox/extras/models/customfields.py

@@ -1091,6 +1091,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
 
 
         # Multiselect
         # Multiselect
         elif self.type == CustomFieldTypeChoices.TYPE_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
             filter_class = filters.MultiValueArrayFilter
 
 
         # Object
         # Object

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

@@ -2752,6 +2752,7 @@ class CustomFieldModelFilterTestCase(TestCase):
             'cf4': None,
             'cf4': None,
             'cf6': None,
             'cf6': None,
             'cf7': None,
             'cf7': None,
+            'cf10': None,
         })
         })
 
 
         for filter_name, value in (
         for filter_name, value in (
@@ -2766,6 +2767,7 @@ class CustomFieldModelFilterTestCase(TestCase):
             ('cf_cf7__nic', 'a'),
             ('cf_cf7__nic', 'a'),
             ('cf_cf7__nisw', 'http://'),
             ('cf_cf7__nisw', 'http://'),
             ('cf_cf7__niew', '.com'),
             ('cf_cf7__niew', '.com'),
+            ('cf_cf10__n', 'A'),
         ):
         ):
             with self.subTest(filter_name):
             with self.subTest(filter_name):
                 pks = set(
                 pks = set(
@@ -2836,6 +2838,15 @@ class CustomFieldModelFilterTestCase(TestCase):
     def test_filter_multiselect(self):
     def test_filter_multiselect(self):
         self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
         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)
         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
         # 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': ['null']}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf10__empty': True}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf10__empty': True}, self.queryset).qs.count(), 2)

+ 7 - 0
netbox/netbox/filtersets.py

@@ -18,6 +18,7 @@ from extras.models import CustomField, SavedFilter
 from users.filterset_mixins import OwnerFilterMixin
 from users.filterset_mixins import OwnerFilterMixin
 from utilities import filters
 from utilities import filters
 from utilities.constants import (
 from utilities.constants import (
+    FILTER_ARRAY_BASED_LOOKUP_MAP,
     FILTER_CHAR_BASED_LOOKUP_MAP,
     FILTER_CHAR_BASED_LOOKUP_MAP,
     FILTER_NEGATION_LOOKUP_MAP,
     FILTER_NEGATION_LOOKUP_MAP,
     FILTER_NUMERIC_BASED_LOOKUP_MAP,
     FILTER_NUMERIC_BASED_LOOKUP_MAP,
@@ -170,6 +171,12 @@ class BaseFilterSet(django_filters.FilterSet):
             # These filter types support only negation
             # These filter types support only negation
             return FILTER_NEGATION_LOOKUP_MAP
             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, (
         if isinstance(existing_filter, (
             django_filters.filters.CharFilter,
             django_filters.filters.CharFilter,
             django_filters.ChoiceFilter,
             django_filters.ChoiceFilter,

+ 6 - 0
netbox/utilities/constants.py

@@ -17,6 +17,12 @@ FILTER_CHAR_BASED_LOOKUP_MAP = dict(
     iregex='iregex',
     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(
 FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(
     n='exact',
     n='exact',
     lte='lte',
     lte='lte',

+ 11 - 0
netbox/utilities/tests/test_filters.py

@@ -28,6 +28,7 @@ from ipam.filtersets import ASNFilterSet
 from ipam.models import ASN, RIR
 from ipam.models import ASN, RIR
 from netbox.filtersets import BaseFilterSet
 from netbox.filtersets import BaseFilterSet
 from utilities.filters import (
 from utilities.filters import (
+    MultiValueArrayFilter,
     MultiValueCharFilter,
     MultiValueCharFilter,
     MultiValueDateFilter,
     MultiValueDateFilter,
     MultiValueDateTimeFilter,
     MultiValueDateTimeFilter,
@@ -209,6 +210,9 @@ class BaseFilterSetTestCase(TestCase):
         multiplechoicefield = django_filters.MultipleChoiceFilter(
         multiplechoicefield = django_filters.MultipleChoiceFilter(
             field_name='choicefield'
             field_name='choicefield'
         )
         )
+        multivaluearrayfield = MultiValueArrayFilter(
+            field_name='charfield'  # We're pretending this is an array field
+        )
         multivaluecharfield = MultiValueCharFilter(
         multivaluecharfield = MultiValueCharFilter(
             field_name='charfield'
             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'].lookup_expr, 'exact')
         self.assertEqual(self.filters['modelmultiplechoicefield__n'].exclude, True)
         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):
     def test_multi_value_char_filter(self):
         self.assertIsInstance(self.filters['multivaluecharfield'], MultiValueCharFilter)
         self.assertIsInstance(self.filters['multivaluecharfield'], MultiValueCharFilter)
         self.assertEqual(self.filters['multivaluecharfield'].lookup_expr, 'exact')
         self.assertEqual(self.filters['multivaluecharfield'].lookup_expr, 'exact')