فهرست منبع

Closes #22685: Add "any" lookup for tag & tag_id filters

bctiemann 1 هفته پیش
والد
کامیت
abe4a2cd9e

+ 17 - 0
docs/reference/filtering.md

@@ -110,6 +110,23 @@ expression: `n`. Here is an example of a lookup expression on a foreign key, it
 GET /api/ipam/vlans/?group_id__n=3203
 GET /api/ipam/vlans/?group_id__n=3203
 ```
 ```
 
 
+### Tags
+
+The `tag` and `tag_id` filters support negation (`n`) as well as an `any` lookup expression:
+
+| Filter | Description                                       |
+|--------|----------------------------------------------------|
+| `n`    | Does not have any of these tags                    |
+| `any`  | Has any of these tags (logical OR)                  |
+
+Passing multiple values for `tag`/`tag_id` without a lookup expression uses a logical AND: `GET /api/dcim/sites/?tag=foo&tag=bar` returns only sites tagged with both `foo` _and_ `bar`. To instead match sites tagged with `foo` _or_ `bar`, use the `any` lookup expression:
+
+```no-highlight
+GET /api/dcim/sites/?tag__any=foo&tag__any=bar
+```
+
+Note that `n` is not the logical complement of the default (AND) behavior: passing multiple values applies NOR logic, matching only objects which have _none_ of the specified tags, rather than objects which are simply missing at least one of them.
+
 ## Ordering Objects
 ## Ordering Objects
 
 
 To order results by a particular field, include the `ordering` query parameter. For example, order the list of sites according to their facility values:
 To order results by a particular field, include the `ordering` query parameter. For example, order the list of sites according to their facility values:

+ 9 - 1
netbox/netbox/filtersets.py

@@ -20,6 +20,7 @@ from utilities.constants import (
     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,
+    FILTER_TAG_LOOKUP_MAP,
     FILTER_TREENODE_NEGATION_LOOKUP_MAP,
     FILTER_TREENODE_NEGATION_LOOKUP_MAP,
 )
 )
 from utilities.forms.fields import MACAddressField
 from utilities.forms.fields import MACAddressField
@@ -153,10 +154,13 @@ class BaseFilterSet(django_filters.FilterSet):
             # TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression
             # TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression
             return FILTER_TREENODE_NEGATION_LOOKUP_MAP
             return FILTER_TREENODE_NEGATION_LOOKUP_MAP
 
 
+        if isinstance(existing_filter, (TagFilter, TagIDFilter)):
+            # Tags additionally support an "any of" (OR) mode, unlike other model choice filters
+            return FILTER_TAG_LOOKUP_MAP
+
         if isinstance(existing_filter, (
         if isinstance(existing_filter, (
             django_filters.ModelChoiceFilter,
             django_filters.ModelChoiceFilter,
             django_filters.ModelMultipleChoiceFilter,
             django_filters.ModelMultipleChoiceFilter,
-            TagFilter
         )):
         )):
             # These filter types support only negation
             # These filter types support only negation
             return FILTER_NEGATION_LOOKUP_MAP
             return FILTER_NEGATION_LOOKUP_MAP
@@ -237,6 +241,10 @@ class BaseFilterSet(django_filters.FilterSet):
                 # Of course setting the negation of the existing filter's exclude attribute handles both cases
                 # Of course setting the negation of the existing filter's exclude attribute handles both cases
                 new_filter.exclude = not existing_filter.exclude
                 new_filter.exclude = not existing_filter.exclude
 
 
+            if lookup_name == 'any' and isinstance(new_filter, (TagFilter, TagIDFilter)):
+                # "Any of" is an OR match, whereas TagFilter/TagIDFilter default to AND (conjoined=True)
+                new_filter.conjoined = False
+
             new_filters[new_filter_name] = new_filter
             new_filters[new_filter_name] = new_filter
 
 
         return new_filters
         return new_filters

+ 5 - 0
netbox/utilities/constants.py

@@ -30,6 +30,11 @@ FILTER_NEGATION_LOOKUP_MAP = dict(
     n='exact'
     n='exact'
 )
 )
 
 
+FILTER_TAG_LOOKUP_MAP = dict(
+    n='exact',
+    any='exact',
+)
+
 FILTER_TREENODE_NEGATION_LOOKUP_MAP = dict(
 FILTER_TREENODE_NEGATION_LOOKUP_MAP = dict(
     n='in'
     n='in'
 )
 )

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

@@ -80,6 +80,7 @@ FORM_FIELD_LOOKUPS = {
     ],
     ],
     TagFilterField: [
     TagFilterField: [
         ('exact', _('has these tags')),
         ('exact', _('has these tags')),
+        ('any', _('has any of these tags')),
         ('n', _('does not have these tags')),
         ('n', _('does not have these tags')),
         (MODIFIER_EMPTY_TRUE, _('is empty')),
         (MODIFIER_EMPTY_TRUE, _('is empty')),
         (MODIFIER_EMPTY_FALSE, _('is not empty')),
         (MODIFIER_EMPTY_FALSE, _('is not empty')),

+ 2 - 2
netbox/utilities/tests/test_filter_modifiers.py

@@ -206,8 +206,8 @@ class FilterModifierMixinTestCase(TestCase):
 
 
         self.assertIsInstance(form.fields['tag'].widget, FilterModifierWidget)
         self.assertIsInstance(form.fields['tag'].widget, FilterModifierWidget)
         tag_lookups = [lookup[0] for lookup in form.fields['tag'].widget.lookups]
         tag_lookups = [lookup[0] for lookup in form.fields['tag'].widget.lookups]
-        # Device filterset has tag and tag__n but not tag__empty
-        expected_lookups = ['exact', 'n']
+        # Device filterset has tag, tag__any, and tag__n but not tag__empty
+        expected_lookups = ['exact', 'any', 'n']
         self.assertEqual(tag_lookups, expected_lookups)
         self.assertEqual(tag_lookups, expected_lookups)
 
 
     def test_mixin_enhances_integer_field(self):
     def test_mixin_enhances_integer_field(self):

+ 69 - 1
netbox/utilities/tests/test_filters.py

@@ -23,7 +23,7 @@ from dcim.models import (
     Site,
     Site,
 )
 )
 from extras.filters import TagFilter
 from extras.filters import TagFilter
-from extras.models import SavedFilter, TaggedItem
+from extras.models import SavedFilter, Tag, TaggedItem
 from ipam.filtersets import ASNFilterSet
 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
@@ -96,6 +96,69 @@ class TreeNodeMultipleChoiceFilterTestCase(TestCase):
         self.assertEqual(qs[1], self.site3)
         self.assertEqual(qs[1], self.site3)
 
 
 
 
+class TagFilterTestCase(TestCase):
+    """
+    Verify the AND (default), OR (`any`), and NOR (`n`) semantics of TagFilter/TagIDFilter.
+    """
+    queryset = Site.objects.all()
+    filterset = SiteFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        tags = (
+            Tag(name='Tag 1', slug='tag-1'),
+            Tag(name='Tag 2', slug='tag-2'),
+            Tag(name='Tag 3', slug='tag-3'),
+        )
+        Tag.objects.bulk_create(tags)
+
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3'),
+        )
+        Site.objects.bulk_create(sites)
+        sites[0].tags.set([tags[0], tags[1]])  # tag-1, tag-2
+        sites[1].tags.set([tags[1]])  # tag-2 only
+        sites[2].tags.set([tags[2]])  # tag-3 only
+
+    def test_tag_and(self):
+        tags = Tag.objects.filter(slug__in=('tag-1', 'tag-2'))
+        params = {'tag': [tags[0].slug, tags[1].slug]}
+        qs = self.filterset(params, self.queryset).qs
+        self.assertEqual(qs.count(), 1)
+        self.assertEqual(qs[0].slug, 'site-1')
+
+        params = {'tag_id': [tags[0].pk, tags[1].pk]}
+        qs = self.filterset(params, self.queryset).qs
+        self.assertEqual(qs.count(), 1)
+        self.assertEqual(qs[0].slug, 'site-1')
+
+    def test_tag_any(self):
+        tags = Tag.objects.filter(slug__in=('tag-1', 'tag-3'))
+        params = {'tag__any': [tags[0].slug, tags[1].slug]}
+        qs = self.filterset(params, self.queryset).qs
+        self.assertEqual(qs.count(), 2)
+        self.assertEqual({site.slug for site in qs}, {'site-1', 'site-3'})
+
+        params = {'tag_id__any': [tags[0].pk, tags[1].pk]}
+        qs = self.filterset(params, self.queryset).qs
+        self.assertEqual(qs.count(), 2)
+        self.assertEqual({site.slug for site in qs}, {'site-1', 'site-3'})
+
+    def test_tag_negation(self):
+        tags = Tag.objects.filter(slug__in=('tag-1', 'tag-3'))
+        params = {'tag__n': [tags[0].slug, tags[1].slug]}
+        qs = self.filterset(params, self.queryset).qs
+        self.assertEqual(qs.count(), 1)
+        self.assertEqual(qs[0].slug, 'site-2')
+
+        params = {'tag_id__n': [tags[0].pk, tags[1].pk]}
+        qs = self.filterset(params, self.queryset).qs
+        self.assertEqual(qs.count(), 1)
+        self.assertEqual(qs[0].slug, 'site-2')
+
+
 class DummyModel(models.Model):
 class DummyModel(models.Model):
     """
     """
     Dummy model used by BaseFilterSetTest for filter validation. Should never appear in a schema migration.
     Dummy model used by BaseFilterSetTest for filter validation. Should never appear in a schema migration.
@@ -377,8 +440,13 @@ class BaseFilterSetTestCase(TestCase):
         self.assertIsInstance(self.filters['tagfield'], TagFilter)
         self.assertIsInstance(self.filters['tagfield'], TagFilter)
         self.assertEqual(self.filters['tagfield'].lookup_expr, 'exact')
         self.assertEqual(self.filters['tagfield'].lookup_expr, 'exact')
         self.assertEqual(self.filters['tagfield'].exclude, False)
         self.assertEqual(self.filters['tagfield'].exclude, False)
+        self.assertEqual(self.filters['tagfield'].conjoined, True)
         self.assertEqual(self.filters['tagfield__n'].lookup_expr, 'exact')
         self.assertEqual(self.filters['tagfield__n'].lookup_expr, 'exact')
         self.assertEqual(self.filters['tagfield__n'].exclude, True)
         self.assertEqual(self.filters['tagfield__n'].exclude, True)
+        self.assertEqual(self.filters['tagfield__n'].conjoined, True)
+        self.assertEqual(self.filters['tagfield__any'].lookup_expr, 'exact')
+        self.assertEqual(self.filters['tagfield__any'].exclude, False)
+        self.assertEqual(self.filters['tagfield__any'].conjoined, False)
 
 
     def test_tree_node_multiple_choice_filter(self):
     def test_tree_node_multiple_choice_filter(self):
         self.assertIsInstance(self.filters['treeforeignkeyfield'], TreeNodeMultipleChoiceFilter)
         self.assertIsInstance(self.filters['treeforeignkeyfield'], TreeNodeMultipleChoiceFilter)