diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 034a99ac9..7e0b1cade 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -5,9 +5,8 @@ from dcim.filters import CableTerminationFilterSet from dcim.models import Region, Site, SiteGroup from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet -from utilities.filters import ( - BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter -) +from utilities.filters import TagFilter, TreeNodeMultipleChoiceFilter +from utilities.filtersets import BaseFilterSet, NameSlugSearchFilterSet from .choices import * from .models import * diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 29c4281ba..777ca5884 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -6,9 +6,9 @@ from tenancy.filters import TenancyFilterSet from tenancy.models import Tenant from utilities.choices import ColorChoices from utilities.filters import ( - BaseFilterSet, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, - NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter, + MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, TagFilter, TreeNodeMultipleChoiceFilter, ) +from utilities.filtersets import BaseFilterSet, NameSlugSearchFilterSet from virtualization.models import Cluster from .choices import * from .constants import * diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index aacdbda6b..9105da81f 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -6,7 +6,8 @@ from django.forms import DateField, IntegerField, NullBooleanField from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup from tenancy.models import Tenant, TenantGroup -from utilities.filters import BaseFilterSet, ContentTypeFilter +from utilities.filtersets import BaseFilterSet +from utilities.filters import ContentTypeFilter from virtualization.models import Cluster, ClusterGroup from .choices import * from .models import * diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 8f4030411..af047424f 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -9,9 +9,10 @@ from dcim.models import Device, Interface, Region, Site, SiteGroup from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( - BaseFilterSet, ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, - NumericArrayFilter, TagFilter, TreeNodeMultipleChoiceFilter, + ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TagFilter, + TreeNodeMultipleChoiceFilter, ) +from utilities.filtersets import BaseFilterSet, NameSlugSearchFilterSet from virtualization.models import VirtualMachine, VMInterface from .choices import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index fb36c827a..ec41ac364 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -3,7 +3,8 @@ from django.db.models import Q from dcim.models import Device from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet -from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter +from utilities.filters import TagFilter +from utilities.filtersets import BaseFilterSet, NameSlugSearchFilterSet from virtualization.models import VirtualMachine from .models import Secret, SecretRole diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 0581866a4..8d43a3794 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -2,7 +2,8 @@ import django_filters from django.db.models import Q from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet -from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter +from utilities.filters import TagFilter, TreeNodeMultipleChoiceFilter +from utilities.filtersets import BaseFilterSet, NameSlugSearchFilterSet from .models import Tenant, TenantGroup diff --git a/netbox/users/filters.py b/netbox/users/filters.py index 359cf9cc7..42f97bedc 100644 --- a/netbox/users/filters.py +++ b/netbox/users/filters.py @@ -3,7 +3,7 @@ from django.contrib.auth.models import Group, User from django.db.models import Q from users.models import ObjectPermission -from utilities.filters import BaseFilterSet +from utilities.filtersets import BaseFilterSet __all__ = ( 'GroupFilterSet', diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 6305c0bba..97608ed22 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -1,17 +1,10 @@ import django_filters -from django_filters.constants import EMPTY_VALUES -from copy import deepcopy -from dcim.forms import MACAddressField from django import forms from django.conf import settings -from django.db import models -from django_filters.utils import get_model_field, resolve_field +from django_filters.constants import EMPTY_VALUES +from dcim.forms import MACAddressField from extras.models import Tag -from utilities.constants import ( - FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP, - FILTER_NUMERIC_BASED_LOOKUP_MAP -) def multivalue_field_factory(field_class): @@ -134,182 +127,3 @@ class ContentTypeFilter(django_filters.CharFilter): f'{self.field_name}__model': model } ) - - -# -# FilterSets -# - -class BaseFilterSet(django_filters.FilterSet): - """ - A base filterset which provides common functionaly to all NetBox filtersets - """ - FILTER_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS) - FILTER_DEFAULTS.update({ - models.AutoField: { - 'filter_class': MultiValueNumberFilter - }, - models.CharField: { - 'filter_class': MultiValueCharFilter - }, - models.DateField: { - 'filter_class': MultiValueDateFilter - }, - models.DateTimeField: { - 'filter_class': MultiValueDateTimeFilter - }, - models.DecimalField: { - 'filter_class': MultiValueNumberFilter - }, - models.EmailField: { - 'filter_class': MultiValueCharFilter - }, - models.FloatField: { - 'filter_class': MultiValueNumberFilter - }, - models.IntegerField: { - 'filter_class': MultiValueNumberFilter - }, - models.PositiveIntegerField: { - 'filter_class': MultiValueNumberFilter - }, - models.PositiveSmallIntegerField: { - 'filter_class': MultiValueNumberFilter - }, - models.SlugField: { - 'filter_class': MultiValueCharFilter - }, - models.SmallIntegerField: { - 'filter_class': MultiValueNumberFilter - }, - models.TimeField: { - 'filter_class': MultiValueTimeFilter - }, - models.URLField: { - 'filter_class': MultiValueCharFilter - }, - MACAddressField: { - 'filter_class': MultiValueMACAddressFilter - }, - }) - - @staticmethod - def _get_filter_lookup_dict(existing_filter): - # Choose the lookup expression map based on the filter type - if isinstance(existing_filter, ( - MultiValueDateFilter, - MultiValueDateTimeFilter, - MultiValueNumberFilter, - MultiValueTimeFilter - )): - lookup_map = FILTER_NUMERIC_BASED_LOOKUP_MAP - - elif isinstance(existing_filter, ( - TreeNodeMultipleChoiceFilter, - )): - # TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression - lookup_map = FILTER_TREENODE_NEGATION_LOOKUP_MAP - - elif isinstance(existing_filter, ( - django_filters.ModelChoiceFilter, - django_filters.ModelMultipleChoiceFilter, - TagFilter - )) or existing_filter.extra.get('choices'): - # These filter types support only negation - lookup_map = FILTER_NEGATION_LOOKUP_MAP - - elif isinstance(existing_filter, ( - django_filters.filters.CharFilter, - django_filters.MultipleChoiceFilter, - MultiValueCharFilter, - MultiValueMACAddressFilter - )): - lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP - - else: - lookup_map = None - - return lookup_map - - @classmethod - def get_filters(cls): - """ - Override filter generation to support dynamic lookup expressions for certain filter types. - - For specific filter types, new filters are created based on defined lookup expressions in - the form `__` - """ - filters = super().get_filters() - - new_filters = {} - for existing_filter_name, existing_filter in filters.items(): - # Loop over existing filters to extract metadata by which to create new filters - - # If the filter makes use of a custom filter method or lookup expression skip it - # as we cannot sanely handle these cases in a generic mannor - if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']: - continue - - # Choose the lookup expression map based on the filter type - lookup_map = cls._get_filter_lookup_dict(existing_filter) - if lookup_map is None: - # Do not augment this filter type with more lookup expressions - continue - - # Get properties of the existing filter for later use - field_name = existing_filter.field_name - field = get_model_field(cls._meta.model, field_name) - - # Create new filters for each lookup expression in the map - for lookup_name, lookup_expr in lookup_map.items(): - new_filter_name = '{}__{}'.format(existing_filter_name, lookup_name) - - try: - if existing_filter_name in cls.declared_filters: - # The filter field has been explicity defined on the filterset class so we must manually - # create the new filter with the same type because there is no guarantee the defined type - # is the same as the default type for the field - resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid - new_filter = type(existing_filter)( - field_name=field_name, - lookup_expr=lookup_expr, - label=existing_filter.label, - exclude=existing_filter.exclude, - distinct=existing_filter.distinct, - **existing_filter.extra - ) - else: - # The filter field is listed in Meta.fields so we can safely rely on default behaviour - # Will raise FieldLookupError if the lookup is invalid - new_filter = cls.filter_for_field(field, field_name, lookup_expr) - except django_filters.exceptions.FieldLookupError: - # The filter could not be created because the lookup expression is not supported on the field - continue - - if lookup_name.startswith('n'): - # This is a negation filter which requires a queryset.exclude() clause - # Of course setting the negation of the existing filter's exclude attribute handles both cases - new_filter.exclude = not existing_filter.exclude - - new_filters[new_filter_name] = new_filter - - filters.update(new_filters) - return filters - - -class NameSlugSearchFilterSet(django_filters.FilterSet): - """ - A base class for adding the search method to models which only expose the `name` and `slug` fields - """ - q = django_filters.CharFilter( - method='search', - label='Search', - ) - - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - models.Q(name__icontains=value) | - models.Q(slug__icontains=value) - ) diff --git a/netbox/utilities/filtersets.py b/netbox/utilities/filtersets.py new file mode 100644 index 000000000..0fb188d11 --- /dev/null +++ b/netbox/utilities/filtersets.py @@ -0,0 +1,190 @@ +import django_filters +from copy import deepcopy +from dcim.forms import MACAddressField +from django.db import models +from django_filters.utils import get_model_field, resolve_field + +from utilities.constants import ( + FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP, + FILTER_NUMERIC_BASED_LOOKUP_MAP +) +from utilities import filters + + +# +# FilterSets +# + +class BaseFilterSet(django_filters.FilterSet): + """ + A base filterset which provides common functionaly to all NetBox filtersets + """ + FILTER_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS) + FILTER_DEFAULTS.update({ + models.AutoField: { + 'filter_class': filters.MultiValueNumberFilter + }, + models.CharField: { + 'filter_class': filters.MultiValueCharFilter + }, + models.DateField: { + 'filter_class': filters.MultiValueDateFilter + }, + models.DateTimeField: { + 'filter_class': filters.MultiValueDateTimeFilter + }, + models.DecimalField: { + 'filter_class': filters.MultiValueNumberFilter + }, + models.EmailField: { + 'filter_class': filters.MultiValueCharFilter + }, + models.FloatField: { + 'filter_class': filters.MultiValueNumberFilter + }, + models.IntegerField: { + 'filter_class': filters.MultiValueNumberFilter + }, + models.PositiveIntegerField: { + 'filter_class': filters.MultiValueNumberFilter + }, + models.PositiveSmallIntegerField: { + 'filter_class': filters.MultiValueNumberFilter + }, + models.SlugField: { + 'filter_class': filters.MultiValueCharFilter + }, + models.SmallIntegerField: { + 'filter_class': filters.MultiValueNumberFilter + }, + models.TimeField: { + 'filter_class': filters.MultiValueTimeFilter + }, + models.URLField: { + 'filter_class': filters.MultiValueCharFilter + }, + MACAddressField: { + 'filter_class': filters.MultiValueMACAddressFilter + }, + }) + + @staticmethod + def _get_filter_lookup_dict(existing_filter): + # Choose the lookup expression map based on the filter type + if isinstance(existing_filter, ( + filters.MultiValueDateFilter, + filters.MultiValueDateTimeFilter, + filters.MultiValueNumberFilter, + filters.MultiValueTimeFilter + )): + lookup_map = FILTER_NUMERIC_BASED_LOOKUP_MAP + + elif isinstance(existing_filter, ( + filters.TreeNodeMultipleChoiceFilter, + )): + # TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression + lookup_map = FILTER_TREENODE_NEGATION_LOOKUP_MAP + + elif isinstance(existing_filter, ( + django_filters.ModelChoiceFilter, + django_filters.ModelMultipleChoiceFilter, + filters.TagFilter + )) or existing_filter.extra.get('choices'): + # These filter types support only negation + lookup_map = FILTER_NEGATION_LOOKUP_MAP + + elif isinstance(existing_filter, ( + django_filters.filters.CharFilter, + django_filters.MultipleChoiceFilter, + filters.MultiValueCharFilter, + filters.MultiValueMACAddressFilter + )): + lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP + + else: + lookup_map = None + + return lookup_map + + @classmethod + def get_filters(cls): + """ + Override filter generation to support dynamic lookup expressions for certain filter types. + + For specific filter types, new filters are created based on defined lookup expressions in + the form `__` + """ + filters = super().get_filters() + + new_filters = {} + for existing_filter_name, existing_filter in filters.items(): + # Loop over existing filters to extract metadata by which to create new filters + + # If the filter makes use of a custom filter method or lookup expression skip it + # as we cannot sanely handle these cases in a generic mannor + if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']: + continue + + # Choose the lookup expression map based on the filter type + lookup_map = cls._get_filter_lookup_dict(existing_filter) + if lookup_map is None: + # Do not augment this filter type with more lookup expressions + continue + + # Get properties of the existing filter for later use + field_name = existing_filter.field_name + field = get_model_field(cls._meta.model, field_name) + + # Create new filters for each lookup expression in the map + for lookup_name, lookup_expr in lookup_map.items(): + new_filter_name = '{}__{}'.format(existing_filter_name, lookup_name) + + try: + if existing_filter_name in cls.declared_filters: + # The filter field has been explicity defined on the filterset class so we must manually + # create the new filter with the same type because there is no guarantee the defined type + # is the same as the default type for the field + resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid + new_filter = type(existing_filter)( + field_name=field_name, + lookup_expr=lookup_expr, + label=existing_filter.label, + exclude=existing_filter.exclude, + distinct=existing_filter.distinct, + **existing_filter.extra + ) + else: + # The filter field is listed in Meta.fields so we can safely rely on default behaviour + # Will raise FieldLookupError if the lookup is invalid + new_filter = cls.filter_for_field(field, field_name, lookup_expr) + except django_filters.exceptions.FieldLookupError: + # The filter could not be created because the lookup expression is not supported on the field + continue + + if lookup_name.startswith('n'): + # This is a negation filter which requires a queryset.exclude() clause + # Of course setting the negation of the existing filter's exclude attribute handles both cases + new_filter.exclude = not existing_filter.exclude + + new_filters[new_filter_name] = new_filter + + filters.update(new_filters) + return filters + + +class NameSlugSearchFilterSet(django_filters.FilterSet): + """ + A base class for adding the search method to models which only expose the `name` and `slug` fields + """ + q = django_filters.CharFilter( + method='search', + label='Search', + ) + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + models.Q(name__icontains=value) | + models.Q(slug__icontains=value) + ) diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 56eaabd4c..6d2826b70 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -13,9 +13,10 @@ from dcim.models import ( ) from extras.models import TaggedItem from utilities.filters import ( - BaseFilterSet, MACAddressFilter, MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter, - MultiValueNumberFilter, MultiValueTimeFilter, TagFilter, TreeNodeMultipleChoiceFilter, + MACAddressFilter, MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter, MultiValueNumberFilter, + MultiValueTimeFilter, TagFilter, TreeNodeMultipleChoiceFilter, ) +from utilities.filtersets import BaseFilterSet class TreeNodeMultipleChoiceFilterTest(TestCase): diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 6d706b6cf..9ace5c49e 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -4,10 +4,8 @@ from django.db.models import Q from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet from tenancy.filters import TenancyFilterSet -from utilities.filters import ( - BaseFilterSet, MultiValueMACAddressFilter, NameSlugSearchFilterSet, TagFilter, - TreeNodeMultipleChoiceFilter, -) +from utilities.filters import MultiValueMACAddressFilter, TagFilter, TreeNodeMultipleChoiceFilter +from utilities.filtersets import BaseFilterSet, NameSlugSearchFilterSet from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface