Closes #22685: Add "any" lookup for tag & tag_id filters
This commit is contained in:
parent
64519b722e
commit
abe4a2cd9e
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue