From a311002141708ac6e1d987e7f35e467b9d9434e1 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Sun, 9 Feb 2020 03:20:59 -0500 Subject: [PATCH 1/9] initial work on dynamic lookup expressions --- netbox/circuits/filters.py | 12 +++-- netbox/dcim/filters.py | 44 ++++++++--------- netbox/extras/filters.py | 13 ++--- netbox/tenancy/filters.py | 2 +- netbox/utilities/constants.py | 38 +++++++++++++++ netbox/utilities/filters.py | 91 +++++++++++++++++++++++++++++++++++ 6 files changed, 166 insertions(+), 34 deletions(-) diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index c27ffb8d7..b0c4cacbd 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -4,7 +4,9 @@ from django.db.models import Q from dcim.models import Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet -from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter +from utilities.filters import ( + BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter +) from .choices import * from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -16,7 +18,7 @@ __all__ = ( ) -class ProviderFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): +class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -65,14 +67,14 @@ class ProviderFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): ) -class CircuitTypeFilterSet(NameSlugSearchFilterSet): +class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = CircuitType fields = ['id', 'name', 'slug'] -class CircuitFilterSet(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet): +class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -146,7 +148,7 @@ class CircuitFilterSet(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFil ).distinct() -class CircuitTerminationFilterSet(django_filters.FilterSet): +class CircuitTerminationFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 7b278ca0e..d9658a28e 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -6,8 +6,8 @@ from tenancy.filters import TenancyFilterSet from tenancy.models import Tenant from utilities.constants import COLOR_CHOICES from utilities.filters import ( - MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, - TagFilter, TreeNodeMultipleChoiceFilter, + BaseFilterSet, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, + BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster from .choices import * @@ -60,7 +60,7 @@ __all__ = ( ) -class RegionFilterSet(NameSlugSearchFilterSet): +class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), label='Parent region (ID)', @@ -77,7 +77,7 @@ class RegionFilterSet(NameSlugSearchFilterSet): fields = ['id', 'name', 'slug'] -class SiteFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class SiteFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -131,7 +131,7 @@ class SiteFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter return queryset.filter(qs_filter) -class RackGroupFilterSet(NameSlugSearchFilterSet): +class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region__in', @@ -159,14 +159,14 @@ class RackGroupFilterSet(NameSlugSearchFilterSet): fields = ['id', 'name', 'slug'] -class RackRoleFilterSet(NameSlugSearchFilterSet): +class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = RackRole fields = ['id', 'name', 'slug', 'color'] -class RackFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class RackFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -244,7 +244,7 @@ class RackFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter ) -class RackReservationFilterSet(TenancyFilterSet): +class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -305,14 +305,14 @@ class RackReservationFilterSet(TenancyFilterSet): ) -class ManufacturerFilterSet(NameSlugSearchFilterSet): +class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = Manufacturer fields = ['id', 'name', 'slug'] -class DeviceTypeFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): +class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -402,7 +402,7 @@ class DeviceTypeFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): return queryset.exclude(device_bay_templates__isnull=value) -class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet): +class DeviceTypeComponentFilterSet(BaseFilterSet, NameSlugSearchFilterSet): devicetype_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), field_name='device_type_id', @@ -466,14 +466,14 @@ class DeviceBayTemplateFilterSet(DeviceTypeComponentFilterSet): fields = ['id', 'name'] -class DeviceRoleFilterSet(NameSlugSearchFilterSet): +class DeviceRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = DeviceRole fields = ['id', 'name', 'slug', 'color', 'vm_role'] -class PlatformFilterSet(NameSlugSearchFilterSet): +class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer', queryset=Manufacturer.objects.all(), @@ -491,7 +491,7 @@ class PlatformFilterSet(NameSlugSearchFilterSet): fields = ['id', 'name', 'slug', 'napalm_driver'] -class DeviceFilterSet(LocalConfigContextFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class DeviceFilterSet(BaseFilterSet, LocalConfigContextFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -690,7 +690,7 @@ class DeviceFilterSet(LocalConfigContextFilterSet, TenancyFilterSet, CustomField return queryset.exclude(device_bays__isnull=value) -class DeviceComponentFilterSet(django_filters.FilterSet): +class DeviceComponentFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1002,7 +1002,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet): return queryset.filter(qs_filter) -class VirtualChassisFilterSet(django_filters.FilterSet): +class VirtualChassisFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1056,7 +1056,7 @@ class VirtualChassisFilterSet(django_filters.FilterSet): return queryset.filter(qs_filter) -class CableFilterSet(django_filters.FilterSet): +class CableFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1119,7 +1119,7 @@ class CableFilterSet(django_filters.FilterSet): return queryset -class ConsoleConnectionFilterSet(django_filters.FilterSet): +class ConsoleConnectionFilterSet(BaseFilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', @@ -1150,7 +1150,7 @@ class ConsoleConnectionFilterSet(django_filters.FilterSet): ) -class PowerConnectionFilterSet(django_filters.FilterSet): +class PowerConnectionFilterSet(BaseFilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', @@ -1181,7 +1181,7 @@ class PowerConnectionFilterSet(django_filters.FilterSet): ) -class InterfaceConnectionFilterSet(django_filters.FilterSet): +class InterfaceConnectionFilterSet(BaseFilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', @@ -1215,7 +1215,7 @@ class InterfaceConnectionFilterSet(django_filters.FilterSet): ) -class PowerPanelFilterSet(django_filters.FilterSet): +class PowerPanelFilterSet(BaseFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -1264,7 +1264,7 @@ class PowerPanelFilterSet(django_filters.FilterSet): return queryset.filter(qs_filter) -class PowerFeedFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): +class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index dcd4f3ede..ccd723a0c 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -4,6 +4,7 @@ from django.db.models import Q from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup +from utilities.filters import BaseFilterSet from virtualization.models import Cluster, ClusterGroup from .choices import * from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag @@ -89,21 +90,21 @@ class CustomFieldFilterSet(django_filters.FilterSet): self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf) -class GraphFilterSet(django_filters.FilterSet): +class GraphFilterSet(BaseFilterSet): class Meta: model = Graph fields = ['type', 'name', 'template_language'] -class ExportTemplateFilterSet(django_filters.FilterSet): +class ExportTemplateFilterSet(BaseFilterSet): class Meta: model = ExportTemplate fields = ['content_type', 'name', 'template_language'] -class TagFilterSet(django_filters.FilterSet): +class TagFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -122,7 +123,7 @@ class TagFilterSet(django_filters.FilterSet): ) -class ConfigContextFilterSet(django_filters.FilterSet): +class ConfigContextFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -234,7 +235,7 @@ class ConfigContextFilterSet(django_filters.FilterSet): # Filter for Local Config Context Data # -class LocalConfigContextFilterSet(django_filters.FilterSet): +class LocalConfigContextFilterSet(BaseFilterSet): local_context_data = django_filters.BooleanFilter( method='_local_context_data', label='Has local config context data', @@ -244,7 +245,7 @@ class LocalConfigContextFilterSet(django_filters.FilterSet): return queryset.exclude(local_context_data__isnull=value) -class ObjectChangeFilterSet(django_filters.FilterSet): +class ObjectChangeFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 001cf29e7..fa40e3986 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -2,7 +2,7 @@ import django_filters from django.db.models import Q from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet -from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter +from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter from .models import Tenant, TenantGroup diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index ad6e8fd90..d7f819e8c 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -27,3 +27,41 @@ COLOR_CHOICES = ( ('111111', 'Black'), ('ffffff', 'White'), ) + + +# +# Filter lookup expressions +# + +FILTER_CHAR_BASED_LOOKUP_MAP = dict( + n='exact', + ic='icontains', + nic='icontains', + iew='iendswith', + niew='iendswith', + isw='istartswith', + nisw='istartswith', + ie='iexact', + nie='iexact' +) + +FILTER_NUMERIC_BASED_LOOKUP_MAP = dict( + n='exact', + lte='lte', + lt='lt', + gte='gte', + gt='gt' +) + +FILTER_LOOKUP_HELP_TEXT_MAP = dict( + icontains='case insensitive contains', + iendswith='case insensitive ends with', + istartswith='case insensitive starts with', + iexact='case insensitive exact', + exact='case sensitive exact', + lt='less than', + lte='less than or equal', + gt='greater than', + gte='greater than or equal', + n='negated' +) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 957020e40..edb7bafa0 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -3,7 +3,12 @@ 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 + from extras.models import Tag +from utilities.constants import ( + FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_LOOKUP_HELP_TEXT_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP +) def multivalue_field_factory(field_class): @@ -111,6 +116,92 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter): # FilterSets # +class BaseFilterSet(django_filters.FilterSet): + """ + A base filterset which provides common functionaly to all NetBox filtersets + """ + @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 + + # It 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 != 'exact': + continue + + # Choose the lookup expression map based on the filter type + if isinstance(existing_filter, ( + django_filters.filters.CharFilter, + django_filters.MultipleChoiceFilter, + MultiValueCharFilter, + MultiValueMACAddressFilter, + TagFilter + )): + lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP + + elif isinstance(existing_filter, ( + MultiValueDateFilter, + MultiValueDateTimeFilter, + MultiValueNumberFilter, + MultiValueTimeFilter + )): + lookup_map = FILTER_NUMERIC_BASED_LOOKUP_MAP + + elif isinstance(existing_filter, ( + django_filters.ModelChoiceFilter, + django_filters.ModelMultipleChoiceFilter, + NumericInFilter, + TreeNodeMultipleChoiceFilter, + )): + # These filter types support only negation + lookup_map = dict( + n='exact' + ) + + else: + # Do no augment any other filter types 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: + print(existing_filter_name) + 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 + continue + + help_text = FILTER_LOOKUP_HELP_TEXT_MAP[lookup_expr] + + if lookup_name.startswith('n'): + # This is a negation filter which requires a queryselt.exclud() clause + new_filter.exclude = True + help_text = 'negated {}'.format(help_text) + + new_filter.extra = existing_filter.extra + new_filter.extra['help_text'] = '{} - {}'.format(field_name, help_text) + 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 From a6b43b30e9f59f30a3365fb6a0e80a6edb4954af Mon Sep 17 00:00:00 2001 From: John Anderson Date: Sun, 9 Feb 2020 17:46:21 -0500 Subject: [PATCH 2/9] functional dynamic filter lookups --- netbox/circuits/filters.py | 12 ++- netbox/dcim/filters.py | 94 +++++++++++-------- netbox/extras/filters.py | 2 +- netbox/extras/tests/test_filters.py | 12 +-- netbox/ipam/filters.py | 39 ++++---- netbox/secrets/filters.py | 6 +- netbox/tenancy/filters.py | 4 +- netbox/utilities/filters.py | 134 ++++++++++++++-------------- netbox/virtualization/filters.py | 24 +++-- 9 files changed, 183 insertions(+), 144 deletions(-) diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index b0c4cacbd..4bd5fa158 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -29,12 +29,14 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilte ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='circuits__terminations__site__region__in', + field_name='circuits__terminations__site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='circuits__terminations__site__region__in', + field_name='circuits__terminations__site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -120,12 +122,14 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, Cr ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='terminations__site__region__in', + field_name='terminations__site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='terminations__site__region__in', + field_name='terminations__site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index d9658a28e..0d7c440c2 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -7,7 +7,7 @@ from tenancy.models import Tenant from utilities.constants import COLOR_CHOICES from utilities.filters import ( BaseFilterSet, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, - BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, + NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster from .choices import * @@ -92,12 +92,14 @@ class SiteFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='region__in', + field_name='region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='region__in', + field_name='region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -134,12 +136,14 @@ class SiteFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -177,12 +181,14 @@ class RackFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -402,7 +408,7 @@ class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFil return queryset.exclude(device_bay_templates__isnull=value) -class DeviceTypeComponentFilterSet(BaseFilterSet, NameSlugSearchFilterSet): +class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet): devicetype_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), field_name='device_type_id', @@ -410,56 +416,56 @@ class DeviceTypeComponentFilterSet(BaseFilterSet, NameSlugSearchFilterSet): ) -class ConsolePortTemplateFilterSet(DeviceTypeComponentFilterSet): +class ConsolePortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class Meta: model = ConsolePortTemplate fields = ['id', 'name', 'type'] -class ConsoleServerPortTemplateFilterSet(DeviceTypeComponentFilterSet): +class ConsoleServerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class Meta: model = ConsoleServerPortTemplate fields = ['id', 'name', 'type'] -class PowerPortTemplateFilterSet(DeviceTypeComponentFilterSet): +class PowerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class Meta: model = PowerPortTemplate fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw'] -class PowerOutletTemplateFilterSet(DeviceTypeComponentFilterSet): +class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class Meta: model = PowerOutletTemplate fields = ['id', 'name', 'type', 'feed_leg'] -class InterfaceTemplateFilterSet(DeviceTypeComponentFilterSet): +class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class Meta: model = InterfaceTemplate fields = ['id', 'name', 'type', 'mgmt_only'] -class FrontPortTemplateFilterSet(DeviceTypeComponentFilterSet): +class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class Meta: model = FrontPortTemplate fields = ['id', 'name', 'type'] -class RearPortTemplateFilterSet(DeviceTypeComponentFilterSet): +class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class Meta: model = RearPortTemplate fields = ['id', 'name', 'type', 'positions'] -class DeviceBayTemplateFilterSet(DeviceTypeComponentFilterSet): +class DeviceBayTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class Meta: model = DeviceBayTemplate @@ -538,12 +544,14 @@ class DeviceFilterSet(BaseFilterSet, LocalConfigContextFilterSet, CustomFieldFil ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -690,19 +698,21 @@ class DeviceFilterSet(BaseFilterSet, LocalConfigContextFilterSet, CustomFieldFil return queryset.exclude(device_bays__isnull=value) -class DeviceComponentFilterSet(BaseFilterSet): +class DeviceComponentFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( method='search', label='Search', ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='device__site__region__in', + field_name='device__site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='device__site__region__in', + field_name='device__site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -738,7 +748,7 @@ class DeviceComponentFilterSet(BaseFilterSet): ) -class ConsolePortFilterSet(DeviceComponentFilterSet): +class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -754,7 +764,7 @@ class ConsolePortFilterSet(DeviceComponentFilterSet): fields = ['id', 'name', 'description', 'connection_status'] -class ConsoleServerPortFilterSet(DeviceComponentFilterSet): +class ConsoleServerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -770,7 +780,7 @@ class ConsoleServerPortFilterSet(DeviceComponentFilterSet): fields = ['id', 'name', 'description', 'connection_status'] -class PowerPortFilterSet(DeviceComponentFilterSet): +class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerPortTypeChoices, null_value=None @@ -786,7 +796,7 @@ class PowerPortFilterSet(DeviceComponentFilterSet): fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connection_status'] -class PowerOutletFilterSet(DeviceComponentFilterSet): +class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerOutletTypeChoices, null_value=None @@ -802,7 +812,7 @@ class PowerOutletFilterSet(DeviceComponentFilterSet): fields = ['id', 'name', 'feed_leg', 'description', 'connection_status'] -class InterfaceFilterSet(DeviceComponentFilterSet): +class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -900,7 +910,7 @@ class InterfaceFilterSet(DeviceComponentFilterSet): }.get(value, queryset.none()) -class FrontPortFilterSet(DeviceComponentFilterSet): +class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): cabled = django_filters.BooleanFilter( field_name='cable', lookup_expr='isnull', @@ -912,7 +922,7 @@ class FrontPortFilterSet(DeviceComponentFilterSet): fields = ['id', 'name', 'type', 'description'] -class RearPortFilterSet(DeviceComponentFilterSet): +class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): cabled = django_filters.BooleanFilter( field_name='cable', lookup_expr='isnull', @@ -924,26 +934,28 @@ class RearPortFilterSet(DeviceComponentFilterSet): fields = ['id', 'name', 'type', 'positions', 'description'] -class DeviceBayFilterSet(DeviceComponentFilterSet): +class DeviceBayFilterSet(BaseFilterSet, DeviceComponentFilterSet): class Meta: model = DeviceBay fields = ['id', 'name', 'description'] -class InventoryItemFilterSet(DeviceComponentFilterSet): +class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet): q = django_filters.CharFilter( method='search', label='Search', ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='device__site__region__in', + field_name='device__site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='device__site__region__in', + field_name='device__site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -1009,12 +1021,14 @@ class VirtualChassisFilterSet(BaseFilterSet): ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='master__site__region__in', + field_name='master__site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='master__site__region__in', + field_name='master__site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -1226,12 +1240,14 @@ class PowerPanelFilterSet(BaseFilterSet): ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -1275,12 +1291,14 @@ class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='power_panel__site__region__in', + field_name='power_panel__site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='power_panel__site__region__in', + field_name='power_panel__site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index ccd723a0c..ad414a691 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -235,7 +235,7 @@ class ConfigContextFilterSet(BaseFilterSet): # Filter for Local Config Context Data # -class LocalConfigContextFilterSet(BaseFilterSet): +class LocalConfigContextFilterSet(django_filters.FilterSet): local_context_data = django_filters.BooleanFilter( method='_local_context_data', label='Has local config context data', diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index 5ef96faa2..ab559cf73 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -28,8 +28,8 @@ class GraphTestCase(TestCase): Graph.objects.bulk_create(graphs) def test_name(self): - params = {'name': 'Graph 1'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'name': ['Graph 1', 'Graph 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_type(self): content_type = ContentType.objects.filter(GRAPH_MODELS).first() @@ -59,8 +59,8 @@ class ExportTemplateTestCase(TestCase): ExportTemplate.objects.bulk_create(export_templates) def test_name(self): - params = {'name': 'Export Template 1'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'name': ['Export Template 1', 'Export Template 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_content_type(self): params = {'content_type': ContentType.objects.get(model='site').pk} @@ -154,8 +154,8 @@ class ConfigContextTestCase(TestCase): c.tenants.set([tenants[i]]) def test_name(self): - params = {'name': 'Config Context 1'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'name': ['Config Context 1', 'Config Context 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_is_active(self): params = {'is_active': True} diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 67ad769cc..f0b3275f0 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -8,7 +8,8 @@ from dcim.models import Device, Interface, Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( - MultiValueCharFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, + BaseFilterSet, MultiValueCharFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, + TreeNodeMultipleChoiceFilter, ) from virtualization.models import VirtualMachine from .choices import * @@ -28,7 +29,7 @@ __all__ = ( ) -class VRFFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -53,7 +54,7 @@ class VRFFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterS fields = ['name', 'rd', 'enforce_unique'] -class RIRFilterSet(NameSlugSearchFilterSet): +class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -64,7 +65,7 @@ class RIRFilterSet(NameSlugSearchFilterSet): fields = ['name', 'slug', 'is_private'] -class AggregateFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): +class AggregateFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -114,7 +115,7 @@ class AggregateFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): return queryset.none() -class RoleFilterSet(NameSlugSearchFilterSet): +class RoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -125,7 +126,7 @@ class RoleFilterSet(NameSlugSearchFilterSet): fields = ['id', 'name', 'slug'] -class PrefixFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -166,12 +167,14 @@ class PrefixFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -273,7 +276,7 @@ class PrefixFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt return queryset.filter(prefix__net_mask_length=value) -class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -395,15 +398,17 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF return queryset.exclude(interface__isnull=value) -class VLANGroupFilterSet(NameSlugSearchFilterSet): +class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -423,7 +428,7 @@ class VLANGroupFilterSet(NameSlugSearchFilterSet): fields = ['id', 'name', 'slug'] -class VLANFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -434,12 +439,14 @@ class VLANFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -494,7 +501,7 @@ class VLANFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter return queryset.filter(qs_filter) -class ServiceFilterSet(CreatedUpdatedFilterSet): +class ServiceFilterSet(BaseFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 0c2b01f4d..f32ac1c55 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -3,7 +3,7 @@ from django.db.models import Q from dcim.models import Device from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet -from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter +from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter from .models import Secret, SecretRole @@ -13,14 +13,14 @@ __all__ = ( ) -class SecretRoleFilterSet(NameSlugSearchFilterSet): +class SecretRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = SecretRole fields = ['id', 'name', 'slug'] -class SecretFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): +class SecretFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index fa40e3986..8ba3054aa 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -13,14 +13,14 @@ __all__ = ( ) -class TenantGroupFilterSet(NameSlugSearchFilterSet): +class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = TenantGroup fields = ['id', 'name', 'slug'] -class TenantFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): +class TenantFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index edb7bafa0..9dacc55eb 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -1,9 +1,10 @@ import django_filters +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 +from django_filters.utils import get_model_field, resolve_field from extras.models import Tag from utilities.constants import ( @@ -120,13 +121,62 @@ 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 + }, + }) + @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 `_` + the form `__` """ filters = super().get_filters() @@ -136,7 +186,7 @@ class BaseFilterSet(django_filters.FilterSet): # It 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 != 'exact': + 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 @@ -169,7 +219,7 @@ class BaseFilterSet(django_filters.FilterSet): ) else: - # Do no augment any other filter types with more lookup expressions + # Do not augment any other filter types with more lookup expressions continue # Get properties of the existing filter for later use @@ -178,24 +228,29 @@ class BaseFilterSet(django_filters.FilterSet): # 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) - + new_filter_name = '{}__{}'.format(existing_filter_name, lookup_name) + try: - print(existing_filter_name) - new_filter = cls.filter_for_field(field, field_name, lookup_expr) + if existing_filter_name in cls.declared_filters: + 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: + 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 + # The filter could not be created because the lookup expression is not supported on the field continue - help_text = FILTER_LOOKUP_HELP_TEXT_MAP[lookup_expr] - if lookup_name.startswith('n'): # This is a negation filter which requires a queryselt.exclud() clause new_filter.exclude = True - help_text = 'negated {}'.format(help_text) - new_filter.extra = existing_filter.extra - new_filter.extra['help_text'] = '{} - {}'.format(field_name, help_text) new_filters[new_filter_name] = new_filter filters.update(new_filters) @@ -218,54 +273,3 @@ class NameSlugSearchFilterSet(django_filters.FilterSet): models.Q(name__icontains=value) | models.Q(slug__icontains=value) ) - - -# -# Update default filters -# - -FILTER_DEFAULTS = 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 - }, -}) diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 0d817ef47..d5af9e3d4 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -6,7 +6,8 @@ from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet, LocalC from tenancy.filters import TenancyFilterSet from tenancy.models import Tenant from utilities.filters import ( - MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, + BaseFilterSet, MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, + TreeNodeMultipleChoiceFilter, ) from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -20,21 +21,21 @@ __all__ = ( ) -class ClusterTypeFilterSet(NameSlugSearchFilterSet): +class ClusterTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = ClusterType fields = ['id', 'name', 'slug'] -class ClusterGroupFilterSet(NameSlugSearchFilterSet): +class ClusterGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: model = ClusterGroup fields = ['id', 'name', 'slug'] -class ClusterFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): +class ClusterFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -45,12 +46,14 @@ class ClusterFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region__in', + field_name='site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -104,6 +107,7 @@ class ClusterFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): class VirtualMachineFilterSet( + BaseFilterSet, LocalConfigContextFilterSet, TenancyFilterSet, CustomFieldFilterSet, @@ -149,12 +153,14 @@ class VirtualMachineFilterSet( ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='cluster__site__region__in', + field_name='cluster__site__region', + lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='cluster__site__region__in', + field_name='cluster__site__region', + lookup_expr='in', to_field_name='slug', label='Region (slug)', ) @@ -208,7 +214,7 @@ class VirtualMachineFilterSet( ) -class InterfaceFilterSet(django_filters.FilterSet): +class InterfaceFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', From 9284e8327025a1df129768f46da4945d468a0ff5 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Sun, 9 Feb 2020 21:32:45 -0500 Subject: [PATCH 3/9] py3.5 compatibility --- netbox/utilities/filters.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 9dacc55eb..d1364cf4b 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -178,13 +178,15 @@ class BaseFilterSet(django_filters.FilterSet): For specific filter types, new filters are created based on defined lookup expressions in the form `__` """ - filters = super().get_filters() + # TODO: once 3.6 is the minimum required version of python, change this to a bare super() call + # We have to do it this way in py3.5 becuase of django_filters.FilterSet's use of a metaclass + filters = super(django_filters.FilterSet, cls).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 - # It the filter makes use of a custom filter method or lookup expression skip it + # 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 @@ -232,6 +234,9 @@ class BaseFilterSet(django_filters.FilterSet): 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, @@ -242,13 +247,14 @@ class BaseFilterSet(django_filters.FilterSet): **existing_filter.extra ) else: + # The filter field is listed in Meta.fields so we can safely rely on default behaviour 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 queryselt.exclud() clause + # This is a negation filter which requires a queryset.exclude() clause new_filter.exclude = True new_filters[new_filter_name] = new_filter From a136a0788c649d5947202bf0780f004bfb5a5232 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 18 Feb 2020 00:32:58 -0500 Subject: [PATCH 4/9] #4121 - dynamic filter lookup expressions --- netbox/utilities/constants.py | 4 ++ netbox/utilities/filters.py | 33 ++++++------ netbox/utilities/tests/test_filters.py | 74 +++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 17 deletions(-) diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index d7f819e8c..1f8e13553 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -53,6 +53,10 @@ FILTER_NUMERIC_BASED_LOOKUP_MAP = dict( gt='gt' ) +FILTER_NEGATION_LOOKUP_MAP = dict( + n='exact' +) + FILTER_LOOKUP_HELP_TEXT_MAP = dict( icontains='case insensitive contains', iendswith='case insensitive ends with', diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index d1364cf4b..5e9e1f4c1 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -8,7 +8,8 @@ from django_filters.utils import get_model_field, resolve_field from extras.models import Tag from utilities.constants import ( - FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_LOOKUP_HELP_TEXT_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP + FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_LOOKUP_HELP_TEXT_MAP, FILTER_NEGATION_LOOKUP_MAP, + FILTER_NUMERIC_BASED_LOOKUP_MAP ) @@ -193,15 +194,6 @@ class BaseFilterSet(django_filters.FilterSet): # Choose the lookup expression map based on the filter type if isinstance(existing_filter, ( - django_filters.filters.CharFilter, - django_filters.MultipleChoiceFilter, - MultiValueCharFilter, - MultiValueMACAddressFilter, - TagFilter - )): - lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP - - elif isinstance(existing_filter, ( MultiValueDateFilter, MultiValueDateTimeFilter, MultiValueNumberFilter, @@ -212,13 +204,19 @@ class BaseFilterSet(django_filters.FilterSet): elif isinstance(existing_filter, ( django_filters.ModelChoiceFilter, django_filters.ModelMultipleChoiceFilter, - NumericInFilter, TreeNodeMultipleChoiceFilter, - )): + TagFilter + )) or existing_filter.extra.get('choices'): # These filter types support only negation - lookup_map = dict( - n='exact' - ) + 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: # Do not augment any other filter types with more lookup expressions @@ -231,6 +229,8 @@ class BaseFilterSet(django_filters.FilterSet): # 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) + if existing_filter.lookup_expr == 'in': + lookup_expr = 'in' # 'in' lookups must remain to avoid unwanted slicing on certain querysets try: if existing_filter_name in cls.declared_filters: @@ -255,7 +255,8 @@ class BaseFilterSet(django_filters.FilterSet): if lookup_name.startswith('n'): # This is a negation filter which requires a queryset.exclude() clause - new_filter.exclude = True + # 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 diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 513e11bca..a1cb771a1 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -2,8 +2,9 @@ from django.conf import settings from django.test import TestCase import django_filters +from dcim.filters import SiteFilterSet from dcim.models import Region, Site -from utilities.filters import TreeNodeMultipleChoiceFilter +from utilities.filters import BaseFilterSet, TreeNodeMultipleChoiceFilter class TreeNodeMultipleChoiceFilterTest(TestCase): @@ -60,3 +61,74 @@ class TreeNodeMultipleChoiceFilterTest(TestCase): self.assertEqual(qs.count(), 2) self.assertEqual(qs[0], self.site1) self.assertEqual(qs[1], self.site3) + + +class DynamicFilterLookupExpressionTest(TestCase): + """ + These tests ensure of the utilities.filters.BaseFilterSet.get_filters() method + correctly generates dynamic filter expressions + """ + + def setUp(self): + + super().setUp() + + self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') + self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') + self.site1 = Site.objects.create(region=self.region1, name='Test Site 1', slug='ABC-test-site1-ABC', asn=65001) + self.site2 = Site.objects.create(region=self.region2, name='Test Site 2', slug='def-test-site2-def', asn=65101) + self.site3 = Site.objects.create(region=None, name='Test Site 3', slug='ghi-test-site3-ghi', asn=65201) + + self.queryset = Site.objects.all() + + def test_site_name_negation(self): + params = {'name__n': ['Test Site 1']} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + + def test_site_slug_icontains(self): + params = {'slug__ic': ['abc']} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + + def test_site_slug_icontains_negation(self): + params = {'slug__nic': ['abc']} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + + def test_site_slug_startswith(self): + params = {'slug__isw': ['abc']} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + + def test_site_slug_startswith_negation(self): + params = {'slug__nisw': ['abc']} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + + def test_site_slug_endswith(self): + params = {'slug__iew': ['abc']} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + + def test_site_slug_endswith_negation(self): + params = {'slug__niew': ['abc']} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + + def test_site_asn_lt(self): + params = {'asn__lt': [65101]} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + + def test_site_asn_lte(self): + params = {'asn__lte': [65101]} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + + def test_site_asn_gt(self): + params = {'asn__lt': [65101]} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + + def test_site_asn_gte(self): + params = {'asn__gte': [65101]} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + + def test_site_region_negation(self): + params = {'region__n': ['test-region-1']} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + + def test_site_region_id_negation(self): + params = {'region_id__n': [self.region1.pk]} + self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) From 67565ca191a1976b6a4938b2bbfde578997b6264 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Mon, 24 Feb 2020 15:03:07 -0500 Subject: [PATCH 5/9] added docs and more tests --- docs/api/filtering.md | 71 ++++++++++ docs/api/overview.md | 2 + mkdocs.yml | 1 + netbox/utilities/constants.py | 13 -- netbox/utilities/filters.py | 3 +- netbox/utilities/tests/test_filters.py | 186 +++++++++++++++++++++---- 6 files changed, 231 insertions(+), 45 deletions(-) create mode 100644 docs/api/filtering.md diff --git a/docs/api/filtering.md b/docs/api/filtering.md new file mode 100644 index 000000000..e7b51d303 --- /dev/null +++ b/docs/api/filtering.md @@ -0,0 +1,71 @@ +# API Filtering + +The NetBox API supports robust filtering of results based on the fields of each model. +Generally speaking you are able to filter based on the attributes (fields) present in +the response body. Please note however that certain read-only or metadata fields are not +filterable. + +Filtering is achieved by passing HTTP query parameters and the parameter name is the +name of the field you wish to filter on and the value is the field value. + +E.g. filtering based on a device's name: +``` +/api/dcim/devices/?name=DC-SPINE-1 +``` + +## Multi Value Logic + +While you are able to filter based on an arbitrary number of fields, you are also able to +pass multiple values for the same field. In most cases filtering on multiple values is +implemented as a logical OR operation. A notible exception is the `tag` filter which +is a logical AND. Passing multiple values for one field, can be combined with other fields. + +For example, filtering for devices with either the name of DC-SPINE-1 _or_ DC-LEAF-4: +``` +/api/dcim/devices/?name=DC-SPINE-1&name=DC-LEAF-4 +``` + +Filtering for devices with tag `router` and `customer-a` will return only devices with +_both_ of those tags applied: +``` +/api/dcim/devices/?tag=router&tag=customer-a +``` + +## Lookup Expressions + +Certain model fields also support filtering using additonal lookup expressions. This allows +for negation and other context specific filtering. + +These lookup expressions can be applied by adding a suffix to the desired field's name. +E.g. `mac_address__n`. In this case, the filter expression is for negation and it is seperated +by two underscores. Below are the lookup expressions that are supported across different field +types. + +### Numeric Fields + +Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions: + +- `n` - not equal (negation) +- `lt` - less than +- `lte` - less than or equal +- `gt` - greater than +- `gte` - greater than or equal + +### String Fields + +String based (char) fields (Name, Address, etc) support these lookup expressions: + +- `n` - not equal (negation) +- `ic` - case insensitive contains +- `nic` - negated case insensitive contains +- `isw` - case insensitive starts with +- `nisw` - negated case insensitive starts with +- `iew` - case insensitive ends with +- `niew` - negated case insensitive ends with +- `ie` - case sensitive exact match +- `nie` - negated case sensitive exact match + +### Foreign Keys & Other Fields + +Certain other fields, namely foreign key relationships support just the negation +expression: `n`. diff --git a/docs/api/overview.md b/docs/api/overview.md index 3841e8bbf..daa4f7c63 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -62,6 +62,8 @@ Lists of objects can be filtered using a set of query parameters. For example, t GET /api/dcim/interfaces/?device_id=123 ``` +See [filtering](filtering.md) for more details. + # Serialization The NetBox API employs three types of serializers to represent model data: diff --git a/mkdocs.yml b/mkdocs.yml index 4ba91dfe5..9dc8b8578 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,7 @@ pages: - Authentication: 'api/authentication.md' - Working with Secrets: 'api/working-with-secrets.md' - Examples: 'api/examples.md' + - Filtering: 'api/filtering.md' - Development: - Introduction: 'development/index.md' - Style Guide: 'development/style-guide.md' diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 1665b45c2..bf2cba592 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -57,19 +57,6 @@ FILTER_NEGATION_LOOKUP_MAP = dict( n='exact' ) -FILTER_LOOKUP_HELP_TEXT_MAP = dict( - icontains='case insensitive contains', - iendswith='case insensitive ends with', - istartswith='case insensitive starts with', - iexact='case insensitive exact', - exact='case sensitive exact', - lt='less than', - lte='less than or equal', - gt='greater than', - gte='greater than or equal', - n='negated' -) - # Keys for PostgreSQL advisory locks. These are arbitrary bigints used by # the advisory_lock contextmanager. When a lock is acquired, diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 5e9e1f4c1..2a9f04316 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -8,8 +8,7 @@ from django_filters.utils import get_model_field, resolve_field from extras.models import Tag from utilities.constants import ( - FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_LOOKUP_HELP_TEXT_MAP, FILTER_NEGATION_LOOKUP_MAP, - FILTER_NUMERIC_BASED_LOOKUP_MAP + FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP ) diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index a1cb771a1..ca1c10faf 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -2,8 +2,12 @@ from django.conf import settings from django.test import TestCase import django_filters -from dcim.filters import SiteFilterSet -from dcim.models import Region, Site +from dcim.filters import DeviceFilterSet, SiteFilterSet +from dcim.choices import * +from dcim.models import ( + Device, DeviceRole, DeviceType, Interface, Manufacturer, Platform, Rack, Region, Site +) +from ipam.models import IPAddress from utilities.filters import BaseFilterSet, TreeNodeMultipleChoiceFilter @@ -68,67 +72,189 @@ class DynamicFilterLookupExpressionTest(TestCase): These tests ensure of the utilities.filters.BaseFilterSet.get_filters() method correctly generates dynamic filter expressions """ + device_queryset = Device.objects.all() + device_filterset = DeviceFilterSet + site_queryset = Site.objects.all() + site_filterset = SiteFilterSet - def setUp(self): + @classmethod + def setUpTestData(cls): - super().setUp() + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), + ) + Manufacturer.objects.bulk_create(manufacturers) - self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') - self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') - self.site1 = Site.objects.create(region=self.region1, name='Test Site 1', slug='ABC-test-site1-ABC', asn=65001) - self.site2 = Site.objects.create(region=self.region2, name='Test Site 2', slug='def-test-site2-def', asn=65101) - self.site3 = Site.objects.create(region=None, name='Test Site 3', slug='ghi-test-site3-ghi', asn=65201) + device_types = ( + DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', is_full_depth=True), + DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', is_full_depth=True), + DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', is_full_depth=False), + ) + DeviceType.objects.bulk_create(device_types) - self.queryset = Site.objects.all() + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) + + platforms = ( + Platform(name='Platform 1', slug='platform-1'), + Platform(name='Platform 2', slug='platform-2'), + Platform(name='Platform 3', slug='platform-3'), + ) + Platform.objects.bulk_create(platforms) + + regions = ( + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), + ) + for region in regions: + region.save() + + sites = ( + Site(name='Site 1', slug='abc-site-1', region=regions[0], asn=65001), + Site(name='Site 2', slug='def-site-2', region=regions[1], asn=65101), + Site(name='Site 3', slug='ghi-site-3', region=regions[2], asn=65201), + ) + Site.objects.bulk_create(sites) + + racks = ( + Rack(name='Rack 1', site=sites[0]), + Rack(name='Rack 2', site=sites[1]), + Rack(name='Rack 3', site=sites[2]), + ) + Rack.objects.bulk_create(racks) + + devices = ( + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], serial='ABC', asset_tag='1001', site=sites[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, local_context_data={"foo": 123}), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], serial='DEF', asset_tag='1002', site=sites[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], serial='GHI', asset_tag='1003', site=sites[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED), + ) + Device.objects.bulk_create(devices) + + interfaces = ( + Interface(device=devices[0], name='Interface 1', mac_address='00-00-00-00-00-01'), + Interface(device=devices[0], name='Interface 2', mac_address='aa-00-00-00-00-01'), + Interface(device=devices[1], name='Interface 3', mac_address='00-00-00-00-00-02'), + Interface(device=devices[1], name='Interface 4', mac_address='bb-00-00-00-00-02'), + Interface(device=devices[2], name='Interface 5', mac_address='00-00-00-00-00-03'), + Interface(device=devices[2], name='Interface 6', mac_address='cc-00-00-00-00-03'), + ) + Interface.objects.bulk_create(interfaces) def test_site_name_negation(self): - params = {'name__n': ['Test Site 1']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + params = {'name__n': ['Site 1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_slug_icontains(self): - params = {'slug__ic': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + params = {'slug__ic': ['-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) def test_site_slug_icontains_negation(self): - params = {'slug__nic': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + params = {'slug__nic': ['-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_slug_startswith(self): params = {'slug__isw': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) def test_site_slug_startswith_negation(self): params = {'slug__nisw': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_slug_endswith(self): - params = {'slug__iew': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + params = {'slug__iew': ['-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) def test_site_slug_endswith_negation(self): - params = {'slug__niew': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + params = {'slug__niew': ['-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_asn_lt(self): params = {'asn__lt': [65101]} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) def test_site_asn_lte(self): params = {'asn__lte': [65101]} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_asn_gt(self): params = {'asn__lt': [65101]} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) def test_site_asn_gte(self): params = {'asn__gte': [65101]} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_region_negation(self): - params = {'region__n': ['test-region-1']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + params = {'region__n': ['region-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_region_id_negation(self): - params = {'region_id__n': [self.region1.pk]} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + params = {'region_id__n': [Region.objects.first().pk]} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + + def test_device_name_eq(self): + params = {'name': ['Device 1']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + + def test_device_name_negation(self): + params = {'name__n': ['Device 1']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_name_startswith(self): + params = {'name__isw': ['Device']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 3) + + def test_device_name_startswith_negation(self): + params = {'name__nisw': ['Device 1']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_name_endswith(self): + params = {'name__iew': [' 1']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + + def test_device_name_endswith_negation(self): + params = {'name__niew': [' 1']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_name_icontains(self): + params = {'name__ic': [' 2']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + + def test_device_name_icontains_negation(self): + params = {'name__nic': [' ']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 0) + + def test_device_mac_address_negation(self): + params = {'mac_address__n': ['00-00-00-00-00-01', 'aa-00-00-00-00-01']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_mac_address_startswith(self): + params = {'mac_address__isw': ['aa:']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + + def test_device_mac_address_startswith_negation(self): + params = {'mac_address__nisw': ['aa:']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_mac_address_endswith(self): + params = {'mac_address__iew': [':02']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + + def test_device_mac_address_endswith_negation(self): + params = {'mac_address__niew': [':02']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_mac_address_icontains(self): + params = {'mac_address__ic': ['aa:', 'bb']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_mac_address_icontains_negation(self): + params = {'mac_address__nic': ['aa:', 'bb']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) From afc8c9bfe9fb4dadb5220804a87733ef2be0c447 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 25 Feb 2020 13:50:31 -0500 Subject: [PATCH 6/9] fix tenancy filterset bases --- netbox/dcim/filters.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 0d7c440c2..7b98359c8 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -77,7 +77,7 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet): fields = ['id', 'name', 'slug'] -class SiteFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -170,7 +170,7 @@ class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): fields = ['id', 'name', 'slug', 'color'] -class RackFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -497,7 +497,13 @@ class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet): fields = ['id', 'name', 'slug', 'napalm_driver'] -class DeviceFilterSet(BaseFilterSet, LocalConfigContextFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class DeviceFilterSet( + BaseFilterSet, + TenancyFilterSet, + LocalConfigContextFilterSet, + CustomFieldFilterSet, + CreatedUpdatedFilterSet +): id__in = NumericInFilter( field_name='id', lookup_expr='in' From 3b4607d30dd831dc54d87426eeaacd59baf8283b Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 25 Feb 2020 15:16:27 -0500 Subject: [PATCH 7/9] refactor lookup map logic --- netbox/utilities/filters.py | 64 +++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 2a9f04316..d88366d24 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -170,6 +170,39 @@ class BaseFilterSet(django_filters.FilterSet): }, }) + @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, ( + django_filters.ModelChoiceFilter, + django_filters.ModelMultipleChoiceFilter, + TreeNodeMultipleChoiceFilter, + 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): """ @@ -192,33 +225,9 @@ class BaseFilterSet(django_filters.FilterSet): continue # 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, ( - django_filters.ModelChoiceFilter, - django_filters.ModelMultipleChoiceFilter, - TreeNodeMultipleChoiceFilter, - 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: - # Do not augment any other filter types with more lookup expressions + 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 @@ -247,6 +256,7 @@ class BaseFilterSet(django_filters.FilterSet): ) 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 From e5f8f1529382cb833b3ffd380ff7a23eafd3f2e0 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Fri, 28 Feb 2020 19:58:06 -0500 Subject: [PATCH 8/9] added lookup map for treenode filter --- netbox/utilities/constants.py | 4 ++++ netbox/utilities/filters.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index bf2cba592..bdcdeef11 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -57,6 +57,10 @@ FILTER_NEGATION_LOOKUP_MAP = dict( n='exact' ) +FILTER_TREENODE_NEGATION_LOOKUP_MAP = dict( + n='in' +) + # Keys for PostgreSQL advisory locks. These are arbitrary bigints used by # the advisory_lock contextmanager. When a lock is acquired, diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index d88366d24..ff34a6011 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -8,7 +8,8 @@ from django_filters.utils import get_model_field, resolve_field from extras.models import Tag from utilities.constants import ( - FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP + FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP, + FILTER_NUMERIC_BASED_LOOKUP_MAP ) @@ -181,10 +182,15 @@ class BaseFilterSet(django_filters.FilterSet): )): 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, - TreeNodeMultipleChoiceFilter, TagFilter )) or existing_filter.extra.get('choices'): # These filter types support only negation @@ -237,8 +243,6 @@ class BaseFilterSet(django_filters.FilterSet): # 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) - if existing_filter.lookup_expr == 'in': - lookup_expr = 'in' # 'in' lookups must remain to avoid unwanted slicing on certain querysets try: if existing_filter_name in cls.declared_filters: From 1e1c6526b2270b8dca291d888008e9ead97b5128 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 2 Mar 2020 12:25:21 -0500 Subject: [PATCH 9/9] Add BaseFilterSetTest to validate automatic generation of filters --- netbox/utilities/tests/test_filters.py | 272 ++++++++++++++++++++++++- 1 file changed, 265 insertions(+), 7 deletions(-) diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index ca1c10faf..f70d7e1db 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -1,14 +1,21 @@ -from django.conf import settings -from django.test import TestCase import django_filters +from django.conf import settings +from django.db import models +from django.test import TestCase +from mptt.fields import TreeForeignKey +from taggit.managers import TaggableManager -from dcim.filters import DeviceFilterSet, SiteFilterSet from dcim.choices import * +from dcim.fields import MACAddressField +from dcim.filters import DeviceFilterSet, SiteFilterSet from dcim.models import ( Device, DeviceRole, DeviceType, Interface, Manufacturer, Platform, Rack, Region, Site ) -from ipam.models import IPAddress -from utilities.filters import BaseFilterSet, TreeNodeMultipleChoiceFilter +from extras.models import TaggedItem +from utilities.filters import ( + BaseFilterSet, MACAddressFilter, MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter, + MultiValueNumberFilter, MultiValueTimeFilter, TagFilter, TreeNodeMultipleChoiceFilter, +) class TreeNodeMultipleChoiceFilterTest(TestCase): @@ -67,10 +74,261 @@ class TreeNodeMultipleChoiceFilterTest(TestCase): self.assertEqual(qs[1], self.site3) +class DummyModel(models.Model): + """ + Dummy model used by BaseFilterSetTest for filter validation. Should never appear in a schema migration. + """ + charfield = models.CharField( + max_length=10 + ) + choicefield = models.IntegerField( + choices=(('A', 1), ('B', 2), ('C', 3)) + ) + datefield = models.DateField() + datetimefield = models.DateTimeField() + integerfield = models.IntegerField() + macaddressfield = MACAddressField() + timefield = models.TimeField() + treeforeignkeyfield = TreeForeignKey( + to='self', + on_delete=models.CASCADE + ) + + tags = TaggableManager(through=TaggedItem) + + +class BaseFilterSetTest(TestCase): + """ + Ensure that a BaseFilterSet automatically creates the expected set of filters for each filter type. + """ + class DummyFilterSet(BaseFilterSet): + charfield = django_filters.CharFilter() + macaddressfield = MACAddressFilter() + modelchoicefield = django_filters.ModelChoiceFilter( + field_name='integerfield', # We're pretending this is a ForeignKey field + queryset=Site.objects.all() + ) + modelmultiplechoicefield = django_filters.ModelMultipleChoiceFilter( + field_name='integerfield', # We're pretending this is a ForeignKey field + queryset=Site.objects.all() + ) + multiplechoicefield = django_filters.MultipleChoiceFilter( + field_name='choicefield' + ) + multivaluecharfield = MultiValueCharFilter( + field_name='charfield' + ) + tagfield = TagFilter() + treeforeignkeyfield = TreeNodeMultipleChoiceFilter( + queryset=DummyModel.objects.all() + ) + + class Meta: + model = DummyModel + fields = ( + 'charfield', + 'choicefield', + 'datefield', + 'datetimefield', + 'integerfield', + 'macaddressfield', + 'modelchoicefield', + 'modelmultiplechoicefield', + 'multiplechoicefield', + 'tagfield', + 'timefield', + 'treeforeignkeyfield', + ) + + @classmethod + def setUpTestData(cls): + cls.filters = cls.DummyFilterSet().filters + + def test_char_filter(self): + self.assertIsInstance(self.filters['charfield'], django_filters.CharFilter) + self.assertEqual(self.filters['charfield'].lookup_expr, 'exact') + self.assertEqual(self.filters['charfield'].exclude, False) + self.assertEqual(self.filters['charfield__n'].lookup_expr, 'exact') + self.assertEqual(self.filters['charfield__n'].exclude, True) + self.assertEqual(self.filters['charfield__ie'].lookup_expr, 'iexact') + self.assertEqual(self.filters['charfield__ie'].exclude, False) + self.assertEqual(self.filters['charfield__nie'].lookup_expr, 'iexact') + self.assertEqual(self.filters['charfield__nie'].exclude, True) + self.assertEqual(self.filters['charfield__ic'].lookup_expr, 'icontains') + self.assertEqual(self.filters['charfield__ic'].exclude, False) + self.assertEqual(self.filters['charfield__nic'].lookup_expr, 'icontains') + self.assertEqual(self.filters['charfield__nic'].exclude, True) + self.assertEqual(self.filters['charfield__isw'].lookup_expr, 'istartswith') + self.assertEqual(self.filters['charfield__isw'].exclude, False) + self.assertEqual(self.filters['charfield__nisw'].lookup_expr, 'istartswith') + self.assertEqual(self.filters['charfield__nisw'].exclude, True) + self.assertEqual(self.filters['charfield__iew'].lookup_expr, 'iendswith') + self.assertEqual(self.filters['charfield__iew'].exclude, False) + self.assertEqual(self.filters['charfield__niew'].lookup_expr, 'iendswith') + self.assertEqual(self.filters['charfield__niew'].exclude, True) + + def test_mac_address_filter(self): + self.assertIsInstance(self.filters['macaddressfield'], MACAddressFilter) + self.assertEqual(self.filters['macaddressfield'].lookup_expr, 'exact') + self.assertEqual(self.filters['macaddressfield'].exclude, False) + self.assertEqual(self.filters['macaddressfield__n'].lookup_expr, 'exact') + self.assertEqual(self.filters['macaddressfield__n'].exclude, True) + self.assertEqual(self.filters['macaddressfield__ie'].lookup_expr, 'iexact') + self.assertEqual(self.filters['macaddressfield__ie'].exclude, False) + self.assertEqual(self.filters['macaddressfield__nie'].lookup_expr, 'iexact') + self.assertEqual(self.filters['macaddressfield__nie'].exclude, True) + self.assertEqual(self.filters['macaddressfield__ic'].lookup_expr, 'icontains') + self.assertEqual(self.filters['macaddressfield__ic'].exclude, False) + self.assertEqual(self.filters['macaddressfield__nic'].lookup_expr, 'icontains') + self.assertEqual(self.filters['macaddressfield__nic'].exclude, True) + self.assertEqual(self.filters['macaddressfield__isw'].lookup_expr, 'istartswith') + self.assertEqual(self.filters['macaddressfield__isw'].exclude, False) + self.assertEqual(self.filters['macaddressfield__nisw'].lookup_expr, 'istartswith') + self.assertEqual(self.filters['macaddressfield__nisw'].exclude, True) + self.assertEqual(self.filters['macaddressfield__iew'].lookup_expr, 'iendswith') + self.assertEqual(self.filters['macaddressfield__iew'].exclude, False) + self.assertEqual(self.filters['macaddressfield__niew'].lookup_expr, 'iendswith') + self.assertEqual(self.filters['macaddressfield__niew'].exclude, True) + + def test_model_choice_filter(self): + self.assertIsInstance(self.filters['modelchoicefield'], django_filters.ModelChoiceFilter) + self.assertEqual(self.filters['modelchoicefield'].lookup_expr, 'exact') + self.assertEqual(self.filters['modelchoicefield'].exclude, False) + self.assertEqual(self.filters['modelchoicefield__n'].lookup_expr, 'exact') + self.assertEqual(self.filters['modelchoicefield__n'].exclude, True) + + def test_model_multiple_choice_filter(self): + self.assertIsInstance(self.filters['modelmultiplechoicefield'], django_filters.ModelMultipleChoiceFilter) + self.assertEqual(self.filters['modelmultiplechoicefield'].lookup_expr, 'exact') + self.assertEqual(self.filters['modelmultiplechoicefield'].exclude, False) + self.assertEqual(self.filters['modelmultiplechoicefield__n'].lookup_expr, 'exact') + self.assertEqual(self.filters['modelmultiplechoicefield__n'].exclude, True) + + def test_multi_value_char_filter(self): + self.assertIsInstance(self.filters['multivaluecharfield'], MultiValueCharFilter) + self.assertEqual(self.filters['multivaluecharfield'].lookup_expr, 'exact') + self.assertEqual(self.filters['multivaluecharfield'].exclude, False) + self.assertEqual(self.filters['multivaluecharfield__n'].lookup_expr, 'exact') + self.assertEqual(self.filters['multivaluecharfield__n'].exclude, True) + self.assertEqual(self.filters['multivaluecharfield__ie'].lookup_expr, 'iexact') + self.assertEqual(self.filters['multivaluecharfield__ie'].exclude, False) + self.assertEqual(self.filters['multivaluecharfield__nie'].lookup_expr, 'iexact') + self.assertEqual(self.filters['multivaluecharfield__nie'].exclude, True) + self.assertEqual(self.filters['multivaluecharfield__ic'].lookup_expr, 'icontains') + self.assertEqual(self.filters['multivaluecharfield__ic'].exclude, False) + self.assertEqual(self.filters['multivaluecharfield__nic'].lookup_expr, 'icontains') + self.assertEqual(self.filters['multivaluecharfield__nic'].exclude, True) + self.assertEqual(self.filters['multivaluecharfield__isw'].lookup_expr, 'istartswith') + self.assertEqual(self.filters['multivaluecharfield__isw'].exclude, False) + self.assertEqual(self.filters['multivaluecharfield__nisw'].lookup_expr, 'istartswith') + self.assertEqual(self.filters['multivaluecharfield__nisw'].exclude, True) + self.assertEqual(self.filters['multivaluecharfield__iew'].lookup_expr, 'iendswith') + self.assertEqual(self.filters['multivaluecharfield__iew'].exclude, False) + self.assertEqual(self.filters['multivaluecharfield__niew'].lookup_expr, 'iendswith') + self.assertEqual(self.filters['multivaluecharfield__niew'].exclude, True) + + def test_multi_value_date_filter(self): + self.assertIsInstance(self.filters['datefield'], MultiValueDateFilter) + self.assertEqual(self.filters['datefield'].lookup_expr, 'exact') + self.assertEqual(self.filters['datefield'].exclude, False) + self.assertEqual(self.filters['datefield__n'].lookup_expr, 'exact') + self.assertEqual(self.filters['datefield__n'].exclude, True) + self.assertEqual(self.filters['datefield__lt'].lookup_expr, 'lt') + self.assertEqual(self.filters['datefield__lt'].exclude, False) + self.assertEqual(self.filters['datefield__lte'].lookup_expr, 'lte') + self.assertEqual(self.filters['datefield__lte'].exclude, False) + self.assertEqual(self.filters['datefield__gt'].lookup_expr, 'gt') + self.assertEqual(self.filters['datefield__gt'].exclude, False) + self.assertEqual(self.filters['datefield__gte'].lookup_expr, 'gte') + self.assertEqual(self.filters['datefield__gte'].exclude, False) + + def test_multi_value_datetime_filter(self): + self.assertIsInstance(self.filters['datetimefield'], MultiValueDateTimeFilter) + self.assertEqual(self.filters['datetimefield'].lookup_expr, 'exact') + self.assertEqual(self.filters['datetimefield'].exclude, False) + self.assertEqual(self.filters['datetimefield__n'].lookup_expr, 'exact') + self.assertEqual(self.filters['datetimefield__n'].exclude, True) + self.assertEqual(self.filters['datetimefield__lt'].lookup_expr, 'lt') + self.assertEqual(self.filters['datetimefield__lt'].exclude, False) + self.assertEqual(self.filters['datetimefield__lte'].lookup_expr, 'lte') + self.assertEqual(self.filters['datetimefield__lte'].exclude, False) + self.assertEqual(self.filters['datetimefield__gt'].lookup_expr, 'gt') + self.assertEqual(self.filters['datetimefield__gt'].exclude, False) + self.assertEqual(self.filters['datetimefield__gte'].lookup_expr, 'gte') + self.assertEqual(self.filters['datetimefield__gte'].exclude, False) + + def test_multi_value_number_filter(self): + self.assertIsInstance(self.filters['integerfield'], MultiValueNumberFilter) + self.assertEqual(self.filters['integerfield'].lookup_expr, 'exact') + self.assertEqual(self.filters['integerfield'].exclude, False) + self.assertEqual(self.filters['integerfield__n'].lookup_expr, 'exact') + self.assertEqual(self.filters['integerfield__n'].exclude, True) + self.assertEqual(self.filters['integerfield__lt'].lookup_expr, 'lt') + self.assertEqual(self.filters['integerfield__lt'].exclude, False) + self.assertEqual(self.filters['integerfield__lte'].lookup_expr, 'lte') + self.assertEqual(self.filters['integerfield__lte'].exclude, False) + self.assertEqual(self.filters['integerfield__gt'].lookup_expr, 'gt') + self.assertEqual(self.filters['integerfield__gt'].exclude, False) + self.assertEqual(self.filters['integerfield__gte'].lookup_expr, 'gte') + self.assertEqual(self.filters['integerfield__gte'].exclude, False) + + def test_multi_value_time_filter(self): + self.assertIsInstance(self.filters['timefield'], MultiValueTimeFilter) + self.assertEqual(self.filters['timefield'].lookup_expr, 'exact') + self.assertEqual(self.filters['timefield'].exclude, False) + self.assertEqual(self.filters['timefield__n'].lookup_expr, 'exact') + self.assertEqual(self.filters['timefield__n'].exclude, True) + self.assertEqual(self.filters['timefield__lt'].lookup_expr, 'lt') + self.assertEqual(self.filters['timefield__lt'].exclude, False) + self.assertEqual(self.filters['timefield__lte'].lookup_expr, 'lte') + self.assertEqual(self.filters['timefield__lte'].exclude, False) + self.assertEqual(self.filters['timefield__gt'].lookup_expr, 'gt') + self.assertEqual(self.filters['timefield__gt'].exclude, False) + self.assertEqual(self.filters['timefield__gte'].lookup_expr, 'gte') + self.assertEqual(self.filters['timefield__gte'].exclude, False) + + def test_multiple_choice_filter(self): + self.assertIsInstance(self.filters['multiplechoicefield'], django_filters.MultipleChoiceFilter) + self.assertEqual(self.filters['multiplechoicefield'].lookup_expr, 'exact') + self.assertEqual(self.filters['multiplechoicefield'].exclude, False) + self.assertEqual(self.filters['multiplechoicefield__n'].lookup_expr, 'exact') + self.assertEqual(self.filters['multiplechoicefield__n'].exclude, True) + self.assertEqual(self.filters['multiplechoicefield__ie'].lookup_expr, 'iexact') + self.assertEqual(self.filters['multiplechoicefield__ie'].exclude, False) + self.assertEqual(self.filters['multiplechoicefield__nie'].lookup_expr, 'iexact') + self.assertEqual(self.filters['multiplechoicefield__nie'].exclude, True) + self.assertEqual(self.filters['multiplechoicefield__ic'].lookup_expr, 'icontains') + self.assertEqual(self.filters['multiplechoicefield__ic'].exclude, False) + self.assertEqual(self.filters['multiplechoicefield__nic'].lookup_expr, 'icontains') + self.assertEqual(self.filters['multiplechoicefield__nic'].exclude, True) + self.assertEqual(self.filters['multiplechoicefield__isw'].lookup_expr, 'istartswith') + self.assertEqual(self.filters['multiplechoicefield__isw'].exclude, False) + self.assertEqual(self.filters['multiplechoicefield__nisw'].lookup_expr, 'istartswith') + self.assertEqual(self.filters['multiplechoicefield__nisw'].exclude, True) + self.assertEqual(self.filters['multiplechoicefield__iew'].lookup_expr, 'iendswith') + self.assertEqual(self.filters['multiplechoicefield__iew'].exclude, False) + self.assertEqual(self.filters['multiplechoicefield__niew'].lookup_expr, 'iendswith') + self.assertEqual(self.filters['multiplechoicefield__niew'].exclude, True) + + def test_tag_filter(self): + 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__n'].lookup_expr, 'exact') + self.assertEqual(self.filters['tagfield__n'].exclude, True) + + def test_tree_node_multiple_choice_filter(self): + self.assertIsInstance(self.filters['treeforeignkeyfield'], TreeNodeMultipleChoiceFilter) + # TODO: lookup_expr different for negation? + self.assertEqual(self.filters['treeforeignkeyfield'].lookup_expr, 'exact') + self.assertEqual(self.filters['treeforeignkeyfield'].exclude, False) + self.assertEqual(self.filters['treeforeignkeyfield__n'].lookup_expr, 'in') + self.assertEqual(self.filters['treeforeignkeyfield__n'].exclude, True) + + class DynamicFilterLookupExpressionTest(TestCase): """ - These tests ensure of the utilities.filters.BaseFilterSet.get_filters() method - correctly generates dynamic filter expressions + Validate function of automatically generated filters using the Device model as an example. """ device_queryset = Device.objects.all() device_filterset = DeviceFilterSet