Kaynağa Gözat

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

bctiemann 6 gün önce
ebeveyn
işleme
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
 ```
 
+### 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
 
 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_NEGATION_LOOKUP_MAP,
     FILTER_NUMERIC_BASED_LOOKUP_MAP,
+    FILTER_TAG_LOOKUP_MAP,
     FILTER_TREENODE_NEGATION_LOOKUP_MAP,
 )
 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
             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, (
             django_filters.ModelChoiceFilter,
             django_filters.ModelMultipleChoiceFilter,
-            TagFilter
         )):
             # These filter types support only negation
             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
                 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
 
         return new_filters

+ 5 - 0
netbox/utilities/constants.py

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

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

@@ -80,6 +80,7 @@ FORM_FIELD_LOOKUPS = {
     ],
     TagFilterField: [
         ('exact', _('has these tags')),
+        ('any', _('has any of these tags')),
         ('n', _('does not have these tags')),
         (MODIFIER_EMPTY_TRUE, _('is 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)
         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)
 
     def test_mixin_enhances_integer_field(self):

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

@@ -23,7 +23,7 @@ from dcim.models import (
     Site,
 )
 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.models import ASN, RIR
 from netbox.filtersets import BaseFilterSet
@@ -96,6 +96,69 @@ class TreeNodeMultipleChoiceFilterTestCase(TestCase):
         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):
     """
     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.assertEqual(self.filters['tagfield'].lookup_expr, 'exact')
         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'].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):
         self.assertIsInstance(self.filters['treeforeignkeyfield'], TreeNodeMultipleChoiceFilter)