瀏覽代碼

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

4121 filter lookup expressions
Jeremy Stretch 6 年之前
父節點
當前提交
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
 GET /api/dcim/interfaces/?device_id=123
 ```
 ```
 
 
+See [filtering](filtering.md) for more details.
+
 # Serialization
 # Serialization
 
 
 The NetBox API employs three types of serializers to represent model data:
 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'
         - Authentication: 'api/authentication.md'
         - Working with Secrets: 'api/working-with-secrets.md'
         - Working with Secrets: 'api/working-with-secrets.md'
         - Examples: 'api/examples.md'
         - Examples: 'api/examples.md'
+        - Filtering: 'api/filtering.md'
     - Development:
     - Development:
         - Introduction: 'development/index.md'
         - Introduction: 'development/index.md'
         - Style Guide: 'development/style-guide.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 dcim.models import Region, Site
 from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from tenancy.filters import TenancyFilterSet
 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 .choices import *
 from .models import Circuit, CircuitTermination, CircuitType, Provider
 from .models import Circuit, CircuitTermination, CircuitType, Provider
 
 
@@ -16,7 +18,7 @@ __all__ = (
 )
 )
 
 
 
 
-class ProviderFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
+class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -27,12 +29,14 @@ class ProviderFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
     )
     )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='circuits__terminations__site__region__in',
+        field_name='circuits__terminations__site__region',
+        lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='circuits__terminations__site__region__in',
+        field_name='circuits__terminations__site__region',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
@@ -65,14 +69,14 @@ class ProviderFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
         )
         )
 
 
 
 
-class CircuitTypeFilterSet(NameSlugSearchFilterSet):
+class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class CircuitFilterSet(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
+class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -118,12 +122,14 @@ class CircuitFilterSet(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFil
     )
     )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='terminations__site__region__in',
+        field_name='terminations__site__region',
+        lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='terminations__site__region__in',
+        field_name='terminations__site__region',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
@@ -146,7 +152,7 @@ class CircuitFilterSet(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFil
         ).distinct()
         ).distinct()
 
 
 
 
-class CircuitTerminationFilterSet(django_filters.FilterSet):
+class CircuitTerminationFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',

+ 79 - 55
netbox/dcim/filters.py

@@ -6,8 +6,8 @@ from tenancy.filters import TenancyFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.constants import COLOR_CHOICES
 from utilities.constants import COLOR_CHOICES
 from utilities.filters import (
 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 virtualization.models import Cluster
 from .choices import *
 from .choices import *
@@ -60,7 +60,7 @@ __all__ = (
 )
 )
 
 
 
 
-class RegionFilterSet(NameSlugSearchFilterSet):
+class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         label='Parent region (ID)',
         label='Parent region (ID)',
@@ -77,7 +77,7 @@ class RegionFilterSet(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class SiteFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
+class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -92,12 +92,14 @@ class SiteFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter
     )
     )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='region__in',
+        field_name='region',
+        lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='region__in',
+        field_name='region',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
@@ -131,15 +133,17 @@ class SiteFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
-class RackGroupFilterSet(NameSlugSearchFilterSet):
+class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region__in',
+        field_name='site__region',
+        lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region__in',
+        field_name='site__region',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
@@ -159,14 +163,14 @@ class RackGroupFilterSet(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class RackRoleFilterSet(NameSlugSearchFilterSet):
+class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = RackRole
         model = RackRole
         fields = ['id', 'name', 'slug', 'color']
         fields = ['id', 'name', 'slug', 'color']
 
 
 
 
-class RackFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
+class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -177,12 +181,14 @@ class RackFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter
     )
     )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region__in',
+        field_name='site__region',
+        lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region__in',
+        field_name='site__region',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
@@ -244,7 +250,7 @@ class RackFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter
         )
         )
 
 
 
 
-class RackReservationFilterSet(TenancyFilterSet):
+class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -305,14 +311,14 @@ class RackReservationFilterSet(TenancyFilterSet):
         )
         )
 
 
 
 
-class ManufacturerFilterSet(NameSlugSearchFilterSet):
+class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class DeviceTypeFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
+class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -410,70 +416,70 @@ class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet):
     )
     )
 
 
 
 
-class ConsolePortTemplateFilterSet(DeviceTypeComponentFilterSet):
+class ConsolePortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = ConsolePortTemplate
         model = ConsolePortTemplate
         fields = ['id', 'name', 'type']
         fields = ['id', 'name', 'type']
 
 
 
 
-class ConsoleServerPortTemplateFilterSet(DeviceTypeComponentFilterSet):
+class ConsoleServerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPortTemplate
         model = ConsoleServerPortTemplate
         fields = ['id', 'name', 'type']
         fields = ['id', 'name', 'type']
 
 
 
 
-class PowerPortTemplateFilterSet(DeviceTypeComponentFilterSet):
+class PowerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = PowerPortTemplate
         model = PowerPortTemplate
         fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw']
         fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw']
 
 
 
 
-class PowerOutletTemplateFilterSet(DeviceTypeComponentFilterSet):
+class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = PowerOutletTemplate
         model = PowerOutletTemplate
         fields = ['id', 'name', 'type', 'feed_leg']
         fields = ['id', 'name', 'type', 'feed_leg']
 
 
 
 
-class InterfaceTemplateFilterSet(DeviceTypeComponentFilterSet):
+class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
         fields = ['id', 'name', 'type', 'mgmt_only']
         fields = ['id', 'name', 'type', 'mgmt_only']
 
 
 
 
-class FrontPortTemplateFilterSet(DeviceTypeComponentFilterSet):
+class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
         fields = ['id', 'name', 'type']
         fields = ['id', 'name', 'type']
 
 
 
 
-class RearPortTemplateFilterSet(DeviceTypeComponentFilterSet):
+class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = RearPortTemplate
         model = RearPortTemplate
         fields = ['id', 'name', 'type', 'positions']
         fields = ['id', 'name', 'type', 'positions']
 
 
 
 
-class DeviceBayTemplateFilterSet(DeviceTypeComponentFilterSet):
+class DeviceBayTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = DeviceBayTemplate
         model = DeviceBayTemplate
         fields = ['id', 'name']
         fields = ['id', 'name']
 
 
 
 
-class DeviceRoleFilterSet(NameSlugSearchFilterSet):
+class DeviceRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
         fields = ['id', 'name', 'slug', 'color', 'vm_role']
         fields = ['id', 'name', 'slug', 'color', 'vm_role']
 
 
 
 
-class PlatformFilterSet(NameSlugSearchFilterSet):
+class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         field_name='manufacturer',
         field_name='manufacturer',
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
@@ -491,7 +497,13 @@ class PlatformFilterSet(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug', 'napalm_driver']
         fields = ['id', 'name', 'slug', 'napalm_driver']
 
 
 
 
-class DeviceFilterSet(LocalConfigContextFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
+class DeviceFilterSet(
+    BaseFilterSet,
+    TenancyFilterSet,
+    LocalConfigContextFilterSet,
+    CustomFieldFilterSet,
+    CreatedUpdatedFilterSet
+):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -538,12 +550,14 @@ class DeviceFilterSet(LocalConfigContextFilterSet, TenancyFilterSet, CustomField
     )
     )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region__in',
+        field_name='site__region',
+        lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region__in',
+        field_name='site__region',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
@@ -697,12 +711,14 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
     )
     )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='device__site__region__in',
+        field_name='device__site__region',
+        lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='device__site__region__in',
+        field_name='device__site__region',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
@@ -738,7 +754,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
         )
         )
 
 
 
 
-class ConsolePortFilterSet(DeviceComponentFilterSet):
+class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
         null_value=None
         null_value=None
@@ -754,7 +770,7 @@ class ConsolePortFilterSet(DeviceComponentFilterSet):
         fields = ['id', 'name', 'description', 'connection_status']
         fields = ['id', 'name', 'description', 'connection_status']
 
 
 
 
-class ConsoleServerPortFilterSet(DeviceComponentFilterSet):
+class ConsoleServerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
         null_value=None
         null_value=None
@@ -770,7 +786,7 @@ class ConsoleServerPortFilterSet(DeviceComponentFilterSet):
         fields = ['id', 'name', 'description', 'connection_status']
         fields = ['id', 'name', 'description', 'connection_status']
 
 
 
 
-class PowerPortFilterSet(DeviceComponentFilterSet):
+class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
         choices=PowerPortTypeChoices,
         choices=PowerPortTypeChoices,
         null_value=None
         null_value=None
@@ -786,7 +802,7 @@ class PowerPortFilterSet(DeviceComponentFilterSet):
         fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connection_status']
         fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connection_status']
 
 
 
 
-class PowerOutletFilterSet(DeviceComponentFilterSet):
+class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
         choices=PowerOutletTypeChoices,
         choices=PowerOutletTypeChoices,
         null_value=None
         null_value=None
@@ -802,7 +818,7 @@ class PowerOutletFilterSet(DeviceComponentFilterSet):
         fields = ['id', 'name', 'feed_leg', 'description', 'connection_status']
         fields = ['id', 'name', 'feed_leg', 'description', 'connection_status']
 
 
 
 
-class InterfaceFilterSet(DeviceComponentFilterSet):
+class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -900,7 +916,7 @@ class InterfaceFilterSet(DeviceComponentFilterSet):
         }.get(value, queryset.none())
         }.get(value, queryset.none())
 
 
 
 
-class FrontPortFilterSet(DeviceComponentFilterSet):
+class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
     cabled = django_filters.BooleanFilter(
     cabled = django_filters.BooleanFilter(
         field_name='cable',
         field_name='cable',
         lookup_expr='isnull',
         lookup_expr='isnull',
@@ -912,7 +928,7 @@ class FrontPortFilterSet(DeviceComponentFilterSet):
         fields = ['id', 'name', 'type', 'description']
         fields = ['id', 'name', 'type', 'description']
 
 
 
 
-class RearPortFilterSet(DeviceComponentFilterSet):
+class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
     cabled = django_filters.BooleanFilter(
     cabled = django_filters.BooleanFilter(
         field_name='cable',
         field_name='cable',
         lookup_expr='isnull',
         lookup_expr='isnull',
@@ -924,26 +940,28 @@ class RearPortFilterSet(DeviceComponentFilterSet):
         fields = ['id', 'name', 'type', 'positions', 'description']
         fields = ['id', 'name', 'type', 'positions', 'description']
 
 
 
 
-class DeviceBayFilterSet(DeviceComponentFilterSet):
+class DeviceBayFilterSet(BaseFilterSet, DeviceComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = DeviceBay
         model = DeviceBay
         fields = ['id', 'name', 'description']
         fields = ['id', 'name', 'description']
 
 
 
 
-class InventoryItemFilterSet(DeviceComponentFilterSet):
+class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='device__site__region__in',
+        field_name='device__site__region',
+        lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='device__site__region__in',
+        field_name='device__site__region',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
@@ -1002,19 +1020,21 @@ class InventoryItemFilterSet(DeviceComponentFilterSet):
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
-class VirtualChassisFilterSet(django_filters.FilterSet):
+class VirtualChassisFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='master__site__region__in',
+        field_name='master__site__region',
+        lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='master__site__region__in',
+        field_name='master__site__region',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
@@ -1056,7 +1076,7 @@ class VirtualChassisFilterSet(django_filters.FilterSet):
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
-class CableFilterSet(django_filters.FilterSet):
+class CableFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -1119,7 +1139,7 @@ class CableFilterSet(django_filters.FilterSet):
         return queryset
         return queryset
 
 
 
 
-class ConsoleConnectionFilterSet(django_filters.FilterSet):
+class ConsoleConnectionFilterSet(BaseFilterSet):
     site = django_filters.CharFilter(
     site = django_filters.CharFilter(
         method='filter_site',
         method='filter_site',
         label='Site (slug)',
         label='Site (slug)',
@@ -1150,7 +1170,7 @@ class ConsoleConnectionFilterSet(django_filters.FilterSet):
         )
         )
 
 
 
 
-class PowerConnectionFilterSet(django_filters.FilterSet):
+class PowerConnectionFilterSet(BaseFilterSet):
     site = django_filters.CharFilter(
     site = django_filters.CharFilter(
         method='filter_site',
         method='filter_site',
         label='Site (slug)',
         label='Site (slug)',
@@ -1181,7 +1201,7 @@ class PowerConnectionFilterSet(django_filters.FilterSet):
         )
         )
 
 
 
 
-class InterfaceConnectionFilterSet(django_filters.FilterSet):
+class InterfaceConnectionFilterSet(BaseFilterSet):
     site = django_filters.CharFilter(
     site = django_filters.CharFilter(
         method='filter_site',
         method='filter_site',
         label='Site (slug)',
         label='Site (slug)',
@@ -1215,7 +1235,7 @@ class InterfaceConnectionFilterSet(django_filters.FilterSet):
         )
         )
 
 
 
 
-class PowerPanelFilterSet(django_filters.FilterSet):
+class PowerPanelFilterSet(BaseFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -1226,12 +1246,14 @@ class PowerPanelFilterSet(django_filters.FilterSet):
     )
     )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region__in',
+        field_name='site__region',
+        lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region__in',
+        field_name='site__region',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
@@ -1264,7 +1286,7 @@ class PowerPanelFilterSet(django_filters.FilterSet):
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
-class PowerFeedFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
+class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -1275,12 +1297,14 @@ class PowerFeedFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
     )
     )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='power_panel__site__region__in',
+        field_name='power_panel__site__region',
+        lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='power_panel__site__region__in',
+        field_name='power_panel__site__region',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (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 dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
+from utilities.filters import BaseFilterSet
 from virtualization.models import Cluster, ClusterGroup
 from virtualization.models import Cluster, ClusterGroup
 from .choices import *
 from .choices import *
 from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag
 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)
             self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf)
 
 
 
 
-class GraphFilterSet(django_filters.FilterSet):
+class GraphFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = Graph
         model = Graph
         fields = ['type', 'name', 'template_language']
         fields = ['type', 'name', 'template_language']
 
 
 
 
-class ExportTemplateFilterSet(django_filters.FilterSet):
+class ExportTemplateFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = ExportTemplate
         model = ExportTemplate
         fields = ['content_type', 'name', 'template_language']
         fields = ['content_type', 'name', 'template_language']
 
 
 
 
-class TagFilterSet(django_filters.FilterSet):
+class TagFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -122,7 +123,7 @@ class TagFilterSet(django_filters.FilterSet):
         )
         )
 
 
 
 
-class ConfigContextFilterSet(django_filters.FilterSet):
+class ConfigContextFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -244,7 +245,7 @@ class LocalConfigContextFilterSet(django_filters.FilterSet):
         return queryset.exclude(local_context_data__isnull=value)
         return queryset.exclude(local_context_data__isnull=value)
 
 
 
 
-class ObjectChangeFilterSet(django_filters.FilterSet):
+class ObjectChangeFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',

+ 6 - 6
netbox/extras/tests/test_filters.py

@@ -28,8 +28,8 @@ class GraphTestCase(TestCase):
         Graph.objects.bulk_create(graphs)
         Graph.objects.bulk_create(graphs)
 
 
     def test_name(self):
     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):
     def test_type(self):
         content_type = ContentType.objects.filter(GRAPH_MODELS).first()
         content_type = ContentType.objects.filter(GRAPH_MODELS).first()
@@ -59,8 +59,8 @@ class ExportTemplateTestCase(TestCase):
         ExportTemplate.objects.bulk_create(export_templates)
         ExportTemplate.objects.bulk_create(export_templates)
 
 
     def test_name(self):
     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):
     def test_content_type(self):
         params = {'content_type': ContentType.objects.get(model='site').pk}
         params = {'content_type': ContentType.objects.get(model='site').pk}
@@ -154,8 +154,8 @@ class ConfigContextTestCase(TestCase):
             c.tenants.set([tenants[i]])
             c.tenants.set([tenants[i]])
 
 
     def test_name(self):
     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):
     def test_is_active(self):
         params = {'is_active': True}
         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 extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from tenancy.filters import TenancyFilterSet
 from tenancy.filters import TenancyFilterSet
 from utilities.filters import (
 from utilities.filters import (
-    MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
+    BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet,
+    NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
 )
 )
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 from .choices import *
 from .choices import *
@@ -28,7 +29,7 @@ __all__ = (
 )
 )
 
 
 
 
-class VRFFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
+class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -53,7 +54,7 @@ class VRFFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterS
         fields = ['name', 'rd', 'enforce_unique']
         fields = ['name', 'rd', 'enforce_unique']
 
 
 
 
-class RIRFilterSet(NameSlugSearchFilterSet):
+class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -64,7 +65,7 @@ class RIRFilterSet(NameSlugSearchFilterSet):
         fields = ['name', 'slug', 'is_private']
         fields = ['name', 'slug', 'is_private']
 
 
 
 
-class AggregateFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
+class AggregateFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -114,7 +115,7 @@ class AggregateFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
             return queryset.none()
             return queryset.none()
 
 
 
 
-class RoleFilterSet(NameSlugSearchFilterSet):
+class RoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -125,7 +126,7 @@ class RoleFilterSet(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class PrefixFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
+class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -166,12 +167,14 @@ class PrefixFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
     )
     )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region__in',
+        field_name='site__region',
+        lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region__in',
+        field_name='site__region',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
@@ -273,7 +276,7 @@ class PrefixFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
         return queryset.filter(prefix__net_mask_length=value)
         return queryset.filter(prefix__net_mask_length=value)
 
 
 
 
-class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
+class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -397,15 +400,17 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF
         return queryset.exclude(interface__isnull=value)
         return queryset.exclude(interface__isnull=value)
 
 
 
 
-class VLANGroupFilterSet(NameSlugSearchFilterSet):
+class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region__in',
+        field_name='site__region',
+        lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region__in',
+        field_name='site__region',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
@@ -425,7 +430,7 @@ class VLANGroupFilterSet(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class VLANFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
+class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -436,12 +441,14 @@ class VLANFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter
     )
     )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region__in',
+        field_name='site__region',
+        lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region__in',
+        field_name='site__region',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
@@ -496,7 +503,7 @@ class VLANFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
-class ServiceFilterSet(CreatedUpdatedFilterSet):
+class ServiceFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='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 dcim.models import Device
 from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 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
 from .models import Secret, SecretRole
 
 
 
 
@@ -13,14 +13,14 @@ __all__ = (
 )
 )
 
 
 
 
-class SecretRoleFilterSet(NameSlugSearchFilterSet):
+class SecretRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = SecretRole
         model = SecretRole
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class SecretFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
+class SecretFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'

+ 3 - 3
netbox/tenancy/filters.py

@@ -2,7 +2,7 @@ import django_filters
 from django.db.models import Q
 from django.db.models import Q
 
 
 from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 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
 from .models import Tenant, TenantGroup
 
 
 
 
@@ -13,14 +13,14 @@ __all__ = (
 )
 )
 
 
 
 
-class TenantGroupFilterSet(NameSlugSearchFilterSet):
+class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = TenantGroup
         model = TenantGroup
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class TenantFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
+class TenantFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'

+ 35 - 0
netbox/utilities/constants.py

@@ -28,12 +28,47 @@ COLOR_CHOICES = (
     ('ffffff', 'White'),
     ('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
 # Keys for PostgreSQL advisory locks. These are arbitrary bigints used by
 # the advisory_lock contextmanager. When a lock is acquired,
 # the advisory_lock contextmanager. When a lock is acquired,
 # one of these keys will be used to identify said lock.
 # one of these keys will be used to identify said lock.
 #
 #
 # When adding a new key, pick something arbitrary and unique so
 # When adding a new key, pick something arbitrary and unique so
 # that it is easily searchable in query logs.
 # that it is easily searchable in query logs.
+
 ADVISORY_LOCK_KEYS = {
 ADVISORY_LOCK_KEYS = {
     'available-prefixes': 100100,
     'available-prefixes': 100100,
     'available-ips': 100200,
     'available-ips': 100200,

+ 166 - 51
netbox/utilities/filters.py

@@ -1,9 +1,16 @@
 import django_filters
 import django_filters
+from copy import deepcopy
 from dcim.forms import MACAddressField
 from dcim.forms import MACAddressField
 from django import forms
 from django import forms
 from django.conf import settings
 from django.conf import settings
 from django.db import models
 from django.db import models
+from django_filters.utils import get_model_field, resolve_field
+
 from extras.models import Tag
 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):
 def multivalue_field_factory(field_class):
@@ -111,6 +118,165 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter):
 # FilterSets
 # 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):
 class NameSlugSearchFilterSet(django_filters.FilterSet):
     """
     """
     A base class for adding the search method to models which only expose the `name` and `slug` fields
     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(name__icontains=value) |
             models.Q(slug__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.conf import settings
+from django.db import models
 from django.test import TestCase
 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):
 class TreeNodeMultipleChoiceFilterTest(TestCase):
@@ -60,3 +72,447 @@ class TreeNodeMultipleChoiceFilterTest(TestCase):
         self.assertEqual(qs.count(), 2)
         self.assertEqual(qs.count(), 2)
         self.assertEqual(qs[0], self.site1)
         self.assertEqual(qs[0], self.site1)
         self.assertEqual(qs[1], self.site3)
         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.filters import TenancyFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.filters import (
 from utilities.filters import (
-    MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
+    BaseFilterSet, MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter,
+    TreeNodeMultipleChoiceFilter,
 )
 )
 from .choices import *
 from .choices import *
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -20,21 +21,21 @@ __all__ = (
 )
 )
 
 
 
 
-class ClusterTypeFilterSet(NameSlugSearchFilterSet):
+class ClusterTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = ClusterType
         model = ClusterType
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class ClusterGroupFilterSet(NameSlugSearchFilterSet):
+class ClusterGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = ClusterGroup
         model = ClusterGroup
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class ClusterFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
+class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -45,12 +46,14 @@ class ClusterFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFil
     )
     )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region__in',
+        field_name='site__region',
+        lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region__in',
+        field_name='site__region',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
@@ -100,6 +103,7 @@ class ClusterFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFil
 
 
 
 
 class VirtualMachineFilterSet(
 class VirtualMachineFilterSet(
+    BaseFilterSet,
     LocalConfigContextFilterSet,
     LocalConfigContextFilterSet,
     TenancyFilterSet,
     TenancyFilterSet,
     CustomFieldFilterSet,
     CustomFieldFilterSet,
@@ -145,12 +149,14 @@ class VirtualMachineFilterSet(
     )
     )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='cluster__site__region__in',
+        field_name='cluster__site__region',
+        lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='cluster__site__region__in',
+        field_name='cluster__site__region',
+        lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
@@ -204,7 +210,7 @@ class VirtualMachineFilterSet(
         )
         )
 
 
 
 
-class InterfaceFilterSet(django_filters.FilterSet):
+class InterfaceFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',