diff --git a/docs/reference/filtering.md b/docs/reference/filtering.md index 442aa14d8..e6187d3e2 100644 --- a/docs/reference/filtering.md +++ b/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: diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 3ef120fa5..0211805e5 100644 --- a/netbox/netbox/filtersets.py +++ b/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 diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 21e3810d6..b82c5b0af 100644 --- a/netbox/utilities/constants.py +++ b/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' ) diff --git a/netbox/utilities/forms/mixins.py b/netbox/utilities/forms/mixins.py index 3a60884df..d765ca6af 100644 --- a/netbox/utilities/forms/mixins.py +++ b/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')), diff --git a/netbox/utilities/tests/test_filter_modifiers.py b/netbox/utilities/tests/test_filter_modifiers.py index 58bf1245d..301e726c1 100644 --- a/netbox/utilities/tests/test_filter_modifiers.py +++ b/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): diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 6d960fe0b..2319ae5a9 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/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)