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

Merge pull request #6315 from netbox-community/6314-filterset-cleanup

Closes #6314: FilterSet cleanup
Jeremy Stretch 4 лет назад
Родитель
Сommit
b93570eeb0
37 измененных файлов с 933 добавлено и 924 удалено
  1. 6 6
      netbox/circuits/api/views.py
  2. 10 11
      netbox/circuits/filtersets.py
  3. 1 1
      netbox/circuits/tests/test_filters.py
  4. 11 11
      netbox/circuits/views.py
  5. 37 37
      netbox/dcim/api/views.py
  6. 46 60
      netbox/dcim/filtersets.py
  7. 1 1
      netbox/dcim/tests/test_filters.py
  8. 80 80
      netbox/dcim/views.py
  9. 12 12
      netbox/extras/api/views.py
  10. 10 358
      netbox/extras/filters.py
  11. 340 0
      netbox/extras/filtersets.py
  12. 1 1
      netbox/extras/tests/test_customfields.py
  13. 1 1
      netbox/extras/tests/test_filters.py
  14. 8 8
      netbox/extras/views.py
  15. 11 11
      netbox/ipam/api/views.py
  16. 14 14
      netbox/ipam/filtersets.py
  17. 1 1
      netbox/ipam/tests/test_filters.py
  18. 30 30
      netbox/ipam/views.py
  19. 6 6
      netbox/netbox/constants.py
  20. 238 0
      netbox/netbox/filtersets.py
  21. 3 3
      netbox/secrets/api/views.py
  22. 4 4
      netbox/secrets/filtersets.py
  23. 1 1
      netbox/secrets/tests/test_filters.py
  24. 6 6
      netbox/secrets/views.py
  25. 3 3
      netbox/tenancy/api/views.py
  26. 5 4
      netbox/tenancy/filtersets.py
  27. 1 1
      netbox/tenancy/tests/test_filters.py
  28. 5 5
      netbox/tenancy/views.py
  29. 4 4
      netbox/users/api/views.py
  30. 1 1
      netbox/users/filtersets.py
  31. 1 1
      netbox/users/tests/test_filters.py
  32. 2 204
      netbox/utilities/filters.py
  33. 5 3
      netbox/utilities/tests/test_filters.py
  34. 6 6
      netbox/virtualization/api/views.py
  35. 10 17
      netbox/virtualization/filtersets.py
  36. 1 1
      netbox/virtualization/tests/test_filters.py
  37. 11 11
      netbox/virtualization/views.py

+ 6 - 6
netbox/circuits/api/views.py

@@ -1,6 +1,6 @@
 from rest_framework.routers import APIRootView
 
-from circuits import filters
+from circuits import filtersets
 from circuits.models import *
 from dcim.api.views import PassThroughPortMixin
 from extras.api.views import CustomFieldModelViewSet
@@ -26,7 +26,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
         circuit_count=count_related(Circuit, 'provider')
     )
     serializer_class = serializers.ProviderSerializer
-    filterset_class = filters.ProviderFilterSet
+    filterset_class = filtersets.ProviderFilterSet
 
 
 #
@@ -38,7 +38,7 @@ class CircuitTypeViewSet(CustomFieldModelViewSet):
         circuit_count=count_related(Circuit, 'type')
     )
     serializer_class = serializers.CircuitTypeSerializer
-    filterset_class = filters.CircuitTypeFilterSet
+    filterset_class = filtersets.CircuitTypeFilterSet
 
 
 #
@@ -50,7 +50,7 @@ class CircuitViewSet(CustomFieldModelViewSet):
         'type', 'tenant', 'provider', 'termination_a', 'termination_z'
     ).prefetch_related('tags')
     serializer_class = serializers.CircuitSerializer
-    filterset_class = filters.CircuitFilterSet
+    filterset_class = filtersets.CircuitFilterSet
 
 
 #
@@ -62,7 +62,7 @@ class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet):
         'circuit', 'site', 'provider_network', 'cable'
     )
     serializer_class = serializers.CircuitTerminationSerializer
-    filterset_class = filters.CircuitTerminationFilterSet
+    filterset_class = filtersets.CircuitTerminationFilterSet
     brief_prefetch_fields = ['circuit']
 
 
@@ -73,4 +73,4 @@ class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet):
 class ProviderNetworkViewSet(CustomFieldModelViewSet):
     queryset = ProviderNetwork.objects.prefetch_related('tags')
     serializer_class = serializers.ProviderNetworkSerializer
-    filterset_class = filters.ProviderNetworkFilterSet
+    filterset_class = filtersets.ProviderNetworkFilterSet

+ 10 - 11
netbox/circuits/filters.py → netbox/circuits/filtersets.py

@@ -1,13 +1,12 @@
 import django_filters
 from django.db.models import Q
 
-from dcim.filters import CableTerminationFilterSet
+from dcim.filtersets import CableTerminationFilterSet
 from dcim.models import Region, Site, SiteGroup
-from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
-from tenancy.filters import TenancyFilterSet
-from utilities.filters import (
-    BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter
-)
+from extras.filters import TagFilter
+from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
+from tenancy.filtersets import TenancyFilterSet
+from utilities.filters import TreeNodeMultipleChoiceFilter
 from .choices import *
 from .models import *
 
@@ -20,7 +19,7 @@ __all__ = (
 )
 
 
-class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class ProviderFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -80,7 +79,7 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdated
         )
 
 
-class ProviderNetworkFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class ProviderNetworkFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -110,14 +109,14 @@ class ProviderNetworkFilterSet(BaseFilterSet, CustomFieldModelFilterSet, Created
         ).distinct()
 
 
-class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class CircuitTypeFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = CircuitType
         fields = ['id', 'name', 'slug']
 
 
-class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
+class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -207,7 +206,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe
         ).distinct()
 
 
-class CircuitTerminationFilterSet(BaseFilterSet, CreatedUpdatedFilterSet, CableTerminationFilterSet):
+class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',

+ 1 - 1
netbox/circuits/tests/test_filters.py

@@ -1,7 +1,7 @@
 from django.test import TestCase
 
 from circuits.choices import *
-from circuits.filters import *
+from circuits.filtersets import *
 from circuits.models import *
 from dcim.models import Cable, Region, Site, SiteGroup
 from tenancy.models import Tenant, TenantGroup

+ 11 - 11
netbox/circuits/views.py

@@ -7,7 +7,7 @@ from netbox.views import generic
 from utilities.forms import ConfirmationForm
 from utilities.tables import paginate_table
 from utilities.utils import count_related
-from . import filters, forms, tables
+from . import filtersets, forms, tables
 from .choices import CircuitTerminationSideChoices
 from .models import *
 
@@ -20,7 +20,7 @@ class ProviderListView(generic.ObjectListView):
     queryset = Provider.objects.annotate(
         count_circuits=count_related(Circuit, 'provider')
     )
-    filterset = filters.ProviderFilterSet
+    filterset = filtersets.ProviderFilterSet
     filterset_form = forms.ProviderFilterForm
     table = tables.ProviderTable
 
@@ -63,7 +63,7 @@ class ProviderBulkEditView(generic.BulkEditView):
     queryset = Provider.objects.annotate(
         count_circuits=count_related(Circuit, 'provider')
     )
-    filterset = filters.ProviderFilterSet
+    filterset = filtersets.ProviderFilterSet
     table = tables.ProviderTable
     form = forms.ProviderBulkEditForm
 
@@ -72,7 +72,7 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
     queryset = Provider.objects.annotate(
         count_circuits=count_related(Circuit, 'provider')
     )
-    filterset = filters.ProviderFilterSet
+    filterset = filtersets.ProviderFilterSet
     table = tables.ProviderTable
 
 
@@ -82,7 +82,7 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
 
 class ProviderNetworkListView(generic.ObjectListView):
     queryset = ProviderNetwork.objects.all()
-    filterset = filters.ProviderNetworkFilterSet
+    filterset = filtersets.ProviderNetworkFilterSet
     filterset_form = forms.ProviderNetworkFilterForm
     table = tables.ProviderNetworkTable
 
@@ -125,14 +125,14 @@ class ProviderNetworkBulkImportView(generic.BulkImportView):
 
 class ProviderNetworkBulkEditView(generic.BulkEditView):
     queryset = ProviderNetwork.objects.all()
-    filterset = filters.ProviderNetworkFilterSet
+    filterset = filtersets.ProviderNetworkFilterSet
     table = tables.ProviderNetworkTable
     form = forms.ProviderNetworkBulkEditForm
 
 
 class ProviderNetworkBulkDeleteView(generic.BulkDeleteView):
     queryset = ProviderNetwork.objects.all()
-    filterset = filters.ProviderNetworkFilterSet
+    filterset = filtersets.ProviderNetworkFilterSet
     table = tables.ProviderNetworkTable
 
 
@@ -183,7 +183,7 @@ class CircuitTypeBulkEditView(generic.BulkEditView):
     queryset = CircuitType.objects.annotate(
         circuit_count=count_related(Circuit, 'type')
     )
-    filterset = filters.CircuitTypeFilterSet
+    filterset = filtersets.CircuitTypeFilterSet
     table = tables.CircuitTypeTable
     form = forms.CircuitTypeBulkEditForm
 
@@ -203,7 +203,7 @@ class CircuitListView(generic.ObjectListView):
     queryset = Circuit.objects.prefetch_related(
         'provider', 'type', 'tenant', 'termination_a', 'termination_z'
     )
-    filterset = filters.CircuitFilterSet
+    filterset = filtersets.CircuitFilterSet
     filterset_form = forms.CircuitFilterForm
     table = tables.CircuitTable
 
@@ -252,7 +252,7 @@ class CircuitBulkEditView(generic.BulkEditView):
     queryset = Circuit.objects.prefetch_related(
         'provider', 'type', 'tenant', 'terminations'
     )
-    filterset = filters.CircuitFilterSet
+    filterset = filtersets.CircuitFilterSet
     table = tables.CircuitTable
     form = forms.CircuitBulkEditForm
 
@@ -261,7 +261,7 @@ class CircuitBulkDeleteView(generic.BulkDeleteView):
     queryset = Circuit.objects.prefetch_related(
         'provider', 'type', 'tenant', 'terminations'
     )
-    filterset = filters.CircuitFilterSet
+    filterset = filtersets.CircuitFilterSet
     table = tables.CircuitTable
 
 

+ 37 - 37
netbox/dcim/api/views.py

@@ -16,7 +16,7 @@ from rest_framework.routers import APIRootView
 from rest_framework.viewsets import GenericViewSet, ViewSet
 
 from circuits.models import Circuit
-from dcim import filters
+from dcim import filtersets
 from dcim.models import *
 from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
 from ipam.models import Prefix, VLAN
@@ -103,7 +103,7 @@ class RegionViewSet(CustomFieldModelViewSet):
         cumulative=True
     )
     serializer_class = serializers.RegionSerializer
-    filterset_class = filters.RegionFilterSet
+    filterset_class = filtersets.RegionFilterSet
 
 
 #
@@ -119,7 +119,7 @@ class SiteGroupViewSet(CustomFieldModelViewSet):
         cumulative=True
     )
     serializer_class = serializers.SiteGroupSerializer
-    filterset_class = filters.SiteGroupFilterSet
+    filterset_class = filtersets.SiteGroupFilterSet
 
 
 #
@@ -138,7 +138,7 @@ class SiteViewSet(CustomFieldModelViewSet):
         virtualmachine_count=count_related(VirtualMachine, 'cluster__site')
     )
     serializer_class = serializers.SiteSerializer
-    filterset_class = filters.SiteFilterSet
+    filterset_class = filtersets.SiteFilterSet
 
 
 #
@@ -160,7 +160,7 @@ class LocationViewSet(CustomFieldModelViewSet):
         cumulative=True
     ).prefetch_related('site')
     serializer_class = serializers.LocationSerializer
-    filterset_class = filters.LocationFilterSet
+    filterset_class = filtersets.LocationFilterSet
 
 
 #
@@ -172,7 +172,7 @@ class RackRoleViewSet(CustomFieldModelViewSet):
         rack_count=count_related(Rack, 'role')
     )
     serializer_class = serializers.RackRoleSerializer
-    filterset_class = filters.RackRoleFilterSet
+    filterset_class = filtersets.RackRoleFilterSet
 
 
 #
@@ -187,7 +187,7 @@ class RackViewSet(CustomFieldModelViewSet):
         powerfeed_count=count_related(PowerFeed, 'rack')
     )
     serializer_class = serializers.RackSerializer
-    filterset_class = filters.RackFilterSet
+    filterset_class = filtersets.RackFilterSet
 
     @swagger_auto_schema(
         responses={200: serializers.RackUnitSerializer(many=True)},
@@ -244,7 +244,7 @@ class RackViewSet(CustomFieldModelViewSet):
 class RackReservationViewSet(ModelViewSet):
     queryset = RackReservation.objects.prefetch_related('rack', 'user', 'tenant')
     serializer_class = serializers.RackReservationSerializer
-    filterset_class = filters.RackReservationFilterSet
+    filterset_class = filtersets.RackReservationFilterSet
 
     # Assign user from request
     def perform_create(self, serializer):
@@ -262,7 +262,7 @@ class ManufacturerViewSet(CustomFieldModelViewSet):
         platform_count=count_related(Platform, 'manufacturer')
     )
     serializer_class = serializers.ManufacturerSerializer
-    filterset_class = filters.ManufacturerFilterSet
+    filterset_class = filtersets.ManufacturerFilterSet
 
 
 #
@@ -274,7 +274,7 @@ class DeviceTypeViewSet(CustomFieldModelViewSet):
         device_count=count_related(Device, 'device_type')
     )
     serializer_class = serializers.DeviceTypeSerializer
-    filterset_class = filters.DeviceTypeFilterSet
+    filterset_class = filtersets.DeviceTypeFilterSet
     brief_prefetch_fields = ['manufacturer']
 
 
@@ -285,49 +285,49 @@ class DeviceTypeViewSet(CustomFieldModelViewSet):
 class ConsolePortTemplateViewSet(ModelViewSet):
     queryset = ConsolePortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.ConsolePortTemplateSerializer
-    filterset_class = filters.ConsolePortTemplateFilterSet
+    filterset_class = filtersets.ConsolePortTemplateFilterSet
 
 
 class ConsoleServerPortTemplateViewSet(ModelViewSet):
     queryset = ConsoleServerPortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.ConsoleServerPortTemplateSerializer
-    filterset_class = filters.ConsoleServerPortTemplateFilterSet
+    filterset_class = filtersets.ConsoleServerPortTemplateFilterSet
 
 
 class PowerPortTemplateViewSet(ModelViewSet):
     queryset = PowerPortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.PowerPortTemplateSerializer
-    filterset_class = filters.PowerPortTemplateFilterSet
+    filterset_class = filtersets.PowerPortTemplateFilterSet
 
 
 class PowerOutletTemplateViewSet(ModelViewSet):
     queryset = PowerOutletTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.PowerOutletTemplateSerializer
-    filterset_class = filters.PowerOutletTemplateFilterSet
+    filterset_class = filtersets.PowerOutletTemplateFilterSet
 
 
 class InterfaceTemplateViewSet(ModelViewSet):
     queryset = InterfaceTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.InterfaceTemplateSerializer
-    filterset_class = filters.InterfaceTemplateFilterSet
+    filterset_class = filtersets.InterfaceTemplateFilterSet
 
 
 class FrontPortTemplateViewSet(ModelViewSet):
     queryset = FrontPortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.FrontPortTemplateSerializer
-    filterset_class = filters.FrontPortTemplateFilterSet
+    filterset_class = filtersets.FrontPortTemplateFilterSet
 
 
 class RearPortTemplateViewSet(ModelViewSet):
     queryset = RearPortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.RearPortTemplateSerializer
-    filterset_class = filters.RearPortTemplateFilterSet
+    filterset_class = filtersets.RearPortTemplateFilterSet
 
 
 class DeviceBayTemplateViewSet(ModelViewSet):
     queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.DeviceBayTemplateSerializer
-    filterset_class = filters.DeviceBayTemplateFilterSet
+    filterset_class = filtersets.DeviceBayTemplateFilterSet
 
 
 #
@@ -340,7 +340,7 @@ class DeviceRoleViewSet(CustomFieldModelViewSet):
         virtualmachine_count=count_related(VirtualMachine, 'role')
     )
     serializer_class = serializers.DeviceRoleSerializer
-    filterset_class = filters.DeviceRoleFilterSet
+    filterset_class = filtersets.DeviceRoleFilterSet
 
 
 #
@@ -353,7 +353,7 @@ class PlatformViewSet(CustomFieldModelViewSet):
         virtualmachine_count=count_related(VirtualMachine, 'platform')
     )
     serializer_class = serializers.PlatformSerializer
-    filterset_class = filters.PlatformFilterSet
+    filterset_class = filtersets.PlatformFilterSet
 
 
 #
@@ -365,7 +365,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
         'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
         'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
     )
-    filterset_class = filters.DeviceFilterSet
+    filterset_class = filtersets.DeviceFilterSet
 
     def get_serializer_class(self):
         """
@@ -510,7 +510,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
 class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
     queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
     serializer_class = serializers.ConsolePortSerializer
-    filterset_class = filters.ConsolePortFilterSet
+    filterset_class = filtersets.ConsolePortFilterSet
     brief_prefetch_fields = ['device']
 
 
@@ -519,21 +519,21 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
         'device', '_path__destination', 'cable', '_cable_peer', 'tags'
     )
     serializer_class = serializers.ConsoleServerPortSerializer
-    filterset_class = filters.ConsoleServerPortFilterSet
+    filterset_class = filtersets.ConsoleServerPortFilterSet
     brief_prefetch_fields = ['device']
 
 
 class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
     queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
     serializer_class = serializers.PowerPortSerializer
-    filterset_class = filters.PowerPortFilterSet
+    filterset_class = filtersets.PowerPortFilterSet
     brief_prefetch_fields = ['device']
 
 
 class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
     queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
     serializer_class = serializers.PowerOutletSerializer
-    filterset_class = filters.PowerOutletFilterSet
+    filterset_class = filtersets.PowerOutletFilterSet
     brief_prefetch_fields = ['device']
 
 
@@ -542,35 +542,35 @@ class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
         'device', 'parent', 'lag', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags'
     )
     serializer_class = serializers.InterfaceSerializer
-    filterset_class = filters.InterfaceFilterSet
+    filterset_class = filtersets.InterfaceFilterSet
     brief_prefetch_fields = ['device']
 
 
 class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
     queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
     serializer_class = serializers.FrontPortSerializer
-    filterset_class = filters.FrontPortFilterSet
+    filterset_class = filtersets.FrontPortFilterSet
     brief_prefetch_fields = ['device']
 
 
 class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
     queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
     serializer_class = serializers.RearPortSerializer
-    filterset_class = filters.RearPortFilterSet
+    filterset_class = filtersets.RearPortFilterSet
     brief_prefetch_fields = ['device']
 
 
 class DeviceBayViewSet(ModelViewSet):
     queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags')
     serializer_class = serializers.DeviceBaySerializer
-    filterset_class = filters.DeviceBayFilterSet
+    filterset_class = filtersets.DeviceBayFilterSet
     brief_prefetch_fields = ['device']
 
 
 class InventoryItemViewSet(ModelViewSet):
     queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags')
     serializer_class = serializers.InventoryItemSerializer
-    filterset_class = filters.InventoryItemFilterSet
+    filterset_class = filtersets.InventoryItemFilterSet
     brief_prefetch_fields = ['device']
 
 
@@ -583,7 +583,7 @@ class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet):
         _path__destination_id__isnull=False
     )
     serializer_class = serializers.ConsolePortSerializer
-    filterset_class = filters.ConsoleConnectionFilterSet
+    filterset_class = filtersets.ConsoleConnectionFilterSet
 
 
 class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
@@ -591,7 +591,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
         _path__destination_id__isnull=False
     )
     serializer_class = serializers.PowerPortSerializer
-    filterset_class = filters.PowerConnectionFilterSet
+    filterset_class = filtersets.PowerConnectionFilterSet
 
 
 class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
@@ -603,7 +603,7 @@ class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
         pk__lt=F('_path__destination_id')
     )
     serializer_class = serializers.InterfaceConnectionSerializer
-    filterset_class = filters.InterfaceConnectionFilterSet
+    filterset_class = filtersets.InterfaceConnectionFilterSet
 
 
 #
@@ -616,7 +616,7 @@ class CableViewSet(ModelViewSet):
         'termination_a', 'termination_b'
     )
     serializer_class = serializers.CableSerializer
-    filterset_class = filters.CableFilterSet
+    filterset_class = filtersets.CableFilterSet
 
 
 #
@@ -628,7 +628,7 @@ class VirtualChassisViewSet(ModelViewSet):
         member_count=count_related(Device, 'virtual_chassis')
     )
     serializer_class = serializers.VirtualChassisSerializer
-    filterset_class = filters.VirtualChassisFilterSet
+    filterset_class = filtersets.VirtualChassisFilterSet
     brief_prefetch_fields = ['master']
 
 
@@ -643,7 +643,7 @@ class PowerPanelViewSet(ModelViewSet):
         powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
     serializer_class = serializers.PowerPanelSerializer
-    filterset_class = filters.PowerPanelFilterSet
+    filterset_class = filtersets.PowerPanelFilterSet
 
 
 #
@@ -655,7 +655,7 @@ class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet):
         'power_panel', 'rack', '_path__destination', 'cable', '_cable_peer', 'tags'
     )
     serializer_class = serializers.PowerFeedSerializer
-    filterset_class = filters.PowerFeedFilterSet
+    filterset_class = filtersets.PowerFeedFilterSet
 
 
 #

+ 46 - 60
netbox/dcim/filters.py → netbox/dcim/filtersets.py

@@ -1,13 +1,16 @@
 import django_filters
 from django.contrib.auth.models import User
 
-from extras.filters import CustomFieldModelFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
-from tenancy.filters import TenancyFilterSet
+from extras.filters import TagFilter
+from extras.filtersets import LocalConfigContextFilterSet
+from netbox.filtersets import (
+    BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
+)
+from tenancy.filtersets import TenancyFilterSet
 from tenancy.models import Tenant
 from utilities.choices import ColorChoices
 from utilities.filters import (
-    BaseFilterSet, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter,
-    NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter,
+    MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
 )
 from virtualization.models import Cluster
 from .choices import *
@@ -57,7 +60,7 @@ __all__ = (
 )
 
 
-class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class RegionFilterSet(OrganizationalModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Region.objects.all(),
         label='Parent region (ID)',
@@ -74,7 +77,7 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilt
         fields = ['id', 'name', 'slug', 'description']
 
 
-class SiteGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class SiteGroupFilterSet(OrganizationalModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
         label='Parent site group (ID)',
@@ -91,7 +94,7 @@ class SiteGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedF
         fields = ['id', 'name', 'slug', 'description']
 
 
-class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -154,7 +157,7 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
         return queryset.filter(qs_filter)
 
 
-class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class LocationFilterSet(OrganizationalModelFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         field_name='site__region',
@@ -218,14 +221,14 @@ class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFi
         )
 
 
-class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class RackRoleFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = RackRole
         fields = ['id', 'name', 'slug', 'color']
 
 
-class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -323,7 +326,7 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
         )
 
 
-class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -383,14 +386,14 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModel
         )
 
 
-class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class ManufacturerFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = Manufacturer
         fields = ['id', 'name', 'slug', 'description']
 
 
-class DeviceTypeFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class DeviceTypeFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -476,7 +479,7 @@ class DeviceTypeFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdat
         return queryset.exclude(devicebaytemplates__isnull=value)
 
 
-class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class DeviceTypeComponentFilterSet(django_filters.FilterSet):
     devicetype_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DeviceType.objects.all(),
         field_name='device_type_id',
@@ -484,28 +487,28 @@ class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet, CreatedUpdatedFilter
     )
 
 
-class ConsolePortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
+class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
 
     class Meta:
         model = ConsolePortTemplate
         fields = ['id', 'name', 'type']
 
 
-class ConsoleServerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
+class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
 
     class Meta:
         model = ConsoleServerPortTemplate
         fields = ['id', 'name', 'type']
 
 
-class PowerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
+class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
 
     class Meta:
         model = PowerPortTemplate
         fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw']
 
 
-class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
+class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
     feed_leg = django_filters.MultipleChoiceFilter(
         choices=PowerOutletFeedLegChoices,
         null_value=None
@@ -516,7 +519,7 @@ class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
         fields = ['id', 'name', 'type', 'feed_leg']
 
 
-class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
+class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=InterfaceTypeChoices,
         null_value=None
@@ -527,7 +530,7 @@ class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
         fields = ['id', 'name', 'type', 'mgmt_only']
 
 
-class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
+class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=PortTypeChoices,
         null_value=None
@@ -538,7 +541,7 @@ class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
         fields = ['id', 'name', 'type']
 
 
-class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
+class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=PortTypeChoices,
         null_value=None
@@ -549,21 +552,21 @@ class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
         fields = ['id', 'name', 'type', 'positions']
 
 
-class DeviceBayTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
+class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
 
     class Meta:
         model = DeviceBayTemplate
         fields = ['id', 'name']
 
 
-class DeviceRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class DeviceRoleFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = DeviceRole
         fields = ['id', 'name', 'slug', 'color', 'vm_role']
 
 
-class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class PlatformFilterSet(OrganizationalModelFilterSet):
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         field_name='manufacturer',
         queryset=Manufacturer.objects.all(),
@@ -581,13 +584,7 @@ class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFi
         fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
 
 
-class DeviceFilterSet(
-    BaseFilterSet,
-    TenancyFilterSet,
-    LocalConfigContextFilterSet,
-    CustomFieldModelFilterSet,
-    CreatedUpdatedFilterSet
-):
+class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -792,7 +789,7 @@ class DeviceFilterSet(
         return queryset.exclude(devicebays__isnull=value)
 
 
-class DeviceComponentFilterSet(CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class DeviceComponentFilterSet(django_filters.FilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -876,7 +873,7 @@ class PathEndpointFilterSet(django_filters.FilterSet):
             return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False))
 
 
-class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
+class ConsolePortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=ConsolePortTypeChoices,
         null_value=None
@@ -887,12 +884,7 @@ class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina
         fields = ['id', 'name', 'label', 'description']
 
 
-class ConsoleServerPortFilterSet(
-    BaseFilterSet,
-    DeviceComponentFilterSet,
-    CableTerminationFilterSet,
-    PathEndpointFilterSet
-):
+class ConsoleServerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=ConsolePortTypeChoices,
         null_value=None
@@ -903,7 +895,7 @@ class ConsoleServerPortFilterSet(
         fields = ['id', 'name', 'label', 'description']
 
 
-class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
+class PowerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=PowerPortTypeChoices,
         null_value=None
@@ -914,7 +906,7 @@ class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
         fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description']
 
 
-class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
+class PowerOutletFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=PowerOutletTypeChoices,
         null_value=None
@@ -929,7 +921,7 @@ class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina
         fields = ['id', 'name', 'label', 'feed_leg', 'description']
 
 
-class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
+class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -1027,7 +1019,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
         }.get(value, queryset.none())
 
 
-class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
+class FrontPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=PortTypeChoices,
         null_value=None
@@ -1038,7 +1030,7 @@ class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
         fields = ['id', 'name', 'label', 'type', 'description']
 
 
-class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
+class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=PortTypeChoices,
         null_value=None
@@ -1049,14 +1041,14 @@ class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminatio
         fields = ['id', 'name', 'label', 'type', 'positions', 'description']
 
 
-class DeviceBayFilterSet(BaseFilterSet, DeviceComponentFilterSet):
+class DeviceBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
 
     class Meta:
         model = DeviceBay
         fields = ['id', 'name', 'label', 'description']
 
 
-class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
+class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -1129,7 +1121,7 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
         return queryset.filter(qs_filter)
 
 
-class VirtualChassisFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class VirtualChassisFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -1209,7 +1201,7 @@ class VirtualChassisFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedU
         return queryset.filter(qs_filter).distinct()
 
 
-class CableFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class CableFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -1273,7 +1265,7 @@ class CableFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFil
         return queryset
 
 
-class ConnectionFilterSet:
+class ConnectionFilterSet(BaseFilterSet):
 
     def filter_site(self, queryset, name, value):
         if not value.strip():
@@ -1286,7 +1278,7 @@ class ConnectionFilterSet:
         return queryset.filter(**{f'{name}__in': value})
 
 
-class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
+class ConsoleConnectionFilterSet(ConnectionFilterSet):
     site = django_filters.CharFilter(
         method='filter_site',
         label='Site (slug)',
@@ -1304,7 +1296,7 @@ class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
         fields = ['name']
 
 
-class PowerConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
+class PowerConnectionFilterSet(ConnectionFilterSet):
     site = django_filters.CharFilter(
         method='filter_site',
         label='Site (slug)',
@@ -1322,7 +1314,7 @@ class PowerConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
         fields = ['name']
 
 
-class InterfaceConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
+class InterfaceConnectionFilterSet(ConnectionFilterSet):
     site = django_filters.CharFilter(
         method='filter_site',
         label='Site (slug)',
@@ -1340,7 +1332,7 @@ class InterfaceConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
         fields = []
 
 
-class PowerPanelFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class PowerPanelFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -1402,13 +1394,7 @@ class PowerPanelFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdat
         return queryset.filter(qs_filter)
 
 
-class PowerFeedFilterSet(
-    BaseFilterSet,
-    CableTerminationFilterSet,
-    PathEndpointFilterSet,
-    CustomFieldModelFilterSet,
-    CreatedUpdatedFilterSet
-):
+class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',

+ 1 - 1
netbox/dcim/tests/test_filters.py

@@ -2,7 +2,7 @@ from django.contrib.auth.models import User
 from django.test import TestCase
 
 from dcim.choices import *
-from dcim.filters import *
+from dcim.filtersets import *
 from dcim.models import *
 from ipam.models import IPAddress
 from tenancy.models import Tenant, TenantGroup

+ 80 - 80
netbox/dcim/views.py

@@ -24,7 +24,7 @@ from utilities.tables import paginate_table
 from utilities.utils import csv_format, count_related
 from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
 from virtualization.models import VirtualMachine
-from . import filters, forms, tables
+from . import filtersets, forms, tables
 from .choices import DeviceFaceChoices
 from .constants import NONCONNECTABLE_IFACE_TYPES
 from .models import (
@@ -107,7 +107,7 @@ class RegionListView(generic.ObjectListView):
         'site_count',
         cumulative=True
     )
-    filterset = filters.RegionFilterSet
+    filterset = filtersets.RegionFilterSet
     filterset_form = forms.RegionFilterForm
     table = tables.RegionTable
 
@@ -163,7 +163,7 @@ class RegionBulkEditView(generic.BulkEditView):
         'site_count',
         cumulative=True
     )
-    filterset = filters.RegionFilterSet
+    filterset = filtersets.RegionFilterSet
     table = tables.RegionTable
     form = forms.RegionBulkEditForm
 
@@ -176,7 +176,7 @@ class RegionBulkDeleteView(generic.BulkDeleteView):
         'site_count',
         cumulative=True
     )
-    filterset = filters.RegionFilterSet
+    filterset = filtersets.RegionFilterSet
     table = tables.RegionTable
 
 
@@ -192,7 +192,7 @@ class SiteGroupListView(generic.ObjectListView):
         'site_count',
         cumulative=True
     )
-    filterset = filters.SiteGroupFilterSet
+    filterset = filtersets.SiteGroupFilterSet
     filterset_form = forms.SiteGroupFilterForm
     table = tables.SiteGroupTable
 
@@ -248,7 +248,7 @@ class SiteGroupBulkEditView(generic.BulkEditView):
         'site_count',
         cumulative=True
     )
-    filterset = filters.SiteGroupFilterSet
+    filterset = filtersets.SiteGroupFilterSet
     table = tables.SiteGroupTable
     form = forms.SiteGroupBulkEditForm
 
@@ -261,7 +261,7 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
         'site_count',
         cumulative=True
     )
-    filterset = filters.SiteGroupFilterSet
+    filterset = filtersets.SiteGroupFilterSet
     table = tables.SiteGroupTable
 
 
@@ -271,7 +271,7 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
 
 class SiteListView(generic.ObjectListView):
     queryset = Site.objects.all()
-    filterset = filters.SiteFilterSet
+    filterset = filtersets.SiteFilterSet
     filterset_form = forms.SiteFilterForm
     table = tables.SiteTable
 
@@ -326,14 +326,14 @@ class SiteBulkImportView(generic.BulkImportView):
 
 class SiteBulkEditView(generic.BulkEditView):
     queryset = Site.objects.prefetch_related('region', 'tenant')
-    filterset = filters.SiteFilterSet
+    filterset = filtersets.SiteFilterSet
     table = tables.SiteTable
     form = forms.SiteBulkEditForm
 
 
 class SiteBulkDeleteView(generic.BulkDeleteView):
     queryset = Site.objects.prefetch_related('region', 'tenant')
-    filterset = filters.SiteFilterSet
+    filterset = filtersets.SiteFilterSet
     table = tables.SiteTable
 
 
@@ -355,7 +355,7 @@ class LocationListView(generic.ObjectListView):
         'rack_count',
         cumulative=True
     )
-    filterset = filters.LocationFilterSet
+    filterset = filtersets.LocationFilterSet
     filterset_form = forms.LocationFilterForm
     table = tables.LocationTable
 
@@ -414,7 +414,7 @@ class LocationBulkEditView(generic.BulkEditView):
         'rack_count',
         cumulative=True
     ).prefetch_related('site')
-    filterset = filters.LocationFilterSet
+    filterset = filtersets.LocationFilterSet
     table = tables.LocationTable
     form = forms.LocationBulkEditForm
 
@@ -427,7 +427,7 @@ class LocationBulkDeleteView(generic.BulkDeleteView):
         'rack_count',
         cumulative=True
     ).prefetch_related('site')
-    filterset = filters.LocationFilterSet
+    filterset = filtersets.LocationFilterSet
     table = tables.LocationTable
 
 
@@ -478,7 +478,7 @@ class RackRoleBulkEditView(generic.BulkEditView):
     queryset = RackRole.objects.annotate(
         rack_count=count_related(Rack, 'role')
     )
-    filterset = filters.RackRoleFilterSet
+    filterset = filtersets.RackRoleFilterSet
     table = tables.RackRoleTable
     form = forms.RackRoleBulkEditForm
 
@@ -500,7 +500,7 @@ class RackListView(generic.ObjectListView):
     ).annotate(
         device_count=count_related(Device, 'rack')
     )
-    filterset = filters.RackFilterSet
+    filterset = filtersets.RackFilterSet
     filterset_form = forms.RackFilterForm
     table = tables.RackDetailTable
 
@@ -513,7 +513,7 @@ class RackElevationListView(generic.ObjectListView):
 
     def get(self, request):
 
-        racks = filters.RackFilterSet(request.GET, self.queryset).qs
+        racks = filtersets.RackFilterSet(request.GET, self.queryset).qs
         total_count = racks.count()
 
         # Determine ordering
@@ -602,14 +602,14 @@ class RackBulkImportView(generic.BulkImportView):
 
 class RackBulkEditView(generic.BulkEditView):
     queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
-    filterset = filters.RackFilterSet
+    filterset = filtersets.RackFilterSet
     table = tables.RackTable
     form = forms.RackBulkEditForm
 
 
 class RackBulkDeleteView(generic.BulkDeleteView):
     queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
-    filterset = filters.RackFilterSet
+    filterset = filtersets.RackFilterSet
     table = tables.RackTable
 
 
@@ -619,7 +619,7 @@ class RackBulkDeleteView(generic.BulkDeleteView):
 
 class RackReservationListView(generic.ObjectListView):
     queryset = RackReservation.objects.all()
-    filterset = filters.RackReservationFilterSet
+    filterset = filtersets.RackReservationFilterSet
     filterset_form = forms.RackReservationFilterForm
     table = tables.RackReservationTable
 
@@ -662,14 +662,14 @@ class RackReservationImportView(generic.BulkImportView):
 
 class RackReservationBulkEditView(generic.BulkEditView):
     queryset = RackReservation.objects.prefetch_related('rack', 'user')
-    filterset = filters.RackReservationFilterSet
+    filterset = filtersets.RackReservationFilterSet
     table = tables.RackReservationTable
     form = forms.RackReservationBulkEditForm
 
 
 class RackReservationBulkDeleteView(generic.BulkDeleteView):
     queryset = RackReservation.objects.prefetch_related('rack', 'user')
-    filterset = filters.RackReservationFilterSet
+    filterset = filtersets.RackReservationFilterSet
     table = tables.RackReservationTable
 
 
@@ -722,7 +722,7 @@ class ManufacturerBulkEditView(generic.BulkEditView):
     queryset = Manufacturer.objects.annotate(
         devicetype_count=count_related(DeviceType, 'manufacturer')
     )
-    filterset = filters.ManufacturerFilterSet
+    filterset = filtersets.ManufacturerFilterSet
     table = tables.ManufacturerTable
     form = forms.ManufacturerBulkEditForm
 
@@ -742,7 +742,7 @@ class DeviceTypeListView(generic.ObjectListView):
     queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
         instance_count=count_related(Device, 'device_type')
     )
-    filterset = filters.DeviceTypeFilterSet
+    filterset = filtersets.DeviceTypeFilterSet
     filterset_form = forms.DeviceTypeFilterForm
     table = tables.DeviceTypeTable
 
@@ -848,7 +848,7 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
     queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
         instance_count=count_related(Device, 'device_type')
     )
-    filterset = filters.DeviceTypeFilterSet
+    filterset = filtersets.DeviceTypeFilterSet
     table = tables.DeviceTypeTable
     form = forms.DeviceTypeBulkEditForm
 
@@ -857,7 +857,7 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
         instance_count=count_related(Device, 'device_type')
     )
-    filterset = filters.DeviceTypeFilterSet
+    filterset = filtersets.DeviceTypeFilterSet
     table = tables.DeviceTypeTable
 
 
@@ -1190,7 +1190,7 @@ class DeviceRoleBulkEditView(generic.BulkEditView):
         device_count=count_related(Device, 'device_role'),
         vm_count=count_related(VirtualMachine, 'role')
     )
-    filterset = filters.DeviceRoleFilterSet
+    filterset = filtersets.DeviceRoleFilterSet
     table = tables.DeviceRoleTable
     form = forms.DeviceRoleBulkEditForm
 
@@ -1249,7 +1249,7 @@ class PlatformBulkImportView(generic.BulkImportView):
 
 class PlatformBulkEditView(generic.BulkEditView):
     queryset = Platform.objects.all()
-    filterset = filters.PlatformFilterSet
+    filterset = filtersets.PlatformFilterSet
     table = tables.PlatformTable
     form = forms.PlatformBulkEditForm
 
@@ -1265,7 +1265,7 @@ class PlatformBulkDeleteView(generic.BulkDeleteView):
 
 class DeviceListView(generic.ObjectListView):
     queryset = Device.objects.all()
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     filterset_form = forms.DeviceFilterForm
     table = tables.DeviceTable
     template_name = 'dcim/device_list.html'
@@ -1600,14 +1600,14 @@ class ChildDeviceBulkImportView(generic.BulkImportView):
 
 class DeviceBulkEditView(generic.BulkEditView):
     queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     form = forms.DeviceBulkEditForm
 
 
 class DeviceBulkDeleteView(generic.BulkDeleteView):
     queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
 
 
@@ -1617,7 +1617,7 @@ class DeviceBulkDeleteView(generic.BulkDeleteView):
 
 class ConsolePortListView(generic.ObjectListView):
     queryset = ConsolePort.objects.all()
-    filterset = filters.ConsolePortFilterSet
+    filterset = filtersets.ConsolePortFilterSet
     filterset_form = forms.ConsolePortFilterForm
     table = tables.ConsolePortTable
     action_buttons = ('import', 'export')
@@ -1652,7 +1652,7 @@ class ConsolePortBulkImportView(generic.BulkImportView):
 
 class ConsolePortBulkEditView(generic.BulkEditView):
     queryset = ConsolePort.objects.all()
-    filterset = filters.ConsolePortFilterSet
+    filterset = filtersets.ConsolePortFilterSet
     table = tables.ConsolePortTable
     form = forms.ConsolePortBulkEditForm
 
@@ -1667,7 +1667,7 @@ class ConsolePortBulkDisconnectView(BulkDisconnectView):
 
 class ConsolePortBulkDeleteView(generic.BulkDeleteView):
     queryset = ConsolePort.objects.all()
-    filterset = filters.ConsolePortFilterSet
+    filterset = filtersets.ConsolePortFilterSet
     table = tables.ConsolePortTable
 
 
@@ -1677,7 +1677,7 @@ class ConsolePortBulkDeleteView(generic.BulkDeleteView):
 
 class ConsoleServerPortListView(generic.ObjectListView):
     queryset = ConsoleServerPort.objects.all()
-    filterset = filters.ConsoleServerPortFilterSet
+    filterset = filtersets.ConsoleServerPortFilterSet
     filterset_form = forms.ConsoleServerPortFilterForm
     table = tables.ConsoleServerPortTable
     action_buttons = ('import', 'export')
@@ -1712,7 +1712,7 @@ class ConsoleServerPortBulkImportView(generic.BulkImportView):
 
 class ConsoleServerPortBulkEditView(generic.BulkEditView):
     queryset = ConsoleServerPort.objects.all()
-    filterset = filters.ConsoleServerPortFilterSet
+    filterset = filtersets.ConsoleServerPortFilterSet
     table = tables.ConsoleServerPortTable
     form = forms.ConsoleServerPortBulkEditForm
 
@@ -1727,7 +1727,7 @@ class ConsoleServerPortBulkDisconnectView(BulkDisconnectView):
 
 class ConsoleServerPortBulkDeleteView(generic.BulkDeleteView):
     queryset = ConsoleServerPort.objects.all()
-    filterset = filters.ConsoleServerPortFilterSet
+    filterset = filtersets.ConsoleServerPortFilterSet
     table = tables.ConsoleServerPortTable
 
 
@@ -1737,7 +1737,7 @@ class ConsoleServerPortBulkDeleteView(generic.BulkDeleteView):
 
 class PowerPortListView(generic.ObjectListView):
     queryset = PowerPort.objects.all()
-    filterset = filters.PowerPortFilterSet
+    filterset = filtersets.PowerPortFilterSet
     filterset_form = forms.PowerPortFilterForm
     table = tables.PowerPortTable
     action_buttons = ('import', 'export')
@@ -1772,7 +1772,7 @@ class PowerPortBulkImportView(generic.BulkImportView):
 
 class PowerPortBulkEditView(generic.BulkEditView):
     queryset = PowerPort.objects.all()
-    filterset = filters.PowerPortFilterSet
+    filterset = filtersets.PowerPortFilterSet
     table = tables.PowerPortTable
     form = forms.PowerPortBulkEditForm
 
@@ -1787,7 +1787,7 @@ class PowerPortBulkDisconnectView(BulkDisconnectView):
 
 class PowerPortBulkDeleteView(generic.BulkDeleteView):
     queryset = PowerPort.objects.all()
-    filterset = filters.PowerPortFilterSet
+    filterset = filtersets.PowerPortFilterSet
     table = tables.PowerPortTable
 
 
@@ -1797,7 +1797,7 @@ class PowerPortBulkDeleteView(generic.BulkDeleteView):
 
 class PowerOutletListView(generic.ObjectListView):
     queryset = PowerOutlet.objects.all()
-    filterset = filters.PowerOutletFilterSet
+    filterset = filtersets.PowerOutletFilterSet
     filterset_form = forms.PowerOutletFilterForm
     table = tables.PowerOutletTable
     action_buttons = ('import', 'export')
@@ -1832,7 +1832,7 @@ class PowerOutletBulkImportView(generic.BulkImportView):
 
 class PowerOutletBulkEditView(generic.BulkEditView):
     queryset = PowerOutlet.objects.all()
-    filterset = filters.PowerOutletFilterSet
+    filterset = filtersets.PowerOutletFilterSet
     table = tables.PowerOutletTable
     form = forms.PowerOutletBulkEditForm
 
@@ -1847,7 +1847,7 @@ class PowerOutletBulkDisconnectView(BulkDisconnectView):
 
 class PowerOutletBulkDeleteView(generic.BulkDeleteView):
     queryset = PowerOutlet.objects.all()
-    filterset = filters.PowerOutletFilterSet
+    filterset = filtersets.PowerOutletFilterSet
     table = tables.PowerOutletTable
 
 
@@ -1857,7 +1857,7 @@ class PowerOutletBulkDeleteView(generic.BulkDeleteView):
 
 class InterfaceListView(generic.ObjectListView):
     queryset = Interface.objects.all()
-    filterset = filters.InterfaceFilterSet
+    filterset = filtersets.InterfaceFilterSet
     filterset_form = forms.InterfaceFilterForm
     table = tables.InterfaceTable
     action_buttons = ('import', 'export')
@@ -1927,7 +1927,7 @@ class InterfaceBulkImportView(generic.BulkImportView):
 
 class InterfaceBulkEditView(generic.BulkEditView):
     queryset = Interface.objects.all()
-    filterset = filters.InterfaceFilterSet
+    filterset = filtersets.InterfaceFilterSet
     table = tables.InterfaceTable
     form = forms.InterfaceBulkEditForm
 
@@ -1942,7 +1942,7 @@ class InterfaceBulkDisconnectView(BulkDisconnectView):
 
 class InterfaceBulkDeleteView(generic.BulkDeleteView):
     queryset = Interface.objects.all()
-    filterset = filters.InterfaceFilterSet
+    filterset = filtersets.InterfaceFilterSet
     table = tables.InterfaceTable
 
 
@@ -1952,7 +1952,7 @@ class InterfaceBulkDeleteView(generic.BulkDeleteView):
 
 class FrontPortListView(generic.ObjectListView):
     queryset = FrontPort.objects.all()
-    filterset = filters.FrontPortFilterSet
+    filterset = filtersets.FrontPortFilterSet
     filterset_form = forms.FrontPortFilterForm
     table = tables.FrontPortTable
     action_buttons = ('import', 'export')
@@ -1987,7 +1987,7 @@ class FrontPortBulkImportView(generic.BulkImportView):
 
 class FrontPortBulkEditView(generic.BulkEditView):
     queryset = FrontPort.objects.all()
-    filterset = filters.FrontPortFilterSet
+    filterset = filtersets.FrontPortFilterSet
     table = tables.FrontPortTable
     form = forms.FrontPortBulkEditForm
 
@@ -2002,7 +2002,7 @@ class FrontPortBulkDisconnectView(BulkDisconnectView):
 
 class FrontPortBulkDeleteView(generic.BulkDeleteView):
     queryset = FrontPort.objects.all()
-    filterset = filters.FrontPortFilterSet
+    filterset = filtersets.FrontPortFilterSet
     table = tables.FrontPortTable
 
 
@@ -2012,7 +2012,7 @@ class FrontPortBulkDeleteView(generic.BulkDeleteView):
 
 class RearPortListView(generic.ObjectListView):
     queryset = RearPort.objects.all()
-    filterset = filters.RearPortFilterSet
+    filterset = filtersets.RearPortFilterSet
     filterset_form = forms.RearPortFilterForm
     table = tables.RearPortTable
     action_buttons = ('import', 'export')
@@ -2047,7 +2047,7 @@ class RearPortBulkImportView(generic.BulkImportView):
 
 class RearPortBulkEditView(generic.BulkEditView):
     queryset = RearPort.objects.all()
-    filterset = filters.RearPortFilterSet
+    filterset = filtersets.RearPortFilterSet
     table = tables.RearPortTable
     form = forms.RearPortBulkEditForm
 
@@ -2062,7 +2062,7 @@ class RearPortBulkDisconnectView(BulkDisconnectView):
 
 class RearPortBulkDeleteView(generic.BulkDeleteView):
     queryset = RearPort.objects.all()
-    filterset = filters.RearPortFilterSet
+    filterset = filtersets.RearPortFilterSet
     table = tables.RearPortTable
 
 
@@ -2072,7 +2072,7 @@ class RearPortBulkDeleteView(generic.BulkDeleteView):
 
 class DeviceBayListView(generic.ObjectListView):
     queryset = DeviceBay.objects.all()
-    filterset = filters.DeviceBayFilterSet
+    filterset = filtersets.DeviceBayFilterSet
     filterset_form = forms.DeviceBayFilterForm
     table = tables.DeviceBayTable
     action_buttons = ('import', 'export')
@@ -2172,7 +2172,7 @@ class DeviceBayBulkImportView(generic.BulkImportView):
 
 class DeviceBayBulkEditView(generic.BulkEditView):
     queryset = DeviceBay.objects.all()
-    filterset = filters.DeviceBayFilterSet
+    filterset = filtersets.DeviceBayFilterSet
     table = tables.DeviceBayTable
     form = forms.DeviceBayBulkEditForm
 
@@ -2183,7 +2183,7 @@ class DeviceBayBulkRenameView(generic.BulkRenameView):
 
 class DeviceBayBulkDeleteView(generic.BulkDeleteView):
     queryset = DeviceBay.objects.all()
-    filterset = filters.DeviceBayFilterSet
+    filterset = filtersets.DeviceBayFilterSet
     table = tables.DeviceBayTable
 
 
@@ -2193,7 +2193,7 @@ class DeviceBayBulkDeleteView(generic.BulkDeleteView):
 
 class InventoryItemListView(generic.ObjectListView):
     queryset = InventoryItem.objects.all()
-    filterset = filters.InventoryItemFilterSet
+    filterset = filtersets.InventoryItemFilterSet
     filterset_form = forms.InventoryItemFilterForm
     table = tables.InventoryItemTable
     action_buttons = ('import', 'export')
@@ -2227,7 +2227,7 @@ class InventoryItemBulkImportView(generic.BulkImportView):
 
 class InventoryItemBulkEditView(generic.BulkEditView):
     queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
-    filterset = filters.InventoryItemFilterSet
+    filterset = filtersets.InventoryItemFilterSet
     table = tables.InventoryItemTable
     form = forms.InventoryItemBulkEditForm
 
@@ -2252,7 +2252,7 @@ class DeviceBulkAddConsolePortView(generic.BulkComponentCreateView):
     form = forms.ConsolePortBulkCreateForm
     queryset = ConsolePort.objects.all()
     model_form = forms.ConsolePortForm
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
 
@@ -2263,7 +2263,7 @@ class DeviceBulkAddConsoleServerPortView(generic.BulkComponentCreateView):
     form = forms.ConsoleServerPortBulkCreateForm
     queryset = ConsoleServerPort.objects.all()
     model_form = forms.ConsoleServerPortForm
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
 
@@ -2274,7 +2274,7 @@ class DeviceBulkAddPowerPortView(generic.BulkComponentCreateView):
     form = forms.PowerPortBulkCreateForm
     queryset = PowerPort.objects.all()
     model_form = forms.PowerPortForm
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
 
@@ -2285,7 +2285,7 @@ class DeviceBulkAddPowerOutletView(generic.BulkComponentCreateView):
     form = forms.PowerOutletBulkCreateForm
     queryset = PowerOutlet.objects.all()
     model_form = forms.PowerOutletForm
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
 
@@ -2296,7 +2296,7 @@ class DeviceBulkAddInterfaceView(generic.BulkComponentCreateView):
     form = forms.InterfaceBulkCreateForm
     queryset = Interface.objects.all()
     model_form = forms.InterfaceForm
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
 
@@ -2307,7 +2307,7 @@ class DeviceBulkAddInterfaceView(generic.BulkComponentCreateView):
 #     form = forms.FrontPortBulkCreateForm
 #     queryset = FrontPort.objects.all()
 #     model_form = forms.FrontPortForm
-#     filterset = filters.DeviceFilterSet
+#     filterset = filtersets.DeviceFilterSet
 #     table = tables.DeviceTable
 #     default_return_url = 'dcim:device_list'
 
@@ -2318,7 +2318,7 @@ class DeviceBulkAddRearPortView(generic.BulkComponentCreateView):
     form = forms.RearPortBulkCreateForm
     queryset = RearPort.objects.all()
     model_form = forms.RearPortForm
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
 
@@ -2329,7 +2329,7 @@ class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView):
     form = forms.DeviceBayBulkCreateForm
     queryset = DeviceBay.objects.all()
     model_form = forms.DeviceBayForm
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
 
@@ -2340,7 +2340,7 @@ class DeviceBulkAddInventoryItemView(generic.BulkComponentCreateView):
     form = forms.InventoryItemBulkCreateForm
     queryset = InventoryItem.objects.all()
     model_form = forms.InventoryItemForm
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
 
@@ -2351,7 +2351,7 @@ class DeviceBulkAddInventoryItemView(generic.BulkComponentCreateView):
 
 class CableListView(generic.ObjectListView):
     queryset = Cable.objects.all()
-    filterset = filters.CableFilterSet
+    filterset = filtersets.CableFilterSet
     filterset_form = forms.CableFilterForm
     table = tables.CableTable
     action_buttons = ('import', 'export')
@@ -2484,14 +2484,14 @@ class CableBulkImportView(generic.BulkImportView):
 
 class CableBulkEditView(generic.BulkEditView):
     queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
-    filterset = filters.CableFilterSet
+    filterset = filtersets.CableFilterSet
     table = tables.CableTable
     form = forms.CableBulkEditForm
 
 
 class CableBulkDeleteView(generic.BulkDeleteView):
     queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
-    filterset = filters.CableFilterSet
+    filterset = filtersets.CableFilterSet
     table = tables.CableTable
 
 
@@ -2501,7 +2501,7 @@ class CableBulkDeleteView(generic.BulkDeleteView):
 
 class ConsoleConnectionsListView(generic.ObjectListView):
     queryset = ConsolePort.objects.filter(_path__isnull=False).order_by('device')
-    filterset = filters.ConsoleConnectionFilterSet
+    filterset = filtersets.ConsoleConnectionFilterSet
     filterset_form = forms.ConsoleConnectionFilterForm
     table = tables.ConsoleConnectionTable
     template_name = 'dcim/connections_list.html'
@@ -2531,7 +2531,7 @@ class ConsoleConnectionsListView(generic.ObjectListView):
 
 class PowerConnectionsListView(generic.ObjectListView):
     queryset = PowerPort.objects.filter(_path__isnull=False).order_by('device')
-    filterset = filters.PowerConnectionFilterSet
+    filterset = filtersets.PowerConnectionFilterSet
     filterset_form = forms.PowerConnectionFilterForm
     table = tables.PowerConnectionTable
     template_name = 'dcim/connections_list.html'
@@ -2565,7 +2565,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
         _path__isnull=False,
         pk__lt=F('_path__destination_id')
     ).order_by('device')
-    filterset = filters.InterfaceConnectionFilterSet
+    filterset = filtersets.InterfaceConnectionFilterSet
     filterset_form = forms.InterfaceConnectionFilterForm
     table = tables.InterfaceConnectionTable
     template_name = 'dcim/connections_list.html'
@@ -2604,7 +2604,7 @@ class VirtualChassisListView(generic.ObjectListView):
         member_count=count_related(Device, 'virtual_chassis')
     )
     table = tables.VirtualChassisTable
-    filterset = filters.VirtualChassisFilterSet
+    filterset = filtersets.VirtualChassisFilterSet
     filterset_form = forms.VirtualChassisFilterForm
 
 
@@ -2812,14 +2812,14 @@ class VirtualChassisBulkImportView(generic.BulkImportView):
 
 class VirtualChassisBulkEditView(generic.BulkEditView):
     queryset = VirtualChassis.objects.all()
-    filterset = filters.VirtualChassisFilterSet
+    filterset = filtersets.VirtualChassisFilterSet
     table = tables.VirtualChassisTable
     form = forms.VirtualChassisBulkEditForm
 
 
 class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
     queryset = VirtualChassis.objects.all()
-    filterset = filters.VirtualChassisFilterSet
+    filterset = filtersets.VirtualChassisFilterSet
     table = tables.VirtualChassisTable
 
 
@@ -2833,7 +2833,7 @@ class PowerPanelListView(generic.ObjectListView):
     ).annotate(
         powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
-    filterset = filters.PowerPanelFilterSet
+    filterset = filtersets.PowerPanelFilterSet
     filterset_form = forms.PowerPanelFilterForm
     table = tables.PowerPanelTable
 
@@ -2873,7 +2873,7 @@ class PowerPanelBulkImportView(generic.BulkImportView):
 
 class PowerPanelBulkEditView(generic.BulkEditView):
     queryset = PowerPanel.objects.prefetch_related('site', 'location')
-    filterset = filters.PowerPanelFilterSet
+    filterset = filtersets.PowerPanelFilterSet
     table = tables.PowerPanelTable
     form = forms.PowerPanelBulkEditForm
 
@@ -2884,7 +2884,7 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
     ).annotate(
         powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
-    filterset = filters.PowerPanelFilterSet
+    filterset = filtersets.PowerPanelFilterSet
     table = tables.PowerPanelTable
 
 
@@ -2894,7 +2894,7 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
 
 class PowerFeedListView(generic.ObjectListView):
     queryset = PowerFeed.objects.all()
-    filterset = filters.PowerFeedFilterSet
+    filterset = filtersets.PowerFeedFilterSet
     filterset_form = forms.PowerFeedFilterForm
     table = tables.PowerFeedTable
 
@@ -2920,7 +2920,7 @@ class PowerFeedBulkImportView(generic.BulkImportView):
 
 class PowerFeedBulkEditView(generic.BulkEditView):
     queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
-    filterset = filters.PowerFeedFilterSet
+    filterset = filtersets.PowerFeedFilterSet
     table = tables.PowerFeedTable
     form = forms.PowerFeedBulkEditForm
 
@@ -2931,5 +2931,5 @@ class PowerFeedBulkDisconnectView(BulkDisconnectView):
 
 class PowerFeedBulkDeleteView(generic.BulkDeleteView):
     queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
-    filterset = filters.PowerFeedFilterSet
+    filterset = filtersets.PowerFeedFilterSet
     table = tables.PowerFeedTable

+ 12 - 12
netbox/extras/api/views.py

@@ -9,7 +9,7 @@ from rest_framework.routers import APIRootView
 from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
 from rq import Worker
 
-from extras import filters
+from extras import filtersets
 from extras.choices import JobResultStatusChoices
 from extras.models import *
 from extras.models import CustomField
@@ -61,7 +61,7 @@ class WebhookViewSet(ModelViewSet):
     metadata_class = ContentTypeMetadata
     queryset = Webhook.objects.all()
     serializer_class = serializers.WebhookSerializer
-    filterset_class = filters.WebhookFilterSet
+    filterset_class = filtersets.WebhookFilterSet
 
 
 #
@@ -72,7 +72,7 @@ class CustomFieldViewSet(ModelViewSet):
     metadata_class = ContentTypeMetadata
     queryset = CustomField.objects.all()
     serializer_class = serializers.CustomFieldSerializer
-    filterset_class = filters.CustomFieldFilterSet
+    filterset_class = filtersets.CustomFieldFilterSet
 
 
 class CustomFieldModelViewSet(ModelViewSet):
@@ -101,7 +101,7 @@ class CustomLinkViewSet(ModelViewSet):
     metadata_class = ContentTypeMetadata
     queryset = CustomLink.objects.all()
     serializer_class = serializers.CustomLinkSerializer
-    filterset_class = filters.CustomLinkFilterSet
+    filterset_class = filtersets.CustomLinkFilterSet
 
 
 #
@@ -112,7 +112,7 @@ class ExportTemplateViewSet(ModelViewSet):
     metadata_class = ContentTypeMetadata
     queryset = ExportTemplate.objects.all()
     serializer_class = serializers.ExportTemplateSerializer
-    filterset_class = filters.ExportTemplateFilterSet
+    filterset_class = filtersets.ExportTemplateFilterSet
 
 
 #
@@ -124,7 +124,7 @@ class TagViewSet(ModelViewSet):
         tagged_items=count_related(TaggedItem, 'tag')
     )
     serializer_class = serializers.TagSerializer
-    filterset_class = filters.TagFilterSet
+    filterset_class = filtersets.TagFilterSet
 
 
 #
@@ -135,7 +135,7 @@ class ImageAttachmentViewSet(ModelViewSet):
     metadata_class = ContentTypeMetadata
     queryset = ImageAttachment.objects.all()
     serializer_class = serializers.ImageAttachmentSerializer
-    filterset_class = filters.ImageAttachmentFilterSet
+    filterset_class = filtersets.ImageAttachmentFilterSet
 
 
 #
@@ -146,7 +146,7 @@ class JournalEntryViewSet(ModelViewSet):
     metadata_class = ContentTypeMetadata
     queryset = JournalEntry.objects.all()
     serializer_class = serializers.JournalEntrySerializer
-    filterset_class = filters.JournalEntryFilterSet
+    filterset_class = filtersets.JournalEntryFilterSet
 
 
 #
@@ -158,7 +158,7 @@ class ConfigContextViewSet(ModelViewSet):
         'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants',
     )
     serializer_class = serializers.ConfigContextSerializer
-    filterset_class = filters.ConfigContextFilterSet
+    filterset_class = filtersets.ConfigContextFilterSet
 
 
 #
@@ -358,7 +358,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
     metadata_class = ContentTypeMetadata
     queryset = ObjectChange.objects.prefetch_related('user')
     serializer_class = serializers.ObjectChangeSerializer
-    filterset_class = filters.ObjectChangeFilterSet
+    filterset_class = filtersets.ObjectChangeFilterSet
 
 
 #
@@ -371,7 +371,7 @@ class JobResultViewSet(ReadOnlyModelViewSet):
     """
     queryset = JobResult.objects.prefetch_related('user')
     serializer_class = serializers.JobResultSerializer
-    filterset_class = filters.JobResultFilterSet
+    filterset_class = filtersets.JobResultFilterSet
 
 
 #
@@ -384,4 +384,4 @@ class ContentTypeViewSet(ReadOnlyModelViewSet):
     """
     queryset = ContentType.objects.order_by('app_label', 'model')
     serializer_class = serializers.ContentTypeSerializer
-    filterset_class = filters.ContentTypeFilterSet
+    filterset_class = filtersets.ContentTypeFilterSet

+ 10 - 358
netbox/extras/filters.py

@@ -1,31 +1,12 @@
 import django_filters
-from django.contrib.auth.models import User
-from django.contrib.contenttypes.models import ContentType
-from django.db.models import Q
 from django.forms import DateField, IntegerField, NullBooleanField
 
-from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
-from tenancy.models import Tenant, TenantGroup
-from utilities.filters import BaseFilterSet, ContentTypeFilter
-from virtualization.models import Cluster, ClusterGroup
+from .models import Tag
 from .choices import *
-from .models import *
-
 
 __all__ = (
-    'ConfigContextFilterSet',
-    'ContentTypeFilterSet',
-    'CreatedUpdatedFilterSet',
     'CustomFieldFilter',
-    'CustomLinkFilterSet',
-    'CustomFieldModelFilterSet',
-    'ExportTemplateFilterSet',
-    'ImageAttachmentFilterSet',
-    'JournalEntryFilterSet',
-    'LocalConfigContextFilterSet',
-    'ObjectChangeFilterSet',
-    'TagFilterSet',
-    'WebhookFilterSet',
+    'TagFilter',
 )
 
 EXACT_FILTER_TYPES = (
@@ -36,41 +17,6 @@ EXACT_FILTER_TYPES = (
 )
 
 
-class CreatedUpdatedFilterSet(django_filters.FilterSet):
-    created = django_filters.DateFilter()
-    created__gte = django_filters.DateFilter(
-        field_name='created',
-        lookup_expr='gte'
-    )
-    created__lte = django_filters.DateFilter(
-        field_name='created',
-        lookup_expr='lte'
-    )
-    last_updated = django_filters.DateTimeFilter()
-    last_updated__gte = django_filters.DateTimeFilter(
-        field_name='last_updated',
-        lookup_expr='gte'
-    )
-    last_updated__lte = django_filters.DateTimeFilter(
-        field_name='last_updated',
-        lookup_expr='lte'
-    )
-
-
-class WebhookFilterSet(BaseFilterSet):
-    content_types = ContentTypeFilter()
-    http_method = django_filters.MultipleChoiceFilter(
-        choices=WebhookHttpMethodChoices
-    )
-
-    class Meta:
-        model = Webhook
-        fields = [
-            'id', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled',
-            'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
-        ]
-
-
 class CustomFieldFilter(django_filters.Filter):
     """
     Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
@@ -94,310 +40,16 @@ class CustomFieldFilter(django_filters.Filter):
                 self.lookup_expr = 'icontains'
 
 
-class CustomFieldModelFilterSet(django_filters.FilterSet):
+class TagFilter(django_filters.ModelMultipleChoiceFilter):
     """
-    Dynamically add a Filter for each CustomField applicable to the parent model.
+    Match on one or more assigned tags. If multiple tags are specified (e.g. ?tag=foo&tag=bar), the queryset is filtered
+    to objects matching all tags.
     """
     def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        custom_fields = CustomField.objects.filter(
-            content_types=ContentType.objects.get_for_model(self._meta.model)
-        ).exclude(
-            filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
-        )
-        for cf in custom_fields:
-            self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf)
-
-
-class CustomFieldFilterSet(django_filters.FilterSet):
-    content_types = ContentTypeFilter()
-
-    class Meta:
-        model = CustomField
-        fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
-
-
-class CustomLinkFilterSet(BaseFilterSet):
-
-    class Meta:
-        model = CustomLink
-        fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window']
-
-
-class ExportTemplateFilterSet(BaseFilterSet):
-
-    class Meta:
-        model = ExportTemplate
-        fields = ['id', 'content_type', 'name']
-
-
-class ImageAttachmentFilterSet(BaseFilterSet):
-    content_type = ContentTypeFilter()
-
-    class Meta:
-        model = ImageAttachment
-        fields = ['id', 'content_type_id', 'object_id', 'name']
-
-
-class JournalEntryFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
-    q = django_filters.CharFilter(
-        method='search',
-        label='Search',
-    )
-    created = django_filters.DateTimeFromToRangeFilter()
-    assigned_object_type = ContentTypeFilter()
-    created_by_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=User.objects.all(),
-        label='User (ID)',
-    )
-    created_by = django_filters.ModelMultipleChoiceFilter(
-        field_name='created_by__username',
-        queryset=User.objects.all(),
-        to_field_name='username',
-        label='User (name)',
-    )
-    kind = django_filters.MultipleChoiceFilter(
-        choices=JournalEntryKindChoices
-    )
-
-    class Meta:
-        model = JournalEntry
-        fields = ['id', 'assigned_object_type_id', 'assigned_object_id', 'created', 'kind']
-
-    def search(self, queryset, name, value):
-        if not value.strip():
-            return queryset
-        return queryset.filter(comments__icontains=value)
 
+        kwargs.setdefault('field_name', 'tags__slug')
+        kwargs.setdefault('to_field_name', 'slug')
+        kwargs.setdefault('conjoined', True)
+        kwargs.setdefault('queryset', Tag.objects.all())
 
-class TagFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
-    q = django_filters.CharFilter(
-        method='search',
-        label='Search',
-    )
-
-    class Meta:
-        model = Tag
-        fields = ['id', 'name', 'slug', 'color']
-
-    def search(self, queryset, name, value):
-        if not value.strip():
-            return queryset
-        return queryset.filter(
-            Q(name__icontains=value) |
-            Q(slug__icontains=value)
-        )
-
-
-class ConfigContextFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
-    q = django_filters.CharFilter(
-        method='search',
-        label='Search',
-    )
-    region_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='regions',
-        queryset=Region.objects.all(),
-        label='Region',
-    )
-    region = django_filters.ModelMultipleChoiceFilter(
-        field_name='regions__slug',
-        queryset=Region.objects.all(),
-        to_field_name='slug',
-        label='Region (slug)',
-    )
-    site_group = django_filters.ModelMultipleChoiceFilter(
-        field_name='site_groups__slug',
-        queryset=SiteGroup.objects.all(),
-        to_field_name='slug',
-        label='Site group (slug)',
-    )
-    site_group_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='site_groups',
-        queryset=SiteGroup.objects.all(),
-        label='Site group',
-    )
-    site_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='sites',
-        queryset=Site.objects.all(),
-        label='Site',
-    )
-    site = django_filters.ModelMultipleChoiceFilter(
-        field_name='sites__slug',
-        queryset=Site.objects.all(),
-        to_field_name='slug',
-        label='Site (slug)',
-    )
-    device_type_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='device_types',
-        queryset=DeviceType.objects.all(),
-        label='Device type',
-    )
-    role_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='roles',
-        queryset=DeviceRole.objects.all(),
-        label='Role',
-    )
-    role = django_filters.ModelMultipleChoiceFilter(
-        field_name='roles__slug',
-        queryset=DeviceRole.objects.all(),
-        to_field_name='slug',
-        label='Role (slug)',
-    )
-    platform_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='platforms',
-        queryset=Platform.objects.all(),
-        label='Platform',
-    )
-    platform = django_filters.ModelMultipleChoiceFilter(
-        field_name='platforms__slug',
-        queryset=Platform.objects.all(),
-        to_field_name='slug',
-        label='Platform (slug)',
-    )
-    cluster_group_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='cluster_groups',
-        queryset=ClusterGroup.objects.all(),
-        label='Cluster group',
-    )
-    cluster_group = django_filters.ModelMultipleChoiceFilter(
-        field_name='cluster_groups__slug',
-        queryset=ClusterGroup.objects.all(),
-        to_field_name='slug',
-        label='Cluster group (slug)',
-    )
-    cluster_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='clusters',
-        queryset=Cluster.objects.all(),
-        label='Cluster',
-    )
-    tenant_group_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenant_groups',
-        queryset=TenantGroup.objects.all(),
-        label='Tenant group',
-    )
-    tenant_group = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenant_groups__slug',
-        queryset=TenantGroup.objects.all(),
-        to_field_name='slug',
-        label='Tenant group (slug)',
-    )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenants',
-        queryset=Tenant.objects.all(),
-        label='Tenant',
-    )
-    tenant = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenants__slug',
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        label='Tenant (slug)',
-    )
-    tag = django_filters.ModelMultipleChoiceFilter(
-        field_name='tags__slug',
-        queryset=Tag.objects.all(),
-        to_field_name='slug',
-        label='Tag (slug)',
-    )
-
-    class Meta:
-        model = ConfigContext
-        fields = ['id', 'name', 'is_active']
-
-    def search(self, queryset, name, value):
-        if not value.strip():
-            return queryset
-        return queryset.filter(
-            Q(name__icontains=value) |
-            Q(description__icontains=value) |
-            Q(data__icontains=value)
-        )
-
-
-#
-# Filter for Local Config Context Data
-#
-
-class LocalConfigContextFilterSet(django_filters.FilterSet):
-    local_context_data = django_filters.BooleanFilter(
-        method='_local_context_data',
-        label='Has local config context data',
-    )
-
-    def _local_context_data(self, queryset, name, value):
-        return queryset.exclude(local_context_data__isnull=value)
-
-
-class ObjectChangeFilterSet(BaseFilterSet):
-    q = django_filters.CharFilter(
-        method='search',
-        label='Search',
-    )
-    time = django_filters.DateTimeFromToRangeFilter()
-    changed_object_type = ContentTypeFilter()
-    user_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=User.objects.all(),
-        label='User (ID)',
-    )
-    user = django_filters.ModelMultipleChoiceFilter(
-        field_name='user__username',
-        queryset=User.objects.all(),
-        to_field_name='username',
-        label='User name',
-    )
-
-    class Meta:
-        model = ObjectChange
-        fields = [
-            'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id',
-            'object_repr',
-        ]
-
-    def search(self, queryset, name, value):
-        if not value.strip():
-            return queryset
-        return queryset.filter(
-            Q(user_name__icontains=value) |
-            Q(object_repr__icontains=value)
-        )
-
-
-#
-# Job Results
-#
-
-class JobResultFilterSet(BaseFilterSet):
-    q = django_filters.CharFilter(
-        method='search',
-        label='Search',
-    )
-    created = django_filters.DateTimeFilter()
-    completed = django_filters.DateTimeFilter()
-    status = django_filters.MultipleChoiceFilter(
-        choices=JobResultStatusChoices,
-        null_value=None
-    )
-
-    class Meta:
-        model = JobResult
-        fields = [
-            'id', 'created', 'completed', 'status', 'user', 'obj_type', 'name'
-        ]
-
-    def search(self, queryset, name, value):
-        if not value.strip():
-            return queryset
-        return queryset.filter(
-            Q(user__username__icontains=value)
-        )
-
-
-#
-# ContentTypes
-#
-
-class ContentTypeFilterSet(django_filters.FilterSet):
-
-    class Meta:
-        model = ContentType
-        fields = ['id', 'app_label', 'model']
+        super().__init__(*args, **kwargs)

+ 340 - 0
netbox/extras/filtersets.py

@@ -0,0 +1,340 @@
+import django_filters
+from django.contrib.auth.models import User
+from django.contrib.contenttypes.models import ContentType
+from django.db.models import Q
+
+from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
+from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet
+from tenancy.models import Tenant, TenantGroup
+from utilities.filters import ContentTypeFilter
+from virtualization.models import Cluster, ClusterGroup
+from .choices import *
+from .models import *
+
+
+__all__ = (
+    'ConfigContextFilterSet',
+    'ContentTypeFilterSet',
+    'CustomLinkFilterSet',
+    'ExportTemplateFilterSet',
+    'ImageAttachmentFilterSet',
+    'JournalEntryFilterSet',
+    'LocalConfigContextFilterSet',
+    'ObjectChangeFilterSet',
+    'TagFilterSet',
+    'WebhookFilterSet',
+)
+
+EXACT_FILTER_TYPES = (
+    CustomFieldTypeChoices.TYPE_BOOLEAN,
+    CustomFieldTypeChoices.TYPE_DATE,
+    CustomFieldTypeChoices.TYPE_INTEGER,
+    CustomFieldTypeChoices.TYPE_SELECT,
+)
+
+
+class WebhookFilterSet(BaseFilterSet):
+    content_types = ContentTypeFilter()
+    http_method = django_filters.MultipleChoiceFilter(
+        choices=WebhookHttpMethodChoices
+    )
+
+    class Meta:
+        model = Webhook
+        fields = [
+            'id', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled',
+            'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
+        ]
+
+
+class CustomFieldFilterSet(django_filters.FilterSet):
+    content_types = ContentTypeFilter()
+
+    class Meta:
+        model = CustomField
+        fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
+
+
+class CustomLinkFilterSet(BaseFilterSet):
+
+    class Meta:
+        model = CustomLink
+        fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window']
+
+
+class ExportTemplateFilterSet(BaseFilterSet):
+
+    class Meta:
+        model = ExportTemplate
+        fields = ['id', 'content_type', 'name']
+
+
+class ImageAttachmentFilterSet(BaseFilterSet):
+    content_type = ContentTypeFilter()
+
+    class Meta:
+        model = ImageAttachment
+        fields = ['id', 'content_type_id', 'object_id', 'name']
+
+
+class JournalEntryFilterSet(ChangeLoggedModelFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    created = django_filters.DateTimeFromToRangeFilter()
+    assigned_object_type = ContentTypeFilter()
+    created_by_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=User.objects.all(),
+        label='User (ID)',
+    )
+    created_by = django_filters.ModelMultipleChoiceFilter(
+        field_name='created_by__username',
+        queryset=User.objects.all(),
+        to_field_name='username',
+        label='User (name)',
+    )
+    kind = django_filters.MultipleChoiceFilter(
+        choices=JournalEntryKindChoices
+    )
+
+    class Meta:
+        model = JournalEntry
+        fields = ['id', 'assigned_object_type_id', 'assigned_object_id', 'created', 'kind']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(comments__icontains=value)
+
+
+class TagFilterSet(ChangeLoggedModelFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+
+    class Meta:
+        model = Tag
+        fields = ['id', 'name', 'slug', 'color']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(slug__icontains=value)
+        )
+
+
+class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    region_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='regions',
+        queryset=Region.objects.all(),
+        label='Region',
+    )
+    region = django_filters.ModelMultipleChoiceFilter(
+        field_name='regions__slug',
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        label='Region (slug)',
+    )
+    site_group = django_filters.ModelMultipleChoiceFilter(
+        field_name='site_groups__slug',
+        queryset=SiteGroup.objects.all(),
+        to_field_name='slug',
+        label='Site group (slug)',
+    )
+    site_group_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='site_groups',
+        queryset=SiteGroup.objects.all(),
+        label='Site group',
+    )
+    site_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='sites',
+        queryset=Site.objects.all(),
+        label='Site',
+    )
+    site = django_filters.ModelMultipleChoiceFilter(
+        field_name='sites__slug',
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        label='Site (slug)',
+    )
+    device_type_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='device_types',
+        queryset=DeviceType.objects.all(),
+        label='Device type',
+    )
+    role_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='roles',
+        queryset=DeviceRole.objects.all(),
+        label='Role',
+    )
+    role = django_filters.ModelMultipleChoiceFilter(
+        field_name='roles__slug',
+        queryset=DeviceRole.objects.all(),
+        to_field_name='slug',
+        label='Role (slug)',
+    )
+    platform_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='platforms',
+        queryset=Platform.objects.all(),
+        label='Platform',
+    )
+    platform = django_filters.ModelMultipleChoiceFilter(
+        field_name='platforms__slug',
+        queryset=Platform.objects.all(),
+        to_field_name='slug',
+        label='Platform (slug)',
+    )
+    cluster_group_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='cluster_groups',
+        queryset=ClusterGroup.objects.all(),
+        label='Cluster group',
+    )
+    cluster_group = django_filters.ModelMultipleChoiceFilter(
+        field_name='cluster_groups__slug',
+        queryset=ClusterGroup.objects.all(),
+        to_field_name='slug',
+        label='Cluster group (slug)',
+    )
+    cluster_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='clusters',
+        queryset=Cluster.objects.all(),
+        label='Cluster',
+    )
+    tenant_group_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='tenant_groups',
+        queryset=TenantGroup.objects.all(),
+        label='Tenant group',
+    )
+    tenant_group = django_filters.ModelMultipleChoiceFilter(
+        field_name='tenant_groups__slug',
+        queryset=TenantGroup.objects.all(),
+        to_field_name='slug',
+        label='Tenant group (slug)',
+    )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='tenants',
+        queryset=Tenant.objects.all(),
+        label='Tenant',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        field_name='tenants__slug',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
+    tag = django_filters.ModelMultipleChoiceFilter(
+        field_name='tags__slug',
+        queryset=Tag.objects.all(),
+        to_field_name='slug',
+        label='Tag (slug)',
+    )
+
+    class Meta:
+        model = ConfigContext
+        fields = ['id', 'name', 'is_active']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value) |
+            Q(data__icontains=value)
+        )
+
+
+#
+# Filter for Local Config Context Data
+#
+
+class LocalConfigContextFilterSet(django_filters.FilterSet):
+    local_context_data = django_filters.BooleanFilter(
+        method='_local_context_data',
+        label='Has local config context data',
+    )
+
+    def _local_context_data(self, queryset, name, value):
+        return queryset.exclude(local_context_data__isnull=value)
+
+
+class ObjectChangeFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    time = django_filters.DateTimeFromToRangeFilter()
+    changed_object_type = ContentTypeFilter()
+    user_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=User.objects.all(),
+        label='User (ID)',
+    )
+    user = django_filters.ModelMultipleChoiceFilter(
+        field_name='user__username',
+        queryset=User.objects.all(),
+        to_field_name='username',
+        label='User name',
+    )
+
+    class Meta:
+        model = ObjectChange
+        fields = [
+            'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id',
+            'object_repr',
+        ]
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(user_name__icontains=value) |
+            Q(object_repr__icontains=value)
+        )
+
+
+#
+# Job Results
+#
+
+class JobResultFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    created = django_filters.DateTimeFilter()
+    completed = django_filters.DateTimeFilter()
+    status = django_filters.MultipleChoiceFilter(
+        choices=JobResultStatusChoices,
+        null_value=None
+    )
+
+    class Meta:
+        model = JobResult
+        fields = [
+            'id', 'created', 'completed', 'status', 'user', 'obj_type', 'name'
+        ]
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(user__username__icontains=value)
+        )
+
+
+#
+# ContentTypes
+#
+
+class ContentTypeFilterSet(django_filters.FilterSet):
+
+    class Meta:
+        model = ContentType
+        fields = ['id', 'app_label', 'model']

+ 1 - 1
netbox/extras/tests/test_customfields.py

@@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError
 from django.urls import reverse
 from rest_framework import status
 
-from dcim.filters import SiteFilterSet
+from dcim.filtersets import SiteFilterSet
 from dcim.forms import SiteCSVForm
 from dcim.models import Site, Rack
 from extras.choices import *

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

@@ -6,7 +6,7 @@ from django.test import TestCase
 
 from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
 from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
-from extras.filters import *
+from extras.filtersets import *
 from extras.models import *
 from ipam.models import IPAddress
 from tenancy.models import Tenant, TenantGroup

+ 8 - 8
netbox/extras/views.py

@@ -13,7 +13,7 @@ from utilities.forms import ConfirmationForm
 from utilities.tables import paginate_table
 from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
 from utilities.views import ContentTypePermissionRequiredMixin
-from . import filters, forms, tables
+from . import filtersets, forms, tables
 from .choices import JobResultStatusChoices
 from .models import ConfigContext, ImageAttachment, JournalEntry, ObjectChange, JobResult, Tag, TaggedItem
 from .reports import get_report, get_reports, run_report
@@ -28,7 +28,7 @@ class TagListView(generic.ObjectListView):
     queryset = Tag.objects.annotate(
         items=count_related(TaggedItem, 'tag')
     )
-    filterset = filters.TagFilterSet
+    filterset = filtersets.TagFilterSet
     filterset_form = forms.TagFilterForm
     table = tables.TagTable
 
@@ -94,7 +94,7 @@ class TagBulkDeleteView(generic.BulkDeleteView):
 
 class ConfigContextListView(generic.ObjectListView):
     queryset = ConfigContext.objects.all()
-    filterset = filters.ConfigContextFilterSet
+    filterset = filtersets.ConfigContextFilterSet
     filterset_form = forms.ConfigContextFilterForm
     table = tables.ConfigContextTable
     action_buttons = ('add',)
@@ -127,7 +127,7 @@ class ConfigContextEditView(generic.ObjectEditView):
 
 class ConfigContextBulkEditView(generic.BulkEditView):
     queryset = ConfigContext.objects.all()
-    filterset = filters.ConfigContextFilterSet
+    filterset = filtersets.ConfigContextFilterSet
     table = tables.ConfigContextTable
     form = forms.ConfigContextBulkEditForm
 
@@ -173,7 +173,7 @@ class ObjectConfigContextView(generic.ObjectView):
 
 class ObjectChangeListView(generic.ObjectListView):
     queryset = ObjectChange.objects.all()
-    filterset = filters.ObjectChangeFilterSet
+    filterset = filtersets.ObjectChangeFilterSet
     filterset_form = forms.ObjectChangeFilterForm
     table = tables.ObjectChangeTable
     template_name = 'extras/objectchange_list.html'
@@ -300,7 +300,7 @@ class ImageAttachmentDeleteView(generic.ObjectDeleteView):
 
 class JournalEntryListView(generic.ObjectListView):
     queryset = JournalEntry.objects.all()
-    filterset = filters.JournalEntryFilterSet
+    filterset = filtersets.JournalEntryFilterSet
     filterset_form = forms.JournalEntryFilterForm
     table = tables.JournalEntryTable
     action_buttons = ('export',)
@@ -338,14 +338,14 @@ class JournalEntryDeleteView(generic.ObjectDeleteView):
 
 class JournalEntryBulkEditView(generic.BulkEditView):
     queryset = JournalEntry.objects.prefetch_related('created_by')
-    filterset = filters.JournalEntryFilterSet
+    filterset = filtersets.JournalEntryFilterSet
     table = tables.JournalEntryTable
     form = forms.JournalEntryBulkEditForm
 
 
 class JournalEntryBulkDeleteView(generic.BulkDeleteView):
     queryset = JournalEntry.objects.prefetch_related('created_by')
-    filterset = filters.JournalEntryFilterSet
+    filterset = filtersets.JournalEntryFilterSet
     table = tables.JournalEntryTable
 
 

+ 11 - 11
netbox/ipam/api/views.py

@@ -10,7 +10,7 @@ from rest_framework.response import Response
 from rest_framework.routers import APIRootView
 
 from extras.api.views import CustomFieldModelViewSet
-from ipam import filters
+from ipam import filtersets
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
 from netbox.api.views import ModelViewSet
 from utilities.constants import ADVISORY_LOCK_KEYS
@@ -38,7 +38,7 @@ class VRFViewSet(CustomFieldModelViewSet):
         prefix_count=count_related(Prefix, 'vrf')
     )
     serializer_class = serializers.VRFSerializer
-    filterset_class = filters.VRFFilterSet
+    filterset_class = filtersets.VRFFilterSet
 
 
 #
@@ -48,7 +48,7 @@ class VRFViewSet(CustomFieldModelViewSet):
 class RouteTargetViewSet(CustomFieldModelViewSet):
     queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags')
     serializer_class = serializers.RouteTargetSerializer
-    filterset_class = filters.RouteTargetFilterSet
+    filterset_class = filtersets.RouteTargetFilterSet
 
 
 #
@@ -60,7 +60,7 @@ class RIRViewSet(CustomFieldModelViewSet):
         aggregate_count=count_related(Aggregate, 'rir')
     )
     serializer_class = serializers.RIRSerializer
-    filterset_class = filters.RIRFilterSet
+    filterset_class = filtersets.RIRFilterSet
 
 
 #
@@ -70,7 +70,7 @@ class RIRViewSet(CustomFieldModelViewSet):
 class AggregateViewSet(CustomFieldModelViewSet):
     queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags')
     serializer_class = serializers.AggregateSerializer
-    filterset_class = filters.AggregateFilterSet
+    filterset_class = filtersets.AggregateFilterSet
 
 
 #
@@ -83,7 +83,7 @@ class RoleViewSet(CustomFieldModelViewSet):
         vlan_count=count_related(VLAN, 'role')
     )
     serializer_class = serializers.RoleSerializer
-    filterset_class = filters.RoleFilterSet
+    filterset_class = filtersets.RoleFilterSet
 
 
 #
@@ -95,7 +95,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
         'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags'
     )
     serializer_class = serializers.PrefixSerializer
-    filterset_class = filters.PrefixFilterSet
+    filterset_class = filtersets.PrefixFilterSet
 
     def get_serializer_class(self):
         if self.action == "available_prefixes" and self.request.method == "POST":
@@ -275,7 +275,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
         'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
     )
     serializer_class = serializers.IPAddressSerializer
-    filterset_class = filters.IPAddressFilterSet
+    filterset_class = filtersets.IPAddressFilterSet
 
 
 #
@@ -287,7 +287,7 @@ class VLANGroupViewSet(CustomFieldModelViewSet):
         vlan_count=count_related(VLAN, 'group')
     )
     serializer_class = serializers.VLANGroupSerializer
-    filterset_class = filters.VLANGroupFilterSet
+    filterset_class = filtersets.VLANGroupFilterSet
 
 
 #
@@ -301,7 +301,7 @@ class VLANViewSet(CustomFieldModelViewSet):
         prefix_count=count_related(Prefix, 'vlan')
     )
     serializer_class = serializers.VLANSerializer
-    filterset_class = filters.VLANFilterSet
+    filterset_class = filtersets.VLANFilterSet
 
 
 #
@@ -313,4 +313,4 @@ class ServiceViewSet(ModelViewSet):
         'device', 'virtual_machine', 'tags', 'ipaddresses'
     )
     serializer_class = serializers.ServiceSerializer
-    filterset_class = filters.ServiceFilterSet
+    filterset_class = filtersets.ServiceFilterSet

+ 14 - 14
netbox/ipam/filters.py → netbox/ipam/filtersets.py

@@ -6,11 +6,11 @@ from django.db.models import Q
 from netaddr.core import AddrFormatError
 
 from dcim.models import Device, Interface, Region, Site, SiteGroup
-from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
-from tenancy.filters import TenancyFilterSet
+from extras.filters import TagFilter
+from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
+from tenancy.filtersets import TenancyFilterSet
 from utilities.filters import (
-    BaseFilterSet, ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet,
-    NumericArrayFilter, TagFilter, TreeNodeMultipleChoiceFilter,
+    ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
 )
 from virtualization.models import VirtualMachine, VMInterface
 from .choices import *
@@ -31,7 +31,7 @@ __all__ = (
 )
 
 
-class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -74,7 +74,7 @@ class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, C
         fields = ['id', 'name', 'rd', 'enforce_unique']
 
 
-class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -116,14 +116,14 @@ class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilt
         fields = ['id', 'name']
 
 
-class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class RIRFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = RIR
         fields = ['id', 'name', 'slug', 'is_private', 'description']
 
 
-class AggregateFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -173,7 +173,7 @@ class AggregateFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter
             return queryset.none()
 
 
-class RoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class RoleFilterSet(OrganizationalModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -184,7 +184,7 @@ class RoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilter
         fields = ['id', 'name', 'slug']
 
 
-class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -369,7 +369,7 @@ class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet
         )
 
 
-class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -535,7 +535,7 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter
         return queryset.exclude(assigned_object_id__isnull=value)
 
 
-class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class VLANGroupFilterSet(OrganizationalModelFilterSet):
     scope_type = ContentTypeFilter()
     region = django_filters.NumberFilter(
         method='filter_scope'
@@ -570,7 +570,7 @@ class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedF
         )
 
 
-class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -666,7 +666,7 @@ class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
         return queryset.get_for_virtualmachine(value)
 
 
-class ServiceFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
+class ServiceFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',

+ 1 - 1
netbox/ipam/tests/test_filters.py

@@ -2,7 +2,7 @@ from django.test import TestCase
 
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup
 from ipam.choices import *
-from ipam.filters import *
+from ipam.filtersets import *
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from tenancy.models import Tenant, TenantGroup

+ 30 - 30
netbox/ipam/views.py

@@ -7,7 +7,7 @@ from netbox.views import generic
 from utilities.tables import paginate_table
 from utilities.utils import count_related
 from virtualization.models import VirtualMachine, VMInterface
-from . import filters, forms, tables
+from . import filtersets, forms, tables
 from .constants import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
 from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans
@@ -19,7 +19,7 @@ from .utils import add_available_ipaddresses, add_available_prefixes, add_availa
 
 class VRFListView(generic.ObjectListView):
     queryset = VRF.objects.all()
-    filterset = filters.VRFFilterSet
+    filterset = filtersets.VRFFilterSet
     filterset_form = forms.VRFFilterForm
     table = tables.VRFTable
 
@@ -65,14 +65,14 @@ class VRFBulkImportView(generic.BulkImportView):
 
 class VRFBulkEditView(generic.BulkEditView):
     queryset = VRF.objects.prefetch_related('tenant')
-    filterset = filters.VRFFilterSet
+    filterset = filtersets.VRFFilterSet
     table = tables.VRFTable
     form = forms.VRFBulkEditForm
 
 
 class VRFBulkDeleteView(generic.BulkDeleteView):
     queryset = VRF.objects.prefetch_related('tenant')
-    filterset = filters.VRFFilterSet
+    filterset = filtersets.VRFFilterSet
     table = tables.VRFTable
 
 
@@ -82,7 +82,7 @@ class VRFBulkDeleteView(generic.BulkDeleteView):
 
 class RouteTargetListView(generic.ObjectListView):
     queryset = RouteTarget.objects.all()
-    filterset = filters.RouteTargetFilterSet
+    filterset = filtersets.RouteTargetFilterSet
     filterset_form = forms.RouteTargetFilterForm
     table = tables.RouteTargetTable
 
@@ -123,14 +123,14 @@ class RouteTargetBulkImportView(generic.BulkImportView):
 
 class RouteTargetBulkEditView(generic.BulkEditView):
     queryset = RouteTarget.objects.prefetch_related('tenant')
-    filterset = filters.RouteTargetFilterSet
+    filterset = filtersets.RouteTargetFilterSet
     table = tables.RouteTargetTable
     form = forms.RouteTargetBulkEditForm
 
 
 class RouteTargetBulkDeleteView(generic.BulkDeleteView):
     queryset = RouteTarget.objects.prefetch_related('tenant')
-    filterset = filters.RouteTargetFilterSet
+    filterset = filtersets.RouteTargetFilterSet
     table = tables.RouteTargetTable
 
 
@@ -142,7 +142,7 @@ class RIRListView(generic.ObjectListView):
     queryset = RIR.objects.annotate(
         aggregate_count=count_related(Aggregate, 'rir')
     )
-    filterset = filters.RIRFilterSet
+    filterset = filtersets.RIRFilterSet
     filterset_form = forms.RIRFilterForm
     table = tables.RIRTable
     template_name = 'ipam/rir_list.html'
@@ -184,7 +184,7 @@ class RIRBulkEditView(generic.BulkEditView):
     queryset = RIR.objects.annotate(
         aggregate_count=count_related(Aggregate, 'rir')
     )
-    filterset = filters.RIRFilterSet
+    filterset = filtersets.RIRFilterSet
     table = tables.RIRTable
     form = forms.RIRBulkEditForm
 
@@ -193,7 +193,7 @@ class RIRBulkDeleteView(generic.BulkDeleteView):
     queryset = RIR.objects.annotate(
         aggregate_count=count_related(Aggregate, 'rir')
     )
-    filterset = filters.RIRFilterSet
+    filterset = filtersets.RIRFilterSet
     table = tables.RIRTable
 
 
@@ -205,7 +205,7 @@ class AggregateListView(generic.ObjectListView):
     queryset = Aggregate.objects.annotate(
         child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
     )
-    filterset = filters.AggregateFilterSet
+    filterset = filtersets.AggregateFilterSet
     filterset_form = forms.AggregateFilterForm
     table = tables.AggregateDetailTable
     template_name = 'ipam/aggregate_list.html'
@@ -280,14 +280,14 @@ class AggregateBulkImportView(generic.BulkImportView):
 
 class AggregateBulkEditView(generic.BulkEditView):
     queryset = Aggregate.objects.prefetch_related('rir')
-    filterset = filters.AggregateFilterSet
+    filterset = filtersets.AggregateFilterSet
     table = tables.AggregateTable
     form = forms.AggregateBulkEditForm
 
 
 class AggregateBulkDeleteView(generic.BulkDeleteView):
     queryset = Aggregate.objects.prefetch_related('rir')
-    filterset = filters.AggregateFilterSet
+    filterset = filtersets.AggregateFilterSet
     table = tables.AggregateTable
 
 
@@ -337,7 +337,7 @@ class RoleBulkImportView(generic.BulkImportView):
 
 class RoleBulkEditView(generic.BulkEditView):
     queryset = Role.objects.all()
-    filterset = filters.RoleFilterSet
+    filterset = filtersets.RoleFilterSet
     table = tables.RoleTable
     form = forms.RoleBulkEditForm
 
@@ -353,7 +353,7 @@ class RoleBulkDeleteView(generic.BulkDeleteView):
 
 class PrefixListView(generic.ObjectListView):
     queryset = Prefix.objects.annotate_tree()
-    filterset = filters.PrefixFilterSet
+    filterset = filtersets.PrefixFilterSet
     filterset_form = forms.PrefixFilterForm
     table = tables.PrefixDetailTable
     template_name = 'ipam/prefix_list.html'
@@ -493,14 +493,14 @@ class PrefixBulkImportView(generic.BulkImportView):
 
 class PrefixBulkEditView(generic.BulkEditView):
     queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
-    filterset = filters.PrefixFilterSet
+    filterset = filtersets.PrefixFilterSet
     table = tables.PrefixTable
     form = forms.PrefixBulkEditForm
 
 
 class PrefixBulkDeleteView(generic.BulkDeleteView):
     queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
-    filterset = filters.PrefixFilterSet
+    filterset = filtersets.PrefixFilterSet
     table = tables.PrefixTable
 
 
@@ -510,7 +510,7 @@ class PrefixBulkDeleteView(generic.BulkDeleteView):
 
 class IPAddressListView(generic.ObjectListView):
     queryset = IPAddress.objects.all()
-    filterset = filters.IPAddressFilterSet
+    filterset = filtersets.IPAddressFilterSet
     filterset_form = forms.IPAddressFilterForm
     table = tables.IPAddressDetailTable
 
@@ -613,7 +613,7 @@ class IPAddressAssignView(generic.ObjectView):
 
             addresses = self.queryset.prefetch_related('vrf', 'tenant')
             # Limit to 100 results
-            addresses = filters.IPAddressFilterSet(request.POST, addresses).qs[:100]
+            addresses = filtersets.IPAddressFilterSet(request.POST, addresses).qs[:100]
             table = tables.IPAddressAssignTable(addresses)
 
         return render(request, 'ipam/ipaddress_assign.html', {
@@ -643,14 +643,14 @@ class IPAddressBulkImportView(generic.BulkImportView):
 
 class IPAddressBulkEditView(generic.BulkEditView):
     queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
-    filterset = filters.IPAddressFilterSet
+    filterset = filtersets.IPAddressFilterSet
     table = tables.IPAddressTable
     form = forms.IPAddressBulkEditForm
 
 
 class IPAddressBulkDeleteView(generic.BulkDeleteView):
     queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
-    filterset = filters.IPAddressFilterSet
+    filterset = filtersets.IPAddressFilterSet
     table = tables.IPAddressTable
 
 
@@ -662,7 +662,7 @@ class VLANGroupListView(generic.ObjectListView):
     queryset = VLANGroup.objects.annotate(
         vlan_count=count_related(VLAN, 'group')
     )
-    filterset = filters.VLANGroupFilterSet
+    filterset = filtersets.VLANGroupFilterSet
     filterset_form = forms.VLANGroupFilterForm
     table = tables.VLANGroupTable
 
@@ -718,7 +718,7 @@ class VLANGroupBulkEditView(generic.BulkEditView):
     queryset = VLANGroup.objects.annotate(
         vlan_count=count_related(VLAN, 'group')
     )
-    filterset = filters.VLANGroupFilterSet
+    filterset = filtersets.VLANGroupFilterSet
     table = tables.VLANGroupTable
     form = forms.VLANGroupBulkEditForm
 
@@ -727,7 +727,7 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = VLANGroup.objects.annotate(
         vlan_count=count_related(VLAN, 'group')
     )
-    filterset = filters.VLANGroupFilterSet
+    filterset = filtersets.VLANGroupFilterSet
     table = tables.VLANGroupTable
 
 
@@ -737,7 +737,7 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView):
 
 class VLANListView(generic.ObjectListView):
     queryset = VLAN.objects.all()
-    filterset = filters.VLANFilterSet
+    filterset = filtersets.VLANFilterSet
     filterset_form = forms.VLANFilterForm
     table = tables.VLANDetailTable
 
@@ -805,14 +805,14 @@ class VLANBulkImportView(generic.BulkImportView):
 
 class VLANBulkEditView(generic.BulkEditView):
     queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
-    filterset = filters.VLANFilterSet
+    filterset = filtersets.VLANFilterSet
     table = tables.VLANTable
     form = forms.VLANBulkEditForm
 
 
 class VLANBulkDeleteView(generic.BulkDeleteView):
     queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
-    filterset = filters.VLANFilterSet
+    filterset = filtersets.VLANFilterSet
     table = tables.VLANTable
 
 
@@ -822,7 +822,7 @@ class VLANBulkDeleteView(generic.BulkDeleteView):
 
 class ServiceListView(generic.ObjectListView):
     queryset = Service.objects.all()
-    filterset = filters.ServiceFilterSet
+    filterset = filtersets.ServiceFilterSet
     filterset_form = forms.ServiceFilterForm
     table = tables.ServiceTable
     action_buttons = ('import', 'export')
@@ -863,12 +863,12 @@ class ServiceDeleteView(generic.ObjectDeleteView):
 
 class ServiceBulkEditView(generic.BulkEditView):
     queryset = Service.objects.prefetch_related('device', 'virtual_machine')
-    filterset = filters.ServiceFilterSet
+    filterset = filtersets.ServiceFilterSet
     table = tables.ServiceTable
     form = forms.ServiceBulkEditForm
 
 
 class ServiceBulkDeleteView(generic.BulkDeleteView):
     queryset = Service.objects.prefetch_related('device', 'virtual_machine')
-    filterset = filters.ServiceFilterSet
+    filterset = filtersets.ServiceFilterSet
     table = tables.ServiceTable

+ 6 - 6
netbox/netbox/constants.py

@@ -1,9 +1,9 @@
 from collections import OrderedDict
 
-from circuits.filters import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet
+from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet
 from circuits.models import Circuit, ProviderNetwork, Provider
 from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable
-from dcim.filters import (
+from dcim.filtersets import (
     CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, LocationFilterSet,
     SiteFilterSet, VirtualChassisFilterSet,
 )
@@ -12,17 +12,17 @@ from dcim.tables import (
     CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, LocationTable, SiteTable,
     VirtualChassisTable,
 )
-from ipam.filters import AggregateFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet
+from ipam.filtersets import AggregateFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet
 from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
 from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
-from secrets.filters import SecretFilterSet
+from secrets.filtersets import SecretFilterSet
 from secrets.models import Secret
 from secrets.tables import SecretTable
-from tenancy.filters import TenantFilterSet
+from tenancy.filtersets import TenantFilterSet
 from tenancy.models import Tenant
 from tenancy.tables import TenantTable
 from utilities.utils import count_related
-from virtualization.filters import ClusterFilterSet, VirtualMachineFilterSet
+from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
 from virtualization.models import Cluster, VirtualMachine
 from virtualization.tables import ClusterTable, VirtualMachineDetailTable
 

+ 238 - 0
netbox/netbox/filtersets.py

@@ -0,0 +1,238 @@
+import django_filters
+from copy import deepcopy
+from django.contrib.contenttypes.models import ContentType
+from django.db import models
+from django_filters.utils import get_model_field, resolve_field
+
+from dcim.forms import MACAddressField
+from extras.choices import CustomFieldFilterLogicChoices
+from extras.filters import CustomFieldFilter, TagFilter
+from extras.models import CustomField
+from utilities.constants import (
+    FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
+    FILTER_NUMERIC_BASED_LOOKUP_MAP
+)
+from utilities import filters
+
+
+__all__ = (
+    'BaseFilterSet',
+    'ChangeLoggedModelFilterSet',
+    'OrganizationalModelFilterSet',
+    'PrimaryModelFilterSet',
+)
+
+
+#
+# FilterSets
+#
+
+class BaseFilterSet(django_filters.FilterSet):
+    """
+    A base FilterSet which provides common functionality to all NetBox FilterSets
+    """
+    FILTER_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS)
+    FILTER_DEFAULTS.update({
+        models.AutoField: {
+            'filter_class': filters.MultiValueNumberFilter
+        },
+        models.CharField: {
+            'filter_class': filters.MultiValueCharFilter
+        },
+        models.DateField: {
+            'filter_class': filters.MultiValueDateFilter
+        },
+        models.DateTimeField: {
+            'filter_class': filters.MultiValueDateTimeFilter
+        },
+        models.DecimalField: {
+            'filter_class': filters.MultiValueNumberFilter
+        },
+        models.EmailField: {
+            'filter_class': filters.MultiValueCharFilter
+        },
+        models.FloatField: {
+            'filter_class': filters.MultiValueNumberFilter
+        },
+        models.IntegerField: {
+            'filter_class': filters.MultiValueNumberFilter
+        },
+        models.PositiveIntegerField: {
+            'filter_class': filters.MultiValueNumberFilter
+        },
+        models.PositiveSmallIntegerField: {
+            'filter_class': filters.MultiValueNumberFilter
+        },
+        models.SlugField: {
+            'filter_class': filters.MultiValueCharFilter
+        },
+        models.SmallIntegerField: {
+            'filter_class': filters.MultiValueNumberFilter
+        },
+        models.TimeField: {
+            'filter_class': filters.MultiValueTimeFilter
+        },
+        models.URLField: {
+            'filter_class': filters.MultiValueCharFilter
+        },
+        MACAddressField: {
+            'filter_class': filters.MultiValueMACAddressFilter
+        },
+    })
+
+    @staticmethod
+    def _get_filter_lookup_dict(existing_filter):
+        # Choose the lookup expression map based on the filter type
+        if isinstance(existing_filter, (
+            filters.MultiValueDateFilter,
+            filters.MultiValueDateTimeFilter,
+            filters.MultiValueNumberFilter,
+            filters.MultiValueTimeFilter
+        )):
+            lookup_map = FILTER_NUMERIC_BASED_LOOKUP_MAP
+
+        elif isinstance(existing_filter, (
+            filters.TreeNodeMultipleChoiceFilter,
+        )):
+            # TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression
+            lookup_map = FILTER_TREENODE_NEGATION_LOOKUP_MAP
+
+        elif isinstance(existing_filter, (
+            django_filters.ModelChoiceFilter,
+            django_filters.ModelMultipleChoiceFilter,
+            TagFilter
+        )) or existing_filter.extra.get('choices'):
+            # These filter types support only negation
+            lookup_map = FILTER_NEGATION_LOOKUP_MAP
+
+        elif isinstance(existing_filter, (
+            django_filters.filters.CharFilter,
+            django_filters.MultipleChoiceFilter,
+            filters.MultiValueCharFilter,
+            filters.MultiValueMACAddressFilter
+        )):
+            lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP
+
+        else:
+            lookup_map = None
+
+        return lookup_map
+
+    @classmethod
+    def get_filters(cls):
+        """
+        Override filter generation to support dynamic lookup expressions for certain filter types.
+
+        For specific filter types, new filters are created based on defined lookup expressions in
+        the form `<field_name>__<lookup_expr>`
+        """
+        filters = super().get_filters()
+
+        new_filters = {}
+        for existing_filter_name, existing_filter in filters.items():
+            # Loop over existing filters to extract metadata by which to create new filters
+
+            # If the filter makes use of a custom filter method or lookup expression skip it
+            # as we cannot sanely handle these cases in a generic mannor
+            if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']:
+                continue
+
+            # Choose the lookup expression map based on the filter type
+            lookup_map = cls._get_filter_lookup_dict(existing_filter)
+            if lookup_map is None:
+                # Do not augment this filter type with more lookup expressions
+                continue
+
+            # Get properties of the existing filter for later use
+            field_name = existing_filter.field_name
+            field = get_model_field(cls._meta.model, field_name)
+
+            # Create new filters for each lookup expression in the map
+            for lookup_name, lookup_expr in lookup_map.items():
+                new_filter_name = '{}__{}'.format(existing_filter_name, lookup_name)
+
+                try:
+                    if existing_filter_name in cls.declared_filters:
+                        # The filter field has been explicity defined on the filterset class so we must manually
+                        # create the new filter with the same type because there is no guarantee the defined type
+                        # is the same as the default type for the field
+                        resolve_field(field, lookup_expr)  # Will raise FieldLookupError if the lookup is invalid
+                        new_filter = type(existing_filter)(
+                            field_name=field_name,
+                            lookup_expr=lookup_expr,
+                            label=existing_filter.label,
+                            exclude=existing_filter.exclude,
+                            distinct=existing_filter.distinct,
+                            **existing_filter.extra
+                        )
+                    else:
+                        # The filter field is listed in Meta.fields so we can safely rely on default behaviour
+                        # Will raise FieldLookupError if the lookup is invalid
+                        new_filter = cls.filter_for_field(field, field_name, lookup_expr)
+                except django_filters.exceptions.FieldLookupError:
+                    # The filter could not be created because the lookup expression is not supported on the field
+                    continue
+
+                if lookup_name.startswith('n'):
+                    # This is a negation filter which requires a queryset.exclude() clause
+                    # Of course setting the negation of the existing filter's exclude attribute handles both cases
+                    new_filter.exclude = not existing_filter.exclude
+
+                new_filters[new_filter_name] = new_filter
+
+        filters.update(new_filters)
+        return filters
+
+
+class ChangeLoggedModelFilterSet(BaseFilterSet):
+    created = django_filters.DateFilter()
+    created__gte = django_filters.DateFilter(
+        field_name='created',
+        lookup_expr='gte'
+    )
+    created__lte = django_filters.DateFilter(
+        field_name='created',
+        lookup_expr='lte'
+    )
+    last_updated = django_filters.DateTimeFilter()
+    last_updated__gte = django_filters.DateTimeFilter(
+        field_name='last_updated',
+        lookup_expr='gte'
+    )
+    last_updated__lte = django_filters.DateTimeFilter(
+        field_name='last_updated',
+        lookup_expr='lte'
+    )
+
+
+class PrimaryModelFilterSet(ChangeLoggedModelFilterSet):
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Dynamically add a Filter for each CustomField applicable to the parent model
+        custom_fields = CustomField.objects.filter(
+            content_types=ContentType.objects.get_for_model(self._meta.model)
+        ).exclude(
+            filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
+        )
+        for cf in custom_fields:
+            self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf)
+
+
+class OrganizationalModelFilterSet(PrimaryModelFilterSet):
+    """
+    A base class for adding the search method to models which only expose the `name` and `slug` fields
+    """
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            models.Q(name__icontains=value) |
+            models.Q(slug__icontains=value)
+        )

+ 3 - 3
netbox/secrets/api/views.py

@@ -10,7 +10,7 @@ from rest_framework.viewsets import ViewSet
 
 from extras.api.views import CustomFieldModelViewSet
 from netbox.api.views import ModelViewSet
-from secrets import filters
+from secrets import filtersets
 from secrets.exceptions import InvalidKey
 from secrets.models import Secret, SecretRole, SessionKey, UserKey
 from utilities.utils import count_related
@@ -39,7 +39,7 @@ class SecretRoleViewSet(CustomFieldModelViewSet):
         secret_count=count_related(Secret, 'role')
     )
     serializer_class = serializers.SecretRoleSerializer
-    filterset_class = filters.SecretRoleFilterSet
+    filterset_class = filtersets.SecretRoleFilterSet
 
 
 #
@@ -49,7 +49,7 @@ class SecretRoleViewSet(CustomFieldModelViewSet):
 class SecretViewSet(ModelViewSet):
     queryset = Secret.objects.prefetch_related('role', 'tags')
     serializer_class = serializers.SecretSerializer
-    filterset_class = filters.SecretFilterSet
+    filterset_class = filtersets.SecretFilterSet
 
     master_key = None
 

+ 4 - 4
netbox/secrets/filters.py → netbox/secrets/filtersets.py

@@ -2,8 +2,8 @@ import django_filters
 from django.db.models import Q
 
 from dcim.models import Device
-from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
-from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter
+from extras.filters import TagFilter
+from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
 from virtualization.models import VirtualMachine
 from .models import Secret, SecretRole
 
@@ -14,14 +14,14 @@ __all__ = (
 )
 
 
-class SecretRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class SecretRoleFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = SecretRole
         fields = ['id', 'name', 'slug']
 
 
-class SecretFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class SecretFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',

+ 1 - 1
netbox/secrets/tests/test_filters.py

@@ -1,7 +1,7 @@
 from django.test import TestCase
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
-from secrets.filters import *
+from secrets.filtersets import *
 from secrets.models import Secret, SecretRole
 from virtualization.models import Cluster, ClusterType, VirtualMachine
 

+ 6 - 6
netbox/secrets/views.py

@@ -2,14 +2,14 @@ import base64
 import logging
 
 from django.contrib import messages
-from django.shortcuts import get_object_or_404, redirect, render
+from django.shortcuts import redirect, render
 from django.utils.html import escape
 from django.utils.safestring import mark_safe
 
 from netbox.views import generic
 from utilities.tables import paginate_table
 from utilities.utils import count_related
-from . import filters, forms, tables
+from . import filtersets, forms, tables
 from .models import SecretRole, Secret, SessionKey, UserKey
 
 
@@ -70,7 +70,7 @@ class SecretRoleBulkEditView(generic.BulkEditView):
     queryset = SecretRole.objects.annotate(
         secret_count=count_related(Secret, 'role')
     )
-    filterset = filters.SecretRoleFilterSet
+    filterset = filtersets.SecretRoleFilterSet
     table = tables.SecretRoleTable
     form = forms.SecretRoleBulkEditForm
 
@@ -88,7 +88,7 @@ class SecretRoleBulkDeleteView(generic.BulkDeleteView):
 
 class SecretListView(generic.ObjectListView):
     queryset = Secret.objects.all()
-    filterset = filters.SecretFilterSet
+    filterset = filtersets.SecretFilterSet
     filterset_form = forms.SecretFilterForm
     table = tables.SecretTable
     action_buttons = ('import', 'export')
@@ -220,12 +220,12 @@ class SecretBulkImportView(generic.BulkImportView):
 
 class SecretBulkEditView(generic.BulkEditView):
     queryset = Secret.objects.prefetch_related('role')
-    filterset = filters.SecretFilterSet
+    filterset = filtersets.SecretFilterSet
     table = tables.SecretTable
     form = forms.SecretBulkEditForm
 
 
 class SecretBulkDeleteView(generic.BulkDeleteView):
     queryset = Secret.objects.prefetch_related('role')
-    filterset = filters.SecretFilterSet
+    filterset = filtersets.SecretFilterSet
     table = tables.SecretTable

+ 3 - 3
netbox/tenancy/api/views.py

@@ -4,7 +4,7 @@ from circuits.models import Circuit
 from dcim.models import Device, Rack, Site
 from extras.api.views import CustomFieldModelViewSet
 from ipam.models import IPAddress, Prefix, VLAN, VRF
-from tenancy import filters
+from tenancy import filtersets
 from tenancy.models import Tenant, TenantGroup
 from utilities.utils import count_related
 from virtualization.models import VirtualMachine
@@ -32,7 +32,7 @@ class TenantGroupViewSet(CustomFieldModelViewSet):
         cumulative=True
     )
     serializer_class = serializers.TenantGroupSerializer
-    filterset_class = filters.TenantGroupFilterSet
+    filterset_class = filtersets.TenantGroupFilterSet
 
 
 #
@@ -54,4 +54,4 @@ class TenantViewSet(CustomFieldModelViewSet):
         vrf_count=count_related(VRF, 'tenant')
     )
     serializer_class = serializers.TenantSerializer
-    filterset_class = filters.TenantFilterSet
+    filterset_class = filtersets.TenantFilterSet

+ 5 - 4
netbox/tenancy/filters.py → netbox/tenancy/filtersets.py

@@ -1,8 +1,9 @@
 import django_filters
 from django.db.models import Q
 
-from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
-from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter
+from extras.filters import TagFilter
+from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
+from utilities.filters import TreeNodeMultipleChoiceFilter
 from .models import Tenant, TenantGroup
 
 
@@ -13,7 +14,7 @@ __all__ = (
 )
 
 
-class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class TenantGroupFilterSet(OrganizationalModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=TenantGroup.objects.all(),
         label='Tenant group (ID)',
@@ -30,7 +31,7 @@ class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdate
         fields = ['id', 'name', 'slug', 'description']
 
 
-class TenantFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class TenantFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',

+ 1 - 1
netbox/tenancy/tests/test_filters.py

@@ -1,6 +1,6 @@
 from django.test import TestCase
 
-from tenancy.filters import *
+from tenancy.filtersets import *
 from tenancy.models import Tenant, TenantGroup
 
 

+ 5 - 5
netbox/tenancy/views.py

@@ -4,7 +4,7 @@ from ipam.models import IPAddress, Prefix, VLAN, VRF
 from netbox.views import generic
 from utilities.tables import paginate_table
 from virtualization.models import VirtualMachine, Cluster
-from . import filters, forms, tables
+from . import filtersets, forms, tables
 from .models import Tenant, TenantGroup
 
 
@@ -63,7 +63,7 @@ class TenantGroupBulkEditView(generic.BulkEditView):
         'tenant_count',
         cumulative=True
     )
-    filterset = filters.TenantGroupFilterSet
+    filterset = filtersets.TenantGroupFilterSet
     table = tables.TenantGroupTable
     form = forms.TenantGroupBulkEditForm
 
@@ -85,7 +85,7 @@ class TenantGroupBulkDeleteView(generic.BulkDeleteView):
 
 class TenantListView(generic.ObjectListView):
     queryset = Tenant.objects.all()
-    filterset = filters.TenantFilterSet
+    filterset = filtersets.TenantFilterSet
     filterset_form = forms.TenantFilterForm
     table = tables.TenantTable
 
@@ -130,12 +130,12 @@ class TenantBulkImportView(generic.BulkImportView):
 
 class TenantBulkEditView(generic.BulkEditView):
     queryset = Tenant.objects.prefetch_related('group')
-    filterset = filters.TenantFilterSet
+    filterset = filtersets.TenantFilterSet
     table = tables.TenantTable
     form = forms.TenantBulkEditForm
 
 
 class TenantBulkDeleteView(generic.BulkDeleteView):
     queryset = Tenant.objects.prefetch_related('group')
-    filterset = filters.TenantFilterSet
+    filterset = filtersets.TenantFilterSet
     table = tables.TenantTable

+ 4 - 4
netbox/users/api/views.py

@@ -6,7 +6,7 @@ from rest_framework.routers import APIRootView
 from rest_framework.viewsets import ViewSet
 
 from netbox.api.views import ModelViewSet
-from users import filters
+from users import filtersets
 from users.models import ObjectPermission, UserConfig
 from utilities.querysets import RestrictedQuerySet
 from utilities.utils import deepmerge
@@ -28,13 +28,13 @@ class UsersRootView(APIRootView):
 class UserViewSet(ModelViewSet):
     queryset = RestrictedQuerySet(model=User).prefetch_related('groups').order_by('username')
     serializer_class = serializers.UserSerializer
-    filterset_class = filters.UserFilterSet
+    filterset_class = filtersets.UserFilterSet
 
 
 class GroupViewSet(ModelViewSet):
     queryset = RestrictedQuerySet(model=Group).annotate(user_count=Count('user')).order_by('name')
     serializer_class = serializers.GroupSerializer
-    filterset_class = filters.GroupFilterSet
+    filterset_class = filtersets.GroupFilterSet
 
 
 #
@@ -44,7 +44,7 @@ class GroupViewSet(ModelViewSet):
 class ObjectPermissionViewSet(ModelViewSet):
     queryset = ObjectPermission.objects.prefetch_related('object_types', 'groups', 'users')
     serializer_class = serializers.ObjectPermissionSerializer
-    filterset_class = filters.ObjectPermissionFilterSet
+    filterset_class = filtersets.ObjectPermissionFilterSet
 
 
 #

+ 1 - 1
netbox/users/filters.py → netbox/users/filtersets.py

@@ -2,8 +2,8 @@ import django_filters
 from django.contrib.auth.models import Group, User
 from django.db.models import Q
 
+from netbox.filtersets import BaseFilterSet
 from users.models import ObjectPermission
-from utilities.filters import BaseFilterSet
 
 __all__ = (
     'GroupFilterSet',

+ 1 - 1
netbox/users/tests/test_filters.py

@@ -2,7 +2,7 @@ from django.contrib.auth.models import Group, User
 from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 
-from users.filters import GroupFilterSet, ObjectPermissionFilterSet, UserFilterSet
+from users.filtersets import GroupFilterSet, ObjectPermissionFilterSet, UserFilterSet
 from users.models import ObjectPermission
 
 

+ 2 - 204
netbox/utilities/filters.py

@@ -1,17 +1,9 @@
 import django_filters
-from django_filters.constants import EMPTY_VALUES
-from copy import deepcopy
-from dcim.forms import MACAddressField
 from django import forms
 from django.conf import settings
-from django.db import models
-from django_filters.utils import get_model_field, resolve_field
+from django_filters.constants import EMPTY_VALUES
 
-from 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
-)
+from dcim.forms import MACAddressField
 
 
 def multivalue_field_factory(field_class):
@@ -91,21 +83,6 @@ class NullableCharFieldFilter(django_filters.CharFilter):
         return qs.distinct() if self.distinct else qs
 
 
-class TagFilter(django_filters.ModelMultipleChoiceFilter):
-    """
-    Match on one or more assigned tags. If multiple tags are specified (e.g. ?tag=foo&tag=bar), the queryset is filtered
-    to objects matching all tags.
-    """
-    def __init__(self, *args, **kwargs):
-
-        kwargs.setdefault('field_name', 'tags__slug')
-        kwargs.setdefault('to_field_name', 'slug')
-        kwargs.setdefault('conjoined', True)
-        kwargs.setdefault('queryset', Tag.objects.all())
-
-        super().__init__(*args, **kwargs)
-
-
 class NumericArrayFilter(django_filters.NumberFilter):
     """
     Filter based on the presence of an integer within an ArrayField.
@@ -134,182 +111,3 @@ class ContentTypeFilter(django_filters.CharFilter):
                 f'{self.field_name}__model': model
             }
         )
-
-
-#
-# FilterSets
-#
-
-class BaseFilterSet(django_filters.FilterSet):
-    """
-    A base filterset which provides common functionaly to all NetBox filtersets
-    """
-    FILTER_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS)
-    FILTER_DEFAULTS.update({
-        models.AutoField: {
-            'filter_class': MultiValueNumberFilter
-        },
-        models.CharField: {
-            'filter_class': MultiValueCharFilter
-        },
-        models.DateField: {
-            'filter_class': MultiValueDateFilter
-        },
-        models.DateTimeField: {
-            'filter_class': MultiValueDateTimeFilter
-        },
-        models.DecimalField: {
-            'filter_class': MultiValueNumberFilter
-        },
-        models.EmailField: {
-            'filter_class': MultiValueCharFilter
-        },
-        models.FloatField: {
-            'filter_class': MultiValueNumberFilter
-        },
-        models.IntegerField: {
-            'filter_class': MultiValueNumberFilter
-        },
-        models.PositiveIntegerField: {
-            'filter_class': MultiValueNumberFilter
-        },
-        models.PositiveSmallIntegerField: {
-            'filter_class': MultiValueNumberFilter
-        },
-        models.SlugField: {
-            'filter_class': MultiValueCharFilter
-        },
-        models.SmallIntegerField: {
-            'filter_class': MultiValueNumberFilter
-        },
-        models.TimeField: {
-            'filter_class': MultiValueTimeFilter
-        },
-        models.URLField: {
-            'filter_class': MultiValueCharFilter
-        },
-        MACAddressField: {
-            'filter_class': MultiValueMACAddressFilter
-        },
-    })
-
-    @staticmethod
-    def _get_filter_lookup_dict(existing_filter):
-        # Choose the lookup expression map based on the filter type
-        if isinstance(existing_filter, (
-            MultiValueDateFilter,
-            MultiValueDateTimeFilter,
-            MultiValueNumberFilter,
-            MultiValueTimeFilter
-        )):
-            lookup_map = FILTER_NUMERIC_BASED_LOOKUP_MAP
-
-        elif isinstance(existing_filter, (
-            TreeNodeMultipleChoiceFilter,
-        )):
-            # TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression
-            lookup_map = FILTER_TREENODE_NEGATION_LOOKUP_MAP
-
-        elif isinstance(existing_filter, (
-            django_filters.ModelChoiceFilter,
-            django_filters.ModelMultipleChoiceFilter,
-            TagFilter
-        )) or existing_filter.extra.get('choices'):
-            # These filter types support only negation
-            lookup_map = FILTER_NEGATION_LOOKUP_MAP
-
-        elif isinstance(existing_filter, (
-            django_filters.filters.CharFilter,
-            django_filters.MultipleChoiceFilter,
-            MultiValueCharFilter,
-            MultiValueMACAddressFilter
-        )):
-            lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP
-
-        else:
-            lookup_map = None
-
-        return lookup_map
-
-    @classmethod
-    def get_filters(cls):
-        """
-        Override filter generation to support dynamic lookup expressions for certain filter types.
-
-        For specific filter types, new filters are created based on defined lookup expressions in
-        the form `<field_name>__<lookup_expr>`
-        """
-        filters = super().get_filters()
-
-        new_filters = {}
-        for existing_filter_name, existing_filter in filters.items():
-            # Loop over existing filters to extract metadata by which to create new filters
-
-            # If the filter makes use of a custom filter method or lookup expression skip it
-            # as we cannot sanely handle these cases in a generic mannor
-            if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']:
-                continue
-
-            # Choose the lookup expression map based on the filter type
-            lookup_map = cls._get_filter_lookup_dict(existing_filter)
-            if lookup_map is None:
-                # Do not augment this filter type with more lookup expressions
-                continue
-
-            # Get properties of the existing filter for later use
-            field_name = existing_filter.field_name
-            field = get_model_field(cls._meta.model, field_name)
-
-            # Create new filters for each lookup expression in the map
-            for lookup_name, lookup_expr in lookup_map.items():
-                new_filter_name = '{}__{}'.format(existing_filter_name, lookup_name)
-
-                try:
-                    if existing_filter_name in cls.declared_filters:
-                        # The filter field has been explicity defined on the filterset class so we must manually
-                        # create the new filter with the same type because there is no guarantee the defined type
-                        # is the same as the default type for the field
-                        resolve_field(field, lookup_expr)  # Will raise FieldLookupError if the lookup is invalid
-                        new_filter = type(existing_filter)(
-                            field_name=field_name,
-                            lookup_expr=lookup_expr,
-                            label=existing_filter.label,
-                            exclude=existing_filter.exclude,
-                            distinct=existing_filter.distinct,
-                            **existing_filter.extra
-                        )
-                    else:
-                        # The filter field is listed in Meta.fields so we can safely rely on default behaviour
-                        # Will raise FieldLookupError if the lookup is invalid
-                        new_filter = cls.filter_for_field(field, field_name, lookup_expr)
-                except django_filters.exceptions.FieldLookupError:
-                    # The filter could not be created because the lookup expression is not supported on the field
-                    continue
-
-                if lookup_name.startswith('n'):
-                    # This is a negation filter which requires a queryset.exclude() clause
-                    # Of course setting the negation of the existing filter's exclude attribute handles both cases
-                    new_filter.exclude = not existing_filter.exclude
-
-                new_filters[new_filter_name] = new_filter
-
-        filters.update(new_filters)
-        return filters
-
-
-class NameSlugSearchFilterSet(django_filters.FilterSet):
-    """
-    A base class for adding the search method to models which only expose the `name` and `slug` fields
-    """
-    q = django_filters.CharFilter(
-        method='search',
-        label='Search',
-    )
-
-    def search(self, queryset, name, value):
-        if not value.strip():
-            return queryset
-        return queryset.filter(
-            models.Q(name__icontains=value) |
-            models.Q(slug__icontains=value)
-        )

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

@@ -7,14 +7,16 @@ from taggit.managers import TaggableManager
 
 from dcim.choices import *
 from dcim.fields import MACAddressField
-from dcim.filters import DeviceFilterSet, SiteFilterSet
+from dcim.filtersets import DeviceFilterSet, SiteFilterSet
 from dcim.models import (
     Device, DeviceRole, DeviceType, Interface, Manufacturer, Platform, Rack, Region, Site
 )
+from extras.filters import TagFilter
 from extras.models import TaggedItem
+from netbox.filtersets import BaseFilterSet
 from utilities.filters import (
-    BaseFilterSet, MACAddressFilter, MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter,
-    MultiValueNumberFilter, MultiValueTimeFilter, TagFilter, TreeNodeMultipleChoiceFilter,
+    MACAddressFilter, MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter, MultiValueNumberFilter,
+    MultiValueTimeFilter, TreeNodeMultipleChoiceFilter,
 )
 
 

+ 6 - 6
netbox/virtualization/api/views.py

@@ -3,7 +3,7 @@ from rest_framework.routers import APIRootView
 from dcim.models import Device
 from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet, ModelViewSet
 from utilities.utils import count_related
-from virtualization import filters
+from virtualization import filtersets
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from . import serializers
 
@@ -25,7 +25,7 @@ class ClusterTypeViewSet(CustomFieldModelViewSet):
         cluster_count=count_related(Cluster, 'type')
     )
     serializer_class = serializers.ClusterTypeSerializer
-    filterset_class = filters.ClusterTypeFilterSet
+    filterset_class = filtersets.ClusterTypeFilterSet
 
 
 class ClusterGroupViewSet(CustomFieldModelViewSet):
@@ -33,7 +33,7 @@ class ClusterGroupViewSet(CustomFieldModelViewSet):
         cluster_count=count_related(Cluster, 'group')
     )
     serializer_class = serializers.ClusterGroupSerializer
-    filterset_class = filters.ClusterGroupFilterSet
+    filterset_class = filtersets.ClusterGroupFilterSet
 
 
 class ClusterViewSet(CustomFieldModelViewSet):
@@ -44,7 +44,7 @@ class ClusterViewSet(CustomFieldModelViewSet):
         virtualmachine_count=count_related(VirtualMachine, 'cluster')
     )
     serializer_class = serializers.ClusterSerializer
-    filterset_class = filters.ClusterFilterSet
+    filterset_class = filtersets.ClusterFilterSet
 
 
 #
@@ -55,7 +55,7 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet)
     queryset = VirtualMachine.objects.prefetch_related(
         'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
     )
-    filterset_class = filters.VirtualMachineFilterSet
+    filterset_class = filtersets.VirtualMachineFilterSet
 
     def get_serializer_class(self):
         """
@@ -83,5 +83,5 @@ class VMInterfaceViewSet(ModelViewSet):
         'virtual_machine', 'parent', 'tags', 'tagged_vlans', 'ip_addresses'
     )
     serializer_class = serializers.VMInterfaceSerializer
-    filterset_class = filters.VMInterfaceFilterSet
+    filterset_class = filtersets.VMInterfaceFilterSet
     brief_prefetch_fields = ['virtual_machine']

+ 10 - 17
netbox/virtualization/filters.py → netbox/virtualization/filtersets.py

@@ -2,12 +2,11 @@ import django_filters
 from django.db.models import Q
 
 from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
-from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet
-from tenancy.filters import TenancyFilterSet
-from utilities.filters import (
-    BaseFilterSet, MultiValueMACAddressFilter, NameSlugSearchFilterSet, TagFilter,
-    TreeNodeMultipleChoiceFilter,
-)
+from extras.filters import TagFilter
+from extras.filtersets import LocalConfigContextFilterSet
+from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
+from tenancy.filtersets import TenancyFilterSet
+from utilities.filters import MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
 from .choices import *
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
@@ -20,21 +19,21 @@ __all__ = (
 )
 
 
-class ClusterTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class ClusterTypeFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = ClusterType
         fields = ['id', 'name', 'slug', 'description']
 
 
-class ClusterGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class ClusterGroupFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = ClusterGroup
         fields = ['id', 'name', 'slug', 'description']
 
 
-class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -110,13 +109,7 @@ class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSe
         )
 
 
-class VirtualMachineFilterSet(
-    BaseFilterSet,
-    LocalConfigContextFilterSet,
-    TenancyFilterSet,
-    CustomFieldModelFilterSet,
-    CreatedUpdatedFilterSet
-):
+class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -237,7 +230,7 @@ class VirtualMachineFilterSet(
         return queryset.exclude(params)
 
 
-class VMInterfaceFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class VMInterfaceFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',

+ 1 - 1
netbox/virtualization/tests/test_filters.py

@@ -4,7 +4,7 @@ from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
 from ipam.models import IPAddress
 from tenancy.models import Tenant, TenantGroup
 from virtualization.choices import *
-from virtualization.filters import *
+from virtualization.filtersets import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 

+ 11 - 11
netbox/virtualization/views.py

@@ -13,7 +13,7 @@ from netbox.views import generic
 from secrets.models import Secret
 from utilities.tables import paginate_table
 from utilities.utils import count_related
-from . import filters, forms, tables
+from . import filtersets, forms, tables
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 
@@ -64,7 +64,7 @@ class ClusterTypeBulkEditView(generic.BulkEditView):
     queryset = ClusterType.objects.annotate(
         cluster_count=count_related(Cluster, 'type')
     )
-    filterset = filters.ClusterTypeFilterSet
+    filterset = filtersets.ClusterTypeFilterSet
     table = tables.ClusterTypeTable
     form = forms.ClusterTypeBulkEditForm
 
@@ -125,7 +125,7 @@ class ClusterGroupBulkEditView(generic.BulkEditView):
     queryset = ClusterGroup.objects.annotate(
         cluster_count=count_related(Cluster, 'group')
     )
-    filterset = filters.ClusterGroupFilterSet
+    filterset = filtersets.ClusterGroupFilterSet
     table = tables.ClusterGroupTable
     form = forms.ClusterGroupBulkEditForm
 
@@ -148,7 +148,7 @@ class ClusterListView(generic.ObjectListView):
         vm_count=count_related(VirtualMachine, 'cluster')
     )
     table = tables.ClusterTable
-    filterset = filters.ClusterFilterSet
+    filterset = filtersets.ClusterFilterSet
     filterset_form = forms.ClusterFilterForm
 
 
@@ -205,14 +205,14 @@ class ClusterBulkImportView(generic.BulkImportView):
 
 class ClusterBulkEditView(generic.BulkEditView):
     queryset = Cluster.objects.prefetch_related('type', 'group', 'site')
-    filterset = filters.ClusterFilterSet
+    filterset = filtersets.ClusterFilterSet
     table = tables.ClusterTable
     form = forms.ClusterBulkEditForm
 
 
 class ClusterBulkDeleteView(generic.BulkDeleteView):
     queryset = Cluster.objects.prefetch_related('type', 'group', 'site')
-    filterset = filters.ClusterFilterSet
+    filterset = filtersets.ClusterFilterSet
     table = tables.ClusterTable
 
 
@@ -304,7 +304,7 @@ class ClusterRemoveDevicesView(generic.ObjectEditView):
 
 class VirtualMachineListView(generic.ObjectListView):
     queryset = VirtualMachine.objects.all()
-    filterset = filters.VirtualMachineFilterSet
+    filterset = filtersets.VirtualMachineFilterSet
     filterset_form = forms.VirtualMachineFilterForm
     table = tables.VirtualMachineDetailTable
     template_name = 'virtualization/virtualmachine_list.html'
@@ -388,14 +388,14 @@ class VirtualMachineBulkImportView(generic.BulkImportView):
 
 class VirtualMachineBulkEditView(generic.BulkEditView):
     queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role')
-    filterset = filters.VirtualMachineFilterSet
+    filterset = filtersets.VirtualMachineFilterSet
     table = tables.VirtualMachineTable
     form = forms.VirtualMachineBulkEditForm
 
 
 class VirtualMachineBulkDeleteView(generic.BulkDeleteView):
     queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role')
-    filterset = filters.VirtualMachineFilterSet
+    filterset = filtersets.VirtualMachineFilterSet
     table = tables.VirtualMachineTable
 
 
@@ -405,7 +405,7 @@ class VirtualMachineBulkDeleteView(generic.BulkDeleteView):
 
 class VMInterfaceListView(generic.ObjectListView):
     queryset = VMInterface.objects.all()
-    filterset = filters.VMInterfaceFilterSet
+    filterset = filtersets.VMInterfaceFilterSet
     filterset_form = forms.VMInterfaceFilterForm
     table = tables.VMInterfaceTable
     action_buttons = ('export',)
@@ -500,7 +500,7 @@ class VirtualMachineBulkAddInterfaceView(generic.BulkComponentCreateView):
     form = forms.VMInterfaceBulkCreateForm
     queryset = VMInterface.objects.all()
     model_form = forms.VMInterfaceForm
-    filterset = filters.VirtualMachineFilterSet
+    filterset = filtersets.VirtualMachineFilterSet
     table = tables.VirtualMachineTable
 
     def get_required_permission(self):