Coverage for netbox/filtersets.py: 71%
174 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import json
2from copy import deepcopy
4import django_filters
5from django.contrib.auth.models import AnonymousUser
6from django.contrib.contenttypes.models import ContentType
7from django.db import models
8from django.db.models import Q
9from django.utils.translation import gettext as _
10from django_filters.exceptions import FieldLookupError
11from django_filters.utils import get_model_field, resolve_field
13from core.choices import ObjectChangeActionChoices
14from core.models import ObjectChange
15from extras.choices import CustomFieldFilterLogicChoices
16from extras.filters import TagFilter, TagIDFilter
17from extras.models import CustomField, SavedFilter
18from users.filterset_mixins import OwnerFilterMixin
19from utilities import filters
20from utilities.constants import (
21 FILTER_ARRAY_BASED_LOOKUP_MAP,
22 FILTER_CHAR_BASED_LOOKUP_MAP,
23 FILTER_NEGATION_LOOKUP_MAP,
24 FILTER_NUMERIC_BASED_LOOKUP_MAP,
25 FILTER_TAG_LOOKUP_MAP,
26 FILTER_TREENODE_NEGATION_LOOKUP_MAP,
27)
28from utilities.forms.fields import MACAddressField
30__all__ = (
31 'AttributeFiltersMixin',
32 'BaseFilterSet',
33 'ChangeLoggedModelFilterSet',
34 'NestedGroupModelFilterSet',
35 'NetBoxModelFilterSet',
36 'OrganizationalModelFilterSet',
37 'PrimaryModelFilterSet',
38)
40STANDARD_LOOKUPS = (
41 'exact',
42 'iexact',
43 'in',
44 'contains',
45)
48#
49# FilterSets
50#
52class BaseFilterSet(django_filters.FilterSet):
53 """
54 A base FilterSet which provides some enhanced functionality over django-filter2's FilterSet class.
55 """
56 FILTER_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS)
57 FILTER_DEFAULTS.update({
58 models.AutoField: {
59 'filter_class': filters.MultiValueNumberFilter
60 },
61 models.CharField: {
62 'filter_class': filters.MultiValueCharFilter
63 },
64 models.DateField: {
65 'filter_class': filters.MultiValueDateFilter
66 },
67 models.DateTimeField: {
68 'filter_class': filters.MultiValueDateTimeFilter
69 },
70 models.DecimalField: {
71 'filter_class': filters.MultiValueDecimalFilter
72 },
73 models.EmailField: {
74 'filter_class': filters.MultiValueCharFilter
75 },
76 models.FloatField: {
77 'filter_class': filters.MultiValueNumberFilter
78 },
79 models.IntegerField: {
80 'filter_class': filters.MultiValueNumberFilter
81 },
82 models.PositiveIntegerField: {
83 'filter_class': filters.MultiValueNumberFilter
84 },
85 models.PositiveSmallIntegerField: {
86 'filter_class': filters.MultiValueNumberFilter
87 },
88 models.SlugField: {
89 'filter_class': filters.MultiValueCharFilter
90 },
91 models.SmallIntegerField: {
92 'filter_class': filters.MultiValueNumberFilter
93 },
94 models.TimeField: {
95 'filter_class': filters.MultiValueTimeFilter
96 },
97 models.URLField: {
98 'filter_class': filters.MultiValueCharFilter
99 },
100 MACAddressField: {
101 'filter_class': filters.MultiValueMACAddressFilter
102 },
103 })
105 def __init__(self, data=None, *args, **kwargs):
106 # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready
107 # however FilterSet Factory is setup before this which creates the
108 # initial filters. This recreates the filters so Empty is picked up correctly.
109 self.base_filters = self.__class__.get_filters()
111 # Apply any referenced SavedFilters
112 if data and ('filter' in data or 'filter_id' in data): 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true
113 data = data.copy() # Get a mutable copy
115 # Coerce filter_id values to integers, ignoring any which are not valid (see #22568)
116 filter_ids = []
117 for f_id in data.pop('filter_id', []):
118 try:
119 filter_ids.append(int(f_id))
120 except (ValueError, TypeError):
121 pass
123 # Only apply SavedFilters the requesting user is permitted to see (#22790). Fall back to
124 # anonymous visibility (shared filters only) when no request is available.
125 request = kwargs.get('request')
126 user = request.user if request else AnonymousUser()
127 saved_filters = SavedFilter.objects.restrict_to_shared(user).filter(
128 Q(slug__in=data.pop('filter', [])) |
129 Q(pk__in=filter_ids)
130 )
131 for sf in saved_filters:
132 for key, value in sf.parameters.items():
133 # QueryDicts are... fun
134 if type(value) not in (list, tuple):
135 value = [value]
136 if key in data:
137 for v in value:
138 data.appendlist(key, v)
139 else:
140 data.setlist(key, value)
142 super().__init__(data, *args, **kwargs)
144 @staticmethod
145 def _get_filter_lookup_dict(existing_filter):
146 # Choose the lookup expression map based on the filter type
147 if isinstance(existing_filter, (
148 django_filters.NumberFilter,
149 filters.MultiValueDateFilter,
150 filters.MultiValueDateTimeFilter,
151 filters.MultiValueNumberFilter,
152 filters.MultiValueDecimalFilter,
153 filters.MultiValueTimeFilter
154 )):
155 return FILTER_NUMERIC_BASED_LOOKUP_MAP
157 if isinstance(existing_filter, (
158 filters.TreeNodeMultipleChoiceFilter,
159 )):
160 # TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression
161 return FILTER_TREENODE_NEGATION_LOOKUP_MAP
163 if isinstance(existing_filter, (TagFilter, TagIDFilter)):
164 # Tags additionally support an "any of" (OR) mode, unlike other model choice filters
165 return FILTER_TAG_LOOKUP_MAP
167 if isinstance(existing_filter, (
168 django_filters.ModelChoiceFilter,
169 django_filters.ModelMultipleChoiceFilter,
170 )):
171 # These filter types support only negation
172 return FILTER_NEGATION_LOOKUP_MAP
174 if isinstance(existing_filter, ( 174 ↛ 178line 174 didn't jump to line 178 because the condition on line 174 was never true
175 filters.MultiValueArrayFilter,
176 )):
177 # Must precede the char-based branch below, which would otherwise shadow this subclass
178 return FILTER_ARRAY_BASED_LOOKUP_MAP
180 if isinstance(existing_filter, (
181 django_filters.filters.CharFilter,
182 django_filters.ChoiceFilter,
183 django_filters.MultipleChoiceFilter,
184 filters.MultiValueCharFilter,
185 filters.MultiValueMACAddressFilter
186 )):
187 return FILTER_CHAR_BASED_LOOKUP_MAP
189 return None
191 @classmethod
192 def get_additional_lookups(cls, existing_filter_name, existing_filter):
193 new_filters = {}
195 # Skip on abstract models
196 if not cls._meta.model:
197 return {}
199 # Skip nonstandard lookup expressions
200 if existing_filter.method is not None or existing_filter.lookup_expr not in STANDARD_LOOKUPS:
201 return {}
203 # Choose the lookup expression map based on the filter type
204 lookup_map = cls._get_filter_lookup_dict(existing_filter)
205 if lookup_map is None:
206 # Do not augment this filter type with more lookup expressions
207 return {}
209 # Get properties of the existing filter for later use
210 field_name = existing_filter.field_name
211 field = get_model_field(cls._meta.model, field_name)
213 # Create new filters for each lookup expression in the map
214 for lookup_name, lookup_expr in lookup_map.items():
215 new_filter_name = f'{existing_filter_name}__{lookup_name}'
216 existing_filter_extra = deepcopy(existing_filter.extra)
218 try:
219 if existing_filter_name in cls.declared_filters:
220 # The filter field has been explicitly defined on the filterset class so we must manually
221 # create the new filter with the same type because there is no guarantee the defined type
222 # is the same as the default type for the field
223 if field is None: 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true
224 raise ValueError('Invalid field name/lookup on {}: {}'.format(existing_filter_name, field_name))
225 resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
226 filter_cls = type(existing_filter)
227 if lookup_expr == 'empty':
228 filter_cls = django_filters.BooleanFilter
229 for param_to_remove in ('choices', 'null_value'):
230 existing_filter_extra.pop(param_to_remove, None)
231 new_filter = filter_cls(
232 field_name=field_name,
233 lookup_expr=lookup_expr,
234 label=existing_filter.label,
235 exclude=existing_filter.exclude,
236 distinct=existing_filter.distinct,
237 **existing_filter_extra
238 )
239 elif hasattr(existing_filter, 'custom_field'): 239 ↛ 241line 239 didn't jump to line 241 because the condition on line 239 was never true
240 # Filter is for a custom field
241 custom_field = existing_filter.custom_field
242 new_filter = custom_field.to_filter(lookup_expr=lookup_expr)
243 else:
244 # The filter field is listed in Meta.fields so we can safely rely on default behaviour
245 # Will raise FieldLookupError if the lookup is invalid
246 new_filter = cls.filter_for_field(field, field_name, lookup_expr)
247 except FieldLookupError:
248 # The filter could not be created because the lookup expression is not supported on the field
249 continue
251 if lookup_name.startswith('n'):
252 # This is a negation filter which requires a queryset.exclude() clause
253 # Of course setting the negation of the existing filter's exclude attribute handles both cases
254 new_filter.exclude = not existing_filter.exclude
256 if lookup_name == 'any' and isinstance(new_filter, (TagFilter, TagIDFilter)):
257 # "Any of" is an OR match, whereas TagFilter/TagIDFilter default to AND (conjoined=True)
258 new_filter.conjoined = False
260 new_filters[new_filter_name] = new_filter
262 return new_filters
264 @classmethod
265 def get_filters(cls):
266 """
267 Override filter generation to support dynamic lookup expressions for certain filter types.
269 For specific filter types, new filters are created based on defined lookup expressions in
270 the form `<field_name>__<lookup_expr>`
271 """
272 filters = super().get_filters()
274 additional_filters = {}
275 for existing_filter_name, existing_filter in filters.items():
276 additional_filters.update(cls.get_additional_lookups(existing_filter_name, existing_filter))
278 filters.update(additional_filters)
280 return filters
282 @classmethod
283 def filter_for_lookup(cls, field, lookup_type):
285 if lookup_type == 'empty':
286 return django_filters.BooleanFilter, {}
288 return super().filter_for_lookup(field, lookup_type)
291class ChangeLoggedModelFilterSet(BaseFilterSet):
292 """
293 Base FilterSet for ChangeLoggedModel classes.
294 """
295 created = filters.MultiValueDateTimeFilter()
296 last_updated = filters.MultiValueDateTimeFilter()
297 created_by_request = django_filters.UUIDFilter(
298 method='filter_by_request'
299 )
300 updated_by_request = django_filters.UUIDFilter(
301 method='filter_by_request'
302 )
303 modified_by_request = django_filters.UUIDFilter(
304 method='filter_by_request'
305 )
307 def filter_by_request(self, queryset, name, value):
308 content_type = ContentType.objects.get_for_model(self.Meta.model)
309 action = {
310 'created_by_request': Q(action=ObjectChangeActionChoices.ACTION_CREATE),
311 'updated_by_request': Q(action=ObjectChangeActionChoices.ACTION_UPDATE),
312 'modified_by_request': Q(
313 action__in=[ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE]
314 ),
315 }.get(name)
316 request_id = value
317 pks = ObjectChange.objects.filter(
318 action,
319 changed_object_type=content_type,
320 request_id=request_id,
321 ).values_list('changed_object_id', flat=True)
322 return queryset.filter(pk__in=pks)
325class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
326 """
327 Provides additional filtering functionality (e.g. tags, custom fields) for core NetBox models.
328 """
329 q = django_filters.CharFilter(
330 method='search',
331 label=_('Search'),
332 )
333 tag = TagFilter()
334 tag_id = TagIDFilter()
336 def __init__(self, *args, **kwargs):
337 super().__init__(*args, **kwargs)
339 custom_field_filters = {}
340 for custom_field in CustomField.objects.get_for_model(self._meta.model): 340 ↛ 341line 340 didn't jump to line 341 because the loop on line 340 never started
341 if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_DISABLED:
342 # Skip disabled fields
343 continue
344 if filter_instance := custom_field.to_filter():
345 filter_name = f'cf_{custom_field.name}'
346 custom_field_filters[filter_name] = filter_instance
348 # Add relevant additional lookups
349 additional_lookups = self.get_additional_lookups(filter_name, filter_instance)
350 custom_field_filters.update(additional_lookups)
352 self.filters.update(custom_field_filters)
354 def search(self, queryset, name, value):
355 """
356 Override this method to apply a general-purpose search logic.
357 """
358 return queryset
361class PrimaryModelFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
362 """
363 Base filterset for models inheriting from PrimaryModel.
364 """
365 pass
368class OrganizationalModelFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
369 """
370 Base filterset for models inheriting from OrganizationalModel.
371 """
372 def search(self, queryset, name, value):
373 if not value.strip():
374 return queryset
375 return queryset.filter(
376 models.Q(name__icontains=value) |
377 models.Q(slug__icontains=value) |
378 models.Q(description__icontains=value)
379 )
382class NestedGroupModelFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
383 """
384 Base filterset for models inheriting from NestedGroupModel.
385 """
386 def search(self, queryset, name, value):
387 if value.strip():
388 queryset = queryset.filter(
389 models.Q(name__icontains=value) |
390 models.Q(slug__icontains=value) |
391 models.Q(description__icontains=value) |
392 models.Q(comments__icontains=value)
393 )
395 return queryset
398class AttributeFiltersMixin:
399 attributes_field_name = 'attribute_data'
400 attribute_filter_prefix = 'attr_'
402 def __init__(self, data=None, queryset=None, *, request=None, prefix=None):
403 self.attr_filters = {}
405 # Extract JSONField-based filters from the incoming data
406 if data is not None: 406 ↛ 415line 406 didn't jump to line 415 because the condition on line 406 was always true
407 for key, value in data.items():
408 if field := self._get_field_lookup(key): 408 ↛ 410line 408 didn't jump to line 410 because the condition on line 408 was never true
409 # Attempt to cast the value to a native JSON type
410 try:
411 self.attr_filters[field] = json.loads(value)
412 except (ValueError, json.JSONDecodeError):
413 self.attr_filters[field] = value
415 super().__init__(data=data, queryset=queryset, request=request, prefix=prefix)
417 def _get_field_lookup(self, key):
418 if not key.startswith(self.attribute_filter_prefix): 418 ↛ 420line 418 didn't jump to line 420 because the condition on line 418 was always true
419 return None
420 lookup = key.split(self.attribute_filter_prefix, 1)[1] # Strip prefix
421 return f'{self.attributes_field_name}__{lookup}'
423 def filter_queryset(self, queryset):
424 return super().filter_queryset(queryset).filter(**self.attr_filters)