Просмотр исходного кода

Merge pull request #4189 from netbox-community/4121-filter-lookup-expressions

4121 filter lookup expressions
Jeremy Stretch 6 лет назад
Родитель
Сommit
1dd07337fd

+ 71 - 0
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`.

+ 2 - 0
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:

+ 1 - 0
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'

+ 15 - 9
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',

+ 79 - 55
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)',
     )

+ 6 - 5
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',

+ 6 - 6
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}

+ 23 - 16
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',

+ 3 - 3
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'

+ 3 - 3
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'

+ 35 - 0
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,

+ 166 - 51
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 `<field_name>__<lookup_expr>`
+        """
+        # 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
-    },
-})

+ 459 - 3
netbox/utilities/tests/test_filters.py

@@ -1,9 +1,21 @@
+import django_filters
 from django.conf import settings
+from django.db import models
 from django.test import TestCase
-import django_filters
+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)

+ 15 - 9
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',