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

This commit is contained in:
bctiemann 2026-07-22 08:20:09 -04:00 committed by GitHub
parent 64519b722e
commit abe4a2cd9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 103 additions and 4 deletions

View File

@ -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:

View File

@ -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

View File

@ -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'
)

View File

@ -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')),

View File

@ -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):

View File

@ -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)