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 cf45b89ae..7579a4b00 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -55,6 +55,7 @@ nav: - 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/circuits/filters.py b/netbox/circuits/filters.py index c27ffb8d7..4bd5fa158 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' @@ -27,12 +29,14 @@ class ProviderFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): ) 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)', ) @@ -65,14 +69,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' @@ -118,12 +122,14 @@ class CircuitFilterSet(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFil ) 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)', ) @@ -146,7 +152,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..7b98359c8 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, + 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, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -92,12 +92,14 @@ class SiteFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter ) 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)', ) @@ -131,15 +133,17 @@ 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', + 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)', ) @@ -159,14 +163,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, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -177,12 +181,14 @@ class RackFilterSet(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)', ) @@ -244,7 +250,7 @@ class RackFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter ) -class RackReservationFilterSet(TenancyFilterSet): +class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -305,14 +311,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' @@ -410,70 +416,70 @@ class DeviceTypeComponentFilterSet(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 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 +497,13 @@ class PlatformFilterSet(NameSlugSearchFilterSet): fields = ['id', 'name', 'slug', 'napalm_driver'] -class DeviceFilterSet(LocalConfigContextFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class DeviceFilterSet( + BaseFilterSet, + TenancyFilterSet, + LocalConfigContextFilterSet, + CustomFieldFilterSet, + CreatedUpdatedFilterSet +): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -538,12 +550,14 @@ class DeviceFilterSet(LocalConfigContextFilterSet, TenancyFilterSet, CustomField ) 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)', ) @@ -697,12 +711,14 @@ class DeviceComponentFilterSet(django_filters.FilterSet): ) 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 +754,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet): ) -class ConsolePortFilterSet(DeviceComponentFilterSet): +class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -754,7 +770,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 +786,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 +802,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 +818,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 +916,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 +928,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 +940,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)', ) @@ -1002,19 +1020,21 @@ class InventoryItemFilterSet(DeviceComponentFilterSet): return queryset.filter(qs_filter) -class VirtualChassisFilterSet(django_filters.FilterSet): +class VirtualChassisFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search', ) 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)', ) @@ -1056,7 +1076,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 +1139,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 +1170,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 +1201,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 +1235,7 @@ class InterfaceConnectionFilterSet(django_filters.FilterSet): ) -class PowerPanelFilterSet(django_filters.FilterSet): +class PowerPanelFilterSet(BaseFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -1226,12 +1246,14 @@ class PowerPanelFilterSet(django_filters.FilterSet): ) 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)', ) @@ -1264,7 +1286,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' @@ -1275,12 +1297,14 @@ class PowerFeedFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): ) 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 dcd4f3ede..ad414a691 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', @@ -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/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 5f8bcabff..c2dbd573b 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, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, + BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, 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' @@ -397,15 +400,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)', ) @@ -425,7 +430,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' @@ -436,12 +441,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)', ) @@ -496,7 +503,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 001cf29e7..8ba3054aa 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 @@ -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/constants.py b/netbox/utilities/constants.py index 2cec2b532..bdcdeef11 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -28,12 +28,47 @@ COLOR_CHOICES = ( ('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_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, # one of these keys will be used to identify said lock. # # When adding a new key, pick something arbitrary and unique so # that it is easily searchable in query logs. + ADVISORY_LOCK_KEYS = { 'available-prefixes': 100100, 'available-ips': 100200, diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 957020e40..ff34a6011 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -1,9 +1,16 @@ 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, resolve_field + from extras.models import Tag +from utilities.constants import ( + FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP, + FILTER_NUMERIC_BASED_LOOKUP_MAP +) def multivalue_field_factory(field_class): @@ -111,6 +118,165 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter): # FilterSets # +class BaseFilterSet(django_filters.FilterSet): + """ + A base filterset which provides common functionaly to all NetBox filtersets + """ + FILTER_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS) + FILTER_DEFAULTS.update({ + models.AutoField: { + 'filter_class': MultiValueNumberFilter + }, + models.CharField: { + 'filter_class': MultiValueCharFilter + }, + models.DateField: { + 'filter_class': MultiValueDateFilter + }, + models.DateTimeField: { + 'filter_class': MultiValueDateTimeFilter + }, + models.DecimalField: { + 'filter_class': MultiValueNumberFilter + }, + models.EmailField: { + 'filter_class': MultiValueCharFilter + }, + models.FloatField: { + 'filter_class': MultiValueNumberFilter + }, + models.IntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.PositiveIntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.PositiveSmallIntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.SlugField: { + 'filter_class': MultiValueCharFilter + }, + models.SmallIntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.TimeField: { + 'filter_class': MultiValueTimeFilter + }, + models.URLField: { + 'filter_class': MultiValueCharFilter + }, + MACAddressField: { + 'filter_class': MultiValueMACAddressFilter + }, + }) + + @staticmethod + def _get_filter_lookup_dict(existing_filter): + # Choose the lookup expression map based on the filter type + if isinstance(existing_filter, ( + MultiValueDateFilter, + MultiValueDateTimeFilter, + MultiValueNumberFilter, + MultiValueTimeFilter + )): + lookup_map = FILTER_NUMERIC_BASED_LOOKUP_MAP + + elif isinstance(existing_filter, ( + TreeNodeMultipleChoiceFilter, + )): + # TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression + lookup_map = FILTER_TREENODE_NEGATION_LOOKUP_MAP + + elif isinstance(existing_filter, ( + django_filters.ModelChoiceFilter, + django_filters.ModelMultipleChoiceFilter, + TagFilter + )) or existing_filter.extra.get('choices'): + # These filter types support only negation + lookup_map = FILTER_NEGATION_LOOKUP_MAP + + elif isinstance(existing_filter, ( + django_filters.filters.CharFilter, + django_filters.MultipleChoiceFilter, + MultiValueCharFilter, + MultiValueMACAddressFilter + )): + lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP + + else: + lookup_map = None + + return lookup_map + + @classmethod + def get_filters(cls): + """ + Override filter generation to support dynamic lookup expressions for certain filter types. + + For specific filter types, new filters are created based on defined lookup expressions in + the form `__` + """ + # 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 + + # If the filter makes use of a custom filter method or lookup expression skip it + # as we cannot sanely handle these cases in a generic mannor + if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']: + continue + + # Choose the lookup expression map based on the filter type + lookup_map = cls._get_filter_lookup_dict(existing_filter) + if lookup_map is None: + # Do not augment this filter type with more lookup expressions + continue + + # Get properties of the existing filter for later use + field_name = existing_filter.field_name + field = get_model_field(cls._meta.model, field_name) + + # Create new filters for each lookup expression in the map + for lookup_name, lookup_expr in lookup_map.items(): + new_filter_name = '{}__{}'.format(existing_filter_name, lookup_name) + + try: + if existing_filter_name in cls.declared_filters: + # The filter field has been explicity defined on the filterset class so we must manually + # create the new filter with the same type because there is no guarantee the defined type + # is the same as the default type for the field + resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid + new_filter = type(existing_filter)( + field_name=field_name, + lookup_expr=lookup_expr, + label=existing_filter.label, + exclude=existing_filter.exclude, + distinct=existing_filter.distinct, + **existing_filter.extra + ) + else: + # The filter field is listed in Meta.fields so we can safely rely on default behaviour + # Will raise FieldLookupError if the lookup is invalid + new_filter = cls.filter_for_field(field, field_name, lookup_expr) + except django_filters.exceptions.FieldLookupError: + # The filter could not be created because the lookup expression is not supported on the field + continue + + if lookup_name.startswith('n'): + # This is a negation filter which requires a queryset.exclude() clause + # Of course setting the negation of the existing filter's exclude attribute handles both cases + new_filter.exclude = not existing_filter.exclude + + new_filters[new_filter_name] = new_filter + + filters.update(new_filters) + return filters + + class NameSlugSearchFilterSet(django_filters.FilterSet): """ A base class for adding the search method to models which only expose the `name` and `slug` fields @@ -127,54 +293,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/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 513e11bca..f70d7e1db 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -1,9 +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.models import Region, Site -from utilities.filters import TreeNodeMultipleChoiceFilter +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 extras.models import TaggedItem +from utilities.filters import ( + BaseFilterSet, MACAddressFilter, MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter, + MultiValueNumberFilter, MultiValueTimeFilter, TagFilter, TreeNodeMultipleChoiceFilter, +) class TreeNodeMultipleChoiceFilterTest(TestCase): @@ -60,3 +72,447 @@ class TreeNodeMultipleChoiceFilterTest(TestCase): self.assertEqual(qs.count(), 2) self.assertEqual(qs[0], self.site1) 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): + """ + Validate function of automatically generated filters using the Device model as an example. + """ + device_queryset = Device.objects.all() + device_filterset = DeviceFilterSet + site_queryset = Site.objects.all() + site_filterset = SiteFilterSet + + @classmethod + def setUpTestData(cls): + + 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) + + 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) + + 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': ['Site 1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + + def test_site_slug_icontains(self): + params = {'slug__ic': ['-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) + + def test_site_slug_icontains_negation(self): + 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.site_queryset).qs.count(), 1) + + def test_site_slug_startswith_negation(self): + params = {'slug__nisw': ['abc']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + + def test_site_slug_endswith(self): + params = {'slug__iew': ['-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) + + def test_site_slug_endswith_negation(self): + 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.site_queryset).qs.count(), 1) + + def test_site_asn_lte(self): + params = {'asn__lte': [65101]} + 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.site_queryset).qs.count(), 1) + + def test_site_asn_gte(self): + params = {'asn__gte': [65101]} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + + def test_site_region_negation(self): + 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': [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) diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 79313f36e..59f09c401 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(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): +class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -45,12 +46,14 @@ class ClusterFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFil ) 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)', ) @@ -100,6 +103,7 @@ class ClusterFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFil class VirtualMachineFilterSet( + BaseFilterSet, LocalConfigContextFilterSet, TenancyFilterSet, CustomFieldFilterSet, @@ -145,12 +149,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)', ) @@ -204,7 +210,7 @@ class VirtualMachineFilterSet( ) -class InterfaceFilterSet(django_filters.FilterSet): +class InterfaceFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', label='Search',