Bladeren bron

Merge branch 'develop' into feature

jeremystretch 4 jaren geleden
bovenliggende
commit
534b0e4cf6
53 gewijzigde bestanden met toevoegingen van 1171 en 1308 verwijderingen
  1. 22 17
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/documentation_change.yaml
  3. 19 15
      .github/ISSUE_TEMPLATE/feature_request.yaml
  4. 7 5
      .github/ISSUE_TEMPLATE/housekeeping.yaml
  5. 1 0
      .github/workflows/stale.yml
  6. 1 1
      README.md
  7. 8 0
      docs/configuration/optional-settings.md
  8. 19 0
      docs/release-notes/version-2.11.md
  9. 6 6
      netbox/circuits/api/views.py
  10. 11 12
      netbox/circuits/filtersets.py
  11. 7 22
      netbox/circuits/tests/test_filtersets.py
  12. 11 11
      netbox/circuits/views.py
  13. 37 37
      netbox/dcim/api/views.py
  14. 2 0
      netbox/dcim/choices.py
  15. 46 60
      netbox/dcim/filtersets.py
  16. 1 0
      netbox/dcim/tables/devices.py
  17. 35 166
      netbox/dcim/tests/test_filtersets.py
  18. 82 80
      netbox/dcim/views.py
  19. 12 12
      netbox/extras/api/views.py
  20. 10 358
      netbox/extras/filters.py
  21. 341 0
      netbox/extras/filtersets.py
  22. 1 1
      netbox/extras/tests/test_customfields.py
  23. 26 41
      netbox/extras/tests/test_filtersets.py
  24. 8 8
      netbox/extras/views.py
  25. 11 11
      netbox/ipam/api/views.py
  26. 14 14
      netbox/ipam/filtersets.py
  27. 12 51
      netbox/ipam/tests/test_filtersets.py
  28. 39 31
      netbox/ipam/views.py
  29. 3 0
      netbox/netbox/configuration.example.py
  30. 6 6
      netbox/netbox/constants.py
  31. 238 0
      netbox/netbox/filtersets.py
  32. 1 0
      netbox/netbox/settings.py
  33. 3 3
      netbox/secrets/api/views.py
  34. 4 4
      netbox/secrets/filtersets.py
  35. 4 11
      netbox/secrets/tests/test_filtersets.py
  36. 6 6
      netbox/secrets/views.py
  37. 9 0
      netbox/templates/ipam/vlangroup.html
  38. 3 3
      netbox/tenancy/api/views.py
  39. 5 4
      netbox/tenancy/filtersets.py
  40. 4 11
      netbox/tenancy/tests/test_filtersets.py
  41. 5 5
      netbox/tenancy/views.py
  42. 4 4
      netbox/users/api/views.py
  43. 1 1
      netbox/users/filtersets.py
  44. 5 16
      netbox/users/tests/test_filtersets.py
  45. 2 204
      netbox/utilities/filters.py
  46. 1 0
      netbox/utilities/testing/__init__.py
  47. 35 0
      netbox/utilities/testing/filtersets.py
  48. 5 3
      netbox/utilities/tests/test_filters.py
  49. 6 6
      netbox/virtualization/api/views.py
  50. 10 17
      netbox/virtualization/filtersets.py
  51. 3 6
      netbox/virtualization/forms.py
  52. 7 27
      netbox/virtualization/tests/test_filtersets.py
  53. 11 11
      netbox/virtualization/views.py

+ 22 - 17
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -5,21 +5,25 @@ labels: ["type: bug"]
 body:
   - type: markdown
     attributes:
-      value: "**NOTE:** This form is only for reporting _reproducible bugs_ in a
-      current NetBox installation. If you're having trouble with installation or just
-      looking for assistance with using NetBox, please visit our
-      [discussion forum](https://github.com/netbox-community/netbox/discussions) instead."
+      value: >
+        **NOTE:** This form is only for reporting _reproducible bugs_ in a current NetBox
+        installation. If you're having trouble with installation or just looking for
+        assistance with using NetBox, please visit our
+        [discussion forum](https://github.com/netbox-community/netbox/discussions) instead.
   - type: input
     attributes:
       label: NetBox version
-      description: "What version of NetBox are you currently running?"
-      placeholder: v2.10.4
+      description: >
+        What version of NetBox are you currently running? (If you don't have access to the most
+        recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
+        before opening a bug report to see if your issue has already been addressed.)
+      placeholder: v2.11.2
     validations:
       required: true
   - type: dropdown
     attributes:
       label: Python version
-      description: "What version of Python are you currently running?"
+      description: What version of Python are you currently running?
       options:
         - 3.6
         - 3.7
@@ -30,12 +34,13 @@ body:
   - type: textarea
     attributes:
       label: Steps to Reproduce
-      description: "Describe in detail the exact steps that someone else can take to
-      reproduce this bug using the current stable release of NetBox. Begin with the
-      creation of any necessary database objects and call out every operation being
-      performed explicitly. If reporting a bug in the REST API, be sure to reconstruct
-      the raw HTTP request(s) being made: Don't rely on a client  library such as
-      pynetbox."
+      description: >
+        Describe in detail the exact steps that someone else can take to
+        reproduce this bug using the current stable release of NetBox. Begin with the
+        creation of any necessary database objects and call out every operation being
+        performed explicitly. If reporting a bug in the REST API, be sure to reconstruct
+        the raw HTTP request(s) being made: Don't rely on a client  library such as
+        pynetbox."
       placeholder: |
         1. Click on "create widget"
         2. Set foo to 12 and bar to G
@@ -45,14 +50,14 @@ body:
   - type: textarea
     attributes:
       label: Expected Behavior
-      description: "What did you expect to happen?"
-      placeholder: "A new widget should have been created with the specified attributes"
+      description: What did you expect to happen?
+      placeholder: A new widget should have been created with the specified attributes
     validations:
       required: true
   - type: textarea
     attributes:
       label: Observed Behavior
-      description: "What happened instead?"
-      placeholder: "A TypeError exception was raised"
+      description: What happened instead?
+      placeholder: A TypeError exception was raised
     validations:
       required: true

+ 1 - 1
.github/ISSUE_TEMPLATE/documentation_change.yaml

@@ -30,6 +30,6 @@ body:
   - type: textarea
     attributes:
       label: Proposed Changes
-      description: "Describe the proposed changes and why they are necessary"
+      description: Describe the proposed changes and why they are necessary.
     validations:
       required: true

+ 19 - 15
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -5,14 +5,15 @@ labels: ["type: feature"]
 body:
   - type: markdown
     attributes:
-      value: "**NOTE:** This form is only for submitting well-formed proposals to extend or
-      modify NetBox in some way. If you're trying to solve a problem but can't figure out how,
-      or if you still need time to work on the details of a proposed new feature, please start
-      a [discussion](https://github.com/netbox-community/netbox/discussions) instead."
+      value: >
+        **NOTE:** This form is only for submitting well-formed proposals to extend or modify
+        NetBox in some way. If you're trying to solve a problem but can't figure out how, or if
+        you still need time to work on the details of a proposed new feature, please start a
+        [discussion](https://github.com/netbox-community/netbox/discussions) instead.
   - type: input
     attributes:
       label: NetBox version
-      description: "What version of NetBox are you currently running?"
+      description: What version of NetBox are you currently running?
       placeholder: v2.10.4
     validations:
       required: true
@@ -28,26 +29,29 @@ body:
   - type: textarea
     attributes:
       label: Proposed functionality
-      description: "Describe in detail the new feature or behavior you'd like to propose.
-        Include any specific changes to work flows, data models, or the user interface."
+      description: >
+        Describe in detail the new feature or behavior you'd like to propose. Include any specific
+        changes to work flows, data models, or the user interface.
     validations:
       required: true
   - type: textarea
     attributes:
       label: Use case
-      description: "Explain how adding this functionality would benefit NetBox users. What
-        need does it address?"
+      description: >
+        Explain how adding this functionality would benefit NetBox users. What need does it address?
     validations:
       required: true
   - type: textarea
     attributes:
       label: Database changes
-      description: "Note any changes to the database schema necessary to support the new
-        feature. For example, does the proposal require adding a new model or field? (Not
-        all new features require database changes.)"
+      description: >
+        Note any changes to the database schema necessary to support the new feature. For example,
+        does the proposal require adding a new model or field? (Not all new features require database
+        changes.)
   - type: textarea
     attributes:
       label: External dependencies
-      description: "List any new dependencies on external libraries or services that this
-        new feature would introduce. For example, does the proposal require the installation
-        of a new Python package? (Not all new features introduce new dependencies.)"
+      description: >
+        List any new dependencies on external libraries or services that this new feature would
+        introduce. For example, does the proposal require the installation of a new Python package?
+        (Not all new features introduce new dependencies.)

+ 7 - 5
.github/ISSUE_TEMPLATE/housekeeping.yaml

@@ -5,18 +5,20 @@ labels: ["type: housekeeping"]
 body:
   - type: markdown
     attributes:
-      value: "**NOTE:** This template is for use by maintainers only. Please do not submit
-      an issue using this template unless you have been specifically asked to do so."
+      value: >
+        **NOTE:** This template is for use by maintainers only. Please do not submit
+        an issue using this template unless you have been specifically asked to do so.
   - type: textarea
     attributes:
       label: Proposed Changes
-      description: "Describe in detail the new feature or behavior you'd like to propose.
-        Include any specific changes to work flows, data models, or the user interface."
+      description: >
+        Describe in detail the new feature or behavior you'd like to propose.
+        Include any specific changes to work flows, data models, or the user interface.
     validations:
       required: true
   - type: textarea
     attributes:
       label: Justification
-      description: "Please provide justification for the proposed change(s)."
+      description: Please provide justification for the proposed change(s).
     validations:
       required: true

+ 1 - 0
.github/workflows/stale.yml

@@ -20,6 +20,7 @@ jobs:
           days-before-stale: 45
           days-before-close: 15
           exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
+          operations-per-run: 100
           remove-stale-when-updated: false
           stale-issue-label: 'pending closure'
           stale-issue-message: >

+ 1 - 1
README.md

@@ -10,7 +10,7 @@ NetBox runs as a web application atop the [Django](https://www.djangoproject.com
 Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
 complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).
 
-The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/).
+The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/). A public demo instance is available at https://demo.netbox.dev.
 
 ### Discussion
 

+ 8 - 0
docs/configuration/optional-settings.md

@@ -515,6 +515,14 @@ The file path to the location where custom scripts will be kept. By default, thi
 
 ---
 
+## SESSION_COOKIE_NAME
+
+Default: `sessionid`
+
+The name used for the session cookie. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#session-cookie-name) for more detail.
+
+---
+
 ## SESSION_FILE_PATH
 
 Default: None

+ 19 - 0
docs/release-notes/version-2.11.md

@@ -1,5 +1,24 @@
 # NetBox v2.11
 
+## v2.11.3 (FUTURE)
+
+### Enhancements
+
+* [#6197](https://github.com/netbox-community/netbox/issues/6197) - Introduced `SESSION_COOKIE_NAME` config parameter
+* [#6318](https://github.com/netbox-community/netbox/issues/6318) - Add OM5 MMF cable type
+
+### Bug Fixes
+
+* [#6240](https://github.com/netbox-community/netbox/issues/6240) - Fix display of available VLAN ranges under VLAN group view
+* [#6308](https://github.com/netbox-community/netbox/issues/6308) - Fix linking of available VLANs in VLAN group view
+* [#6309](https://github.com/netbox-community/netbox/issues/6309) - Restrict parent VM interface assignment to the parent VM
+* [#6313](https://github.com/netbox-community/netbox/issues/6313) - Fix device type instance count under manufacturer view
+* [#6321](https://github.com/netbox-community/netbox/issues/6321) - Restore "add an IP" button under prefix IPs view
+* [#6333](https://github.com/netbox-community/netbox/issues/6333) - Fix filtering of circuit terminations by primary key
+* [#6339](https://github.com/netbox-community/netbox/issues/6339) - Improve ordering of interfaces when viewing virtual chassis master
+
+---
+
 ## v2.11.2 (2021-04-27)
 
 ### Enhancements

+ 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

+ 11 - 12
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',
@@ -233,7 +232,7 @@ class CircuitTerminationFilterSet(BaseFilterSet, CreatedUpdatedFilterSet, CableT
 
     class Meta:
         model = CircuitTermination
-        fields = ['term_side', 'port_speed', 'upstream_speed', 'xconnect_id']
+        fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id']
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 7 - 22
netbox/circuits/tests/test_filters.py → netbox/circuits/tests/test_filtersets.py

@@ -1,13 +1,14 @@
 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
+from utilities.testing import ChangeLoggedFilterSetTests
 
 
-class ProviderTestCase(TestCase):
+class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Provider.objects.all()
     filterset = ProviderFilterSet
 
@@ -61,10 +62,6 @@ class ProviderTestCase(TestCase):
             CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A'),
         ))
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Provider 1', 'Provider 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -103,7 +100,7 @@ class ProviderTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class CircuitTypeTestCase(TestCase):
+class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = CircuitType.objects.all()
     filterset = CircuitTypeFilterSet
 
@@ -116,10 +113,6 @@ class CircuitTypeTestCase(TestCase):
             CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
         ))
 
-    def test_id(self):
-        params = {'id': [self.queryset.first().pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-
     def test_name(self):
         params = {'name': ['Circuit Type 1']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -129,7 +122,7 @@ class CircuitTypeTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class CircuitTestCase(TestCase):
+class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Circuit.objects.all()
     filterset = CircuitFilterSet
 
@@ -213,10 +206,6 @@ class CircuitTestCase(TestCase):
         ))
         CircuitTermination.objects.bulk_create(circuit_terminations)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_cid(self):
         params = {'cid': ['Test Circuit 1', 'Test Circuit 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -288,7 +277,7 @@ class CircuitTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
-class CircuitTerminationTestCase(TestCase):
+class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = CircuitTermination.objects.all()
     filterset = CircuitTerminationFilterSet
 
@@ -382,7 +371,7 @@ class CircuitTerminationTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class ProviderNetworkTestCase(TestCase):
+class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ProviderNetwork.objects.all()
     filterset = ProviderNetworkFilterSet
 
@@ -403,10 +392,6 @@ class ProviderNetworkTestCase(TestCase):
         )
         ProviderNetwork.objects.bulk_create(provider_networks)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Provider Network 1', 'Provider Network 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 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
 
 
 #

+ 2 - 0
netbox/dcim/choices.py

@@ -1001,6 +1001,7 @@ class CableTypeChoices(ChoiceSet):
     TYPE_MMF_OM2 = 'mmf-om2'
     TYPE_MMF_OM3 = 'mmf-om3'
     TYPE_MMF_OM4 = 'mmf-om4'
+    TYPE_MMF_OM5 = 'mmf-om5'
     TYPE_SMF = 'smf'
     TYPE_SMF_OS1 = 'smf-os1'
     TYPE_SMF_OS2 = 'smf-os2'
@@ -1031,6 +1032,7 @@ class CableTypeChoices(ChoiceSet):
                 (TYPE_MMF_OM2, 'Multimode Fiber (OM2)'),
                 (TYPE_MMF_OM3, 'Multimode Fiber (OM3)'),
                 (TYPE_MMF_OM4, 'Multimode Fiber (OM4)'),
+                (TYPE_MMF_OM5, 'Multimode Fiber (OM5)'),
                 (TYPE_SMF, 'Singlemode Fiber'),
                 (TYPE_SMF_OS1, 'Singlemode Fiber (OS1)'),
                 (TYPE_SMF_OS2, 'Singlemode Fiber (OS2)'),

+ 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 - 0
netbox/dcim/tables/devices.py

@@ -520,6 +520,7 @@ class DeviceInterfaceTable(InterfaceTable):
             'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses',
             'untagged_vlan', 'tagged_vlans', 'actions',
         )
+        order_by = ('name',)
         default_columns = (
             'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
             'cable', 'connection', 'actions',

+ 35 - 166
netbox/dcim/tests/test_filters.py → netbox/dcim/tests/test_filtersets.py

@@ -2,14 +2,15 @@ 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
+from utilities.testing import ChangeLoggedFilterSetTests
 from virtualization.models import Cluster, ClusterType
 
 
-class RegionTestCase(TestCase):
+class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Region.objects.all()
     filterset = RegionFilterSet
 
@@ -35,10 +36,6 @@ class RegionTestCase(TestCase):
         for region in child_regions:
             region.save()
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Region 1', 'Region 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -59,7 +56,7 @@ class RegionTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
-class SiteGroupTestCase(TestCase):
+class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = SiteGroup.objects.all()
     filterset = SiteGroupFilterSet
 
@@ -85,10 +82,6 @@ class SiteGroupTestCase(TestCase):
         for sitegroup in child_sitegroups:
             sitegroup.save()
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Site Group 1', 'Site Group 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -109,7 +102,7 @@ class SiteGroupTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
-class SiteTestCase(TestCase):
+class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Site.objects.all()
     filterset = SiteFilterSet
 
@@ -154,10 +147,6 @@ class SiteTestCase(TestCase):
         )
         Site.objects.bulk_create(sites)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Site 1', 'Site 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -227,7 +216,7 @@ class SiteTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class LocationTestCase(TestCase):
+class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Location.objects.all()
     filterset = LocationFilterSet
 
@@ -273,10 +262,6 @@ class LocationTestCase(TestCase):
         for location in locations:
             location.save()
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Location 1', 'Location 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -318,7 +303,7 @@ class LocationTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class RackRoleTestCase(TestCase):
+class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RackRole.objects.all()
     filterset = RackRoleFilterSet
 
@@ -332,10 +317,6 @@ class RackRoleTestCase(TestCase):
         )
         RackRole.objects.bulk_create(rack_roles)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Rack Role 1', 'Rack Role 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -349,7 +330,7 @@ class RackRoleTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class RackTestCase(TestCase):
+class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Rack.objects.all()
     filterset = RackFilterSet
 
@@ -416,10 +397,6 @@ class RackTestCase(TestCase):
         )
         Rack.objects.bulk_create(racks)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Rack 1', 'Rack 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -523,7 +500,7 @@ class RackTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class RackReservationTestCase(TestCase):
+class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RackReservation.objects.all()
     filterset = RackReservationFilterSet
 
@@ -581,10 +558,6 @@ class RackReservationTestCase(TestCase):
         )
         RackReservation.objects.bulk_create(reservations)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_site(self):
         sites = Site.objects.all()[:2]
         params = {'site_id': [sites[0].pk, sites[1].pk]}
@@ -621,7 +594,7 @@ class RackReservationTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class ManufacturerTestCase(TestCase):
+class ManufacturerTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Manufacturer.objects.all()
     filterset = ManufacturerFilterSet
 
@@ -635,10 +608,6 @@ class ManufacturerTestCase(TestCase):
         )
         Manufacturer.objects.bulk_create(manufacturers)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Manufacturer 1', 'Manufacturer 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -652,7 +621,7 @@ class ManufacturerTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class DeviceTypeTestCase(TestCase):
+class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = DeviceType.objects.all()
     filterset = DeviceTypeFilterSet
 
@@ -708,10 +677,6 @@ class DeviceTypeTestCase(TestCase):
             DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2'),
         ))
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_model(self):
         params = {'model': ['Model 1', 'Model 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -788,7 +753,7 @@ class DeviceTypeTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class ConsolePortTemplateTestCase(TestCase):
+class ConsolePortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConsolePortTemplate.objects.all()
     filterset = ConsolePortTemplateFilterSet
 
@@ -810,10 +775,6 @@ class ConsolePortTemplateTestCase(TestCase):
             ConsolePortTemplate(device_type=device_types[2], name='Console Port 3'),
         ))
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Console Port 1', 'Console Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -824,7 +785,7 @@ class ConsolePortTemplateTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class ConsoleServerPortTemplateTestCase(TestCase):
+class ConsoleServerPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConsoleServerPortTemplate.objects.all()
     filterset = ConsoleServerPortTemplateFilterSet
 
@@ -846,10 +807,6 @@ class ConsoleServerPortTemplateTestCase(TestCase):
             ConsoleServerPortTemplate(device_type=device_types[2], name='Console Server Port 3'),
         ))
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Console Server Port 1', 'Console Server Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -860,7 +817,7 @@ class ConsoleServerPortTemplateTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class PowerPortTemplateTestCase(TestCase):
+class PowerPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = PowerPortTemplate.objects.all()
     filterset = PowerPortTemplateFilterSet
 
@@ -882,10 +839,6 @@ class PowerPortTemplateTestCase(TestCase):
             PowerPortTemplate(device_type=device_types[2], name='Power Port 3', maximum_draw=300, allocated_draw=150),
         ))
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Power Port 1', 'Power Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -904,7 +857,7 @@ class PowerPortTemplateTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class PowerOutletTemplateTestCase(TestCase):
+class PowerOutletTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = PowerOutletTemplate.objects.all()
     filterset = PowerOutletTemplateFilterSet
 
@@ -926,10 +879,6 @@ class PowerOutletTemplateTestCase(TestCase):
             PowerOutletTemplate(device_type=device_types[2], name='Power Outlet 3', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C),
         ))
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Power Outlet 1', 'Power Outlet 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -944,7 +893,7 @@ class PowerOutletTemplateTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class InterfaceTemplateTestCase(TestCase):
+class InterfaceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = InterfaceTemplate.objects.all()
     filterset = InterfaceTemplateFilterSet
 
@@ -966,10 +915,6 @@ class InterfaceTemplateTestCase(TestCase):
             InterfaceTemplate(device_type=device_types[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_SFP, mgmt_only=False),
         ))
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Interface 1', 'Interface 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -990,7 +935,7 @@ class InterfaceTemplateTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class FrontPortTemplateTestCase(TestCase):
+class FrontPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = FrontPortTemplate.objects.all()
     filterset = FrontPortTemplateFilterSet
 
@@ -1019,10 +964,6 @@ class FrontPortTemplateTestCase(TestCase):
             FrontPortTemplate(device_type=device_types[2], name='Front Port 3', rear_port=rear_ports[2], type=PortTypeChoices.TYPE_BNC),
         ))
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Front Port 1', 'Front Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1037,7 +978,7 @@ class FrontPortTemplateTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class RearPortTemplateTestCase(TestCase):
+class RearPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RearPortTemplate.objects.all()
     filterset = RearPortTemplateFilterSet
 
@@ -1059,10 +1000,6 @@ class RearPortTemplateTestCase(TestCase):
             RearPortTemplate(device_type=device_types[2], name='Rear Port 3', type=PortTypeChoices.TYPE_BNC, positions=3),
         ))
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Rear Port 1', 'Rear Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1081,7 +1018,7 @@ class RearPortTemplateTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class DeviceBayTemplateTestCase(TestCase):
+class DeviceBayTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = DeviceBayTemplate.objects.all()
     filterset = DeviceBayTemplateFilterSet
 
@@ -1103,10 +1040,6 @@ class DeviceBayTemplateTestCase(TestCase):
             DeviceBayTemplate(device_type=device_types[2], name='Device Bay 3'),
         ))
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Device Bay 1', 'Device Bay 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1117,7 +1050,7 @@ class DeviceBayTemplateTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class DeviceRoleTestCase(TestCase):
+class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = DeviceRole.objects.all()
     filterset = DeviceRoleFilterSet
 
@@ -1131,10 +1064,6 @@ class DeviceRoleTestCase(TestCase):
         )
         DeviceRole.objects.bulk_create(device_roles)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Device Role 1', 'Device Role 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1154,7 +1083,7 @@ class DeviceRoleTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class PlatformTestCase(TestCase):
+class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Platform.objects.all()
     filterset = PlatformFilterSet
 
@@ -1175,10 +1104,6 @@ class PlatformTestCase(TestCase):
         )
         Platform.objects.bulk_create(platforms)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Platform 1', 'Platform 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1203,7 +1128,7 @@ class PlatformTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class DeviceTestCase(TestCase):
+class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Device.objects.all()
     filterset = DeviceFilterSet
 
@@ -1356,10 +1281,6 @@ class DeviceTestCase(TestCase):
         Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
         Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Device 1', 'Device 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1549,7 +1470,7 @@ class DeviceTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class ConsolePortTestCase(TestCase):
+class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConsolePort.objects.all()
     filterset = ConsolePortFilterSet
 
@@ -1608,10 +1529,6 @@ class ConsolePortTestCase(TestCase):
         Cable(termination_a=console_ports[1], termination_b=console_server_ports[1]).save()
         # Third port is not connected
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Console Port 1', 'Console Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1665,7 +1582,7 @@ class ConsolePortTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class ConsoleServerPortTestCase(TestCase):
+class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConsoleServerPort.objects.all()
     filterset = ConsoleServerPortFilterSet
 
@@ -1724,10 +1641,6 @@ class ConsoleServerPortTestCase(TestCase):
         Cable(termination_a=console_server_ports[1], termination_b=console_ports[1]).save()
         # Third port is not connected
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Console Server Port 1', 'Console Server Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1781,7 +1694,7 @@ class ConsoleServerPortTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class PowerPortTestCase(TestCase):
+class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = PowerPort.objects.all()
     filterset = PowerPortFilterSet
 
@@ -1840,10 +1753,6 @@ class PowerPortTestCase(TestCase):
         Cable(termination_a=power_ports[1], termination_b=power_outlets[1]).save()
         # Third port is not connected
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Power Port 1', 'Power Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1905,7 +1814,7 @@ class PowerPortTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class PowerOutletTestCase(TestCase):
+class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = PowerOutlet.objects.all()
     filterset = PowerOutletFilterSet
 
@@ -1964,10 +1873,6 @@ class PowerOutletTestCase(TestCase):
         Cable(termination_a=power_outlets[1], termination_b=power_ports[1]).save()
         # Third port is not connected
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Power Outlet 1', 'Power Outlet 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2025,7 +1930,7 @@ class PowerOutletTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class InterfaceTestCase(TestCase):
+class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Interface.objects.all()
     filterset = InterfaceFilterSet
 
@@ -2081,10 +1986,6 @@ class InterfaceTestCase(TestCase):
         Cable(termination_a=interfaces[1], termination_b=interfaces[4]).save()
         # Third pair is not connected
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Interface 1', 'Interface 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2200,7 +2101,7 @@ class InterfaceTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class FrontPortTestCase(TestCase):
+class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = FrontPort.objects.all()
     filterset = FrontPortFilterSet
 
@@ -2266,10 +2167,6 @@ class FrontPortTestCase(TestCase):
         Cable(termination_a=front_ports[1], termination_b=front_ports[4]).save()
         # Third port is not connected
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Front Port 1', 'Front Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2321,7 +2218,7 @@ class FrontPortTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class RearPortTestCase(TestCase):
+class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RearPort.objects.all()
     filterset = RearPortFilterSet
 
@@ -2377,10 +2274,6 @@ class RearPortTestCase(TestCase):
         Cable(termination_a=rear_ports[1], termination_b=rear_ports[4]).save()
         # Third port is not connected
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Rear Port 1', 'Rear Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2436,7 +2329,7 @@ class RearPortTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class DeviceBayTestCase(TestCase):
+class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = DeviceBay.objects.all()
     filterset = DeviceBayFilterSet
 
@@ -2483,10 +2376,6 @@ class DeviceBayTestCase(TestCase):
         )
         DeviceBay.objects.bulk_create(device_bays)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Device Bay 1', 'Device Bay 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2528,7 +2417,7 @@ class DeviceBayTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class InventoryItemTestCase(TestCase):
+class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = InventoryItem.objects.all()
     filterset = InventoryItemFilterSet
 
@@ -2591,10 +2480,6 @@ class InventoryItemTestCase(TestCase):
         for i in child_inventory_items:
             i.save()
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Inventory Item 1', 'Inventory Item 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2666,7 +2551,7 @@ class InventoryItemTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class VirtualChassisTestCase(TestCase):
+class VirtualChassisTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VirtualChassis.objects.all()
     filterset = VirtualChassisFilterSet
 
@@ -2721,10 +2606,6 @@ class VirtualChassisTestCase(TestCase):
         Device.objects.filter(pk=devices[3].pk).update(virtual_chassis=virtual_chassis[1])
         Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[2])
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_domain(self):
         params = {'domain': ['Domain 1', 'Domain 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2762,7 +2643,7 @@ class VirtualChassisTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class CableTestCase(TestCase):
+class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Cable.objects.all()
     filterset = CableFilterSet
 
@@ -2827,10 +2708,6 @@ class CableTestCase(TestCase):
         Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
         Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_label(self):
         params = {'label': ['Cable 1', 'Cable 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2886,7 +2763,7 @@ class CableTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
-class PowerPanelTestCase(TestCase):
+class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = PowerPanel.objects.all()
     filterset = PowerPanelFilterSet
 
@@ -2931,10 +2808,6 @@ class PowerPanelTestCase(TestCase):
         )
         PowerPanel.objects.bulk_create(power_panels)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Power Panel 1', 'Power Panel 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2966,7 +2839,7 @@ class PowerPanelTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class PowerFeedTestCase(TestCase):
+class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = PowerFeed.objects.all()
     filterset = PowerFeedFilterSet
 
@@ -3029,10 +2902,6 @@ class PowerFeedTestCase(TestCase):
         Cable(termination_a=power_feeds[0], termination_b=power_ports[0]).save()
         Cable(termination_a=power_feeds[1], termination_b=power_ports[1]).save()
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Power Feed 1', 'Power Feed 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 82 - 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
 
 
@@ -692,6 +692,8 @@ class ManufacturerView(generic.ObjectView):
     def get_extra_context(self, request, instance):
         devicetypes = DeviceType.objects.restrict(request.user, 'view').filter(
             manufacturer=instance
+        ).annotate(
+            instance_count=count_related(Device, 'device_type')
         )
 
         devicetypes_table = tables.DeviceTypeTable(devicetypes)
@@ -722,7 +724,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 +744,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 +850,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 +859,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 +1192,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 +1251,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 +1267,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 +1602,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 +1619,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 +1654,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 +1669,7 @@ class ConsolePortBulkDisconnectView(BulkDisconnectView):
 
 class ConsolePortBulkDeleteView(generic.BulkDeleteView):
     queryset = ConsolePort.objects.all()
-    filterset = filters.ConsolePortFilterSet
+    filterset = filtersets.ConsolePortFilterSet
     table = tables.ConsolePortTable
 
 
@@ -1677,7 +1679,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 +1714,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 +1729,7 @@ class ConsoleServerPortBulkDisconnectView(BulkDisconnectView):
 
 class ConsoleServerPortBulkDeleteView(generic.BulkDeleteView):
     queryset = ConsoleServerPort.objects.all()
-    filterset = filters.ConsoleServerPortFilterSet
+    filterset = filtersets.ConsoleServerPortFilterSet
     table = tables.ConsoleServerPortTable
 
 
@@ -1737,7 +1739,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 +1774,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 +1789,7 @@ class PowerPortBulkDisconnectView(BulkDisconnectView):
 
 class PowerPortBulkDeleteView(generic.BulkDeleteView):
     queryset = PowerPort.objects.all()
-    filterset = filters.PowerPortFilterSet
+    filterset = filtersets.PowerPortFilterSet
     table = tables.PowerPortTable
 
 
@@ -1797,7 +1799,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 +1834,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 +1849,7 @@ class PowerOutletBulkDisconnectView(BulkDisconnectView):
 
 class PowerOutletBulkDeleteView(generic.BulkDeleteView):
     queryset = PowerOutlet.objects.all()
-    filterset = filters.PowerOutletFilterSet
+    filterset = filtersets.PowerOutletFilterSet
     table = tables.PowerOutletTable
 
 
@@ -1857,7 +1859,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 +1929,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 +1944,7 @@ class InterfaceBulkDisconnectView(BulkDisconnectView):
 
 class InterfaceBulkDeleteView(generic.BulkDeleteView):
     queryset = Interface.objects.all()
-    filterset = filters.InterfaceFilterSet
+    filterset = filtersets.InterfaceFilterSet
     table = tables.InterfaceTable
 
 
@@ -1952,7 +1954,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 +1989,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 +2004,7 @@ class FrontPortBulkDisconnectView(BulkDisconnectView):
 
 class FrontPortBulkDeleteView(generic.BulkDeleteView):
     queryset = FrontPort.objects.all()
-    filterset = filters.FrontPortFilterSet
+    filterset = filtersets.FrontPortFilterSet
     table = tables.FrontPortTable
 
 
@@ -2012,7 +2014,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 +2049,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 +2064,7 @@ class RearPortBulkDisconnectView(BulkDisconnectView):
 
 class RearPortBulkDeleteView(generic.BulkDeleteView):
     queryset = RearPort.objects.all()
-    filterset = filters.RearPortFilterSet
+    filterset = filtersets.RearPortFilterSet
     table = tables.RearPortTable
 
 
@@ -2072,7 +2074,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 +2174,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 +2185,7 @@ class DeviceBayBulkRenameView(generic.BulkRenameView):
 
 class DeviceBayBulkDeleteView(generic.BulkDeleteView):
     queryset = DeviceBay.objects.all()
-    filterset = filters.DeviceBayFilterSet
+    filterset = filtersets.DeviceBayFilterSet
     table = tables.DeviceBayTable
 
 
@@ -2193,7 +2195,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 +2229,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 +2254,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 +2265,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 +2276,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 +2287,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 +2298,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 +2309,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 +2320,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 +2331,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 +2342,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 +2353,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 +2486,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 +2503,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 +2533,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 +2567,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 +2606,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 +2814,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 +2835,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 +2875,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 +2886,7 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
     ).annotate(
         powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
-    filterset = filters.PowerPanelFilterSet
+    filterset = filtersets.PowerPanelFilterSet
     table = tables.PowerPanelTable
 
 
@@ -2894,7 +2896,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 +2922,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 +2933,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)

+ 341 - 0
netbox/extras/filtersets.py

@@ -0,0 +1,341 @@
+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):
+    created = django_filters.DateTimeFilter()
+    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 *

+ 26 - 41
netbox/extras/tests/test_filters.py → netbox/extras/tests/test_filtersets.py

@@ -1,4 +1,5 @@
 import uuid
+from datetime import datetime, timezone
 
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
@@ -6,14 +7,15 @@ 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
+from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
-class WebhookTestCase(TestCase):
+class WebhookTestCase(TestCase, BaseFilterSetTests):
     queryset = Webhook.objects.all()
     filterset = WebhookFilterSet
 
@@ -52,10 +54,6 @@ class WebhookTestCase(TestCase):
         webhooks[1].content_types.add(content_types[1])
         webhooks[2].content_types.add(content_types[2])
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Webhook 1', 'Webhook 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -89,7 +87,7 @@ class WebhookTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class CustomLinkTestCase(TestCase):
+class CustomLinkTestCase(TestCase, BaseFilterSetTests):
     queryset = CustomLink.objects.all()
     filterset = CustomLinkFilterSet
 
@@ -125,10 +123,6 @@ class CustomLinkTestCase(TestCase):
         )
         CustomLink.objects.bulk_create(custom_links)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Custom Link 1', 'Custom Link 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -148,7 +142,7 @@ class CustomLinkTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class ExportTemplateTestCase(TestCase):
+class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
     queryset = ExportTemplate.objects.all()
     filterset = ExportTemplateFilterSet
 
@@ -164,10 +158,6 @@ class ExportTemplateTestCase(TestCase):
         )
         ExportTemplate.objects.bulk_create(export_templates)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Export Template 1', 'Export Template 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -177,7 +167,7 @@ class ExportTemplateTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class ImageAttachmentTestCase(TestCase):
+class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
     queryset = ImageAttachment.objects.all()
     filterset = ImageAttachmentFilterSet
 
@@ -235,10 +225,6 @@ class ImageAttachmentTestCase(TestCase):
         )
         ImageAttachment.objects.bulk_create(image_attachments)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Image Attachment 1', 'Image Attachment 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -254,8 +240,14 @@ class ImageAttachmentTestCase(TestCase):
         }
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
+    def test_created(self):
+        pk_list = self.queryset.values_list('pk', flat=True)[:2]
+        self.queryset.filter(pk__in=pk_list).update(created=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc))
+        params = {'created': '2021-01-01T00:00:00'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-class JournalEntryTestCase(TestCase):
+
+class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = JournalEntry.objects.all()
     filterset = JournalEntryFilterSet
 
@@ -320,10 +312,6 @@ class JournalEntryTestCase(TestCase):
         )
         JournalEntry.objects.bulk_create(journal_entries)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_created_by(self):
         users = User.objects.filter(username__in=['Alice', 'Bob'])
         params = {'created_by': [users[0].username, users[1].username]}
@@ -348,8 +336,17 @@ class JournalEntryTestCase(TestCase):
         params = {'kind': [JournalEntryKindChoices.KIND_INFO, JournalEntryKindChoices.KIND_SUCCESS]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_created(self):
+        pk_list = self.queryset.values_list('pk', flat=True)[:2]
+        self.queryset.filter(pk__in=pk_list).update(created=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc))
+        params = {
+            'created_after': '2020-12-31T00:00:00',
+            'created_before': '2021-01-02T00:00:00',
+        }
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
-class ConfigContextTestCase(TestCase):
+class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConfigContext.objects.all()
     filterset = ConfigContextFilterSet
 
@@ -449,10 +446,6 @@ class ConfigContextTestCase(TestCase):
             c.tenant_groups.set([tenant_groups[i]])
             c.tenants.set([tenants[i]])
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Config Context 1', 'Config Context 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -530,7 +523,7 @@ class ConfigContextTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class TagTestCase(TestCase):
+class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Tag.objects.all()
     filterset = TagFilterSet
 
@@ -544,10 +537,6 @@ class TagTestCase(TestCase):
         )
         Tag.objects.bulk_create(tags)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Tag 1', 'Tag 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -561,7 +550,7 @@ class TagTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class ObjectChangeTestCase(TestCase):
+class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
     queryset = ObjectChange.objects.all()
     filterset = ObjectChangeFilterSet
 
@@ -635,10 +624,6 @@ class ObjectChangeTestCase(TestCase):
         )
         ObjectChange.objects.bulk_create(object_changes)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:3]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
-
     def test_user(self):
         params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)

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

+ 12 - 51
netbox/ipam/tests/test_filters.py → netbox/ipam/tests/test_filtersets.py

@@ -2,13 +2,14 @@ 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 utilities.testing import ChangeLoggedFilterSetTests
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from tenancy.models import Tenant, TenantGroup
 
 
-class VRFTestCase(TestCase):
+class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VRF.objects.all()
     filterset = VRFFilterSet
 
@@ -53,10 +54,6 @@ class VRFTestCase(TestCase):
         vrfs[2].import_targets.add(route_targets[2])
         vrfs[2].export_targets.add(route_targets[2])
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['VRF 1', 'VRF 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -100,7 +97,7 @@ class VRFTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
-class RouteTargetTestCase(TestCase):
+class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RouteTarget.objects.all()
     filterset = RouteTargetFilterSet
 
@@ -149,10 +146,6 @@ class RouteTargetTestCase(TestCase):
         vrfs[1].import_targets.add(route_targets[4], route_targets[5])
         vrfs[1].export_targets.add(route_targets[6], route_targets[7])
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['65000:1001', '65000:1002', '65000:1003']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
@@ -186,7 +179,7 @@ class RouteTargetTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
 
 
-class RIRTestCase(TestCase):
+class RIRTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RIR.objects.all()
     filterset = RIRFilterSet
 
@@ -203,10 +196,6 @@ class RIRTestCase(TestCase):
         )
         RIR.objects.bulk_create(rirs)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['RIR 1', 'RIR 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -226,7 +215,7 @@ class RIRTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
-class AggregateTestCase(TestCase):
+class AggregateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Aggregate.objects.all()
     filterset = AggregateFilterSet
 
@@ -265,10 +254,6 @@ class AggregateTestCase(TestCase):
         )
         Aggregate.objects.bulk_create(aggregates)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_family(self):
         params = {'family': '4'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
@@ -304,7 +289,7 @@ class AggregateTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
-class RoleTestCase(TestCase):
+class RoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Role.objects.all()
     filterset = RoleFilterSet
 
@@ -318,10 +303,6 @@ class RoleTestCase(TestCase):
         )
         Role.objects.bulk_create(roles)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Role 1', 'Role 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -331,7 +312,7 @@ class RoleTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class PrefixTestCase(TestCase):
+class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Prefix.objects.all()
     filterset = PrefixFilterSet
 
@@ -421,10 +402,6 @@ class PrefixTestCase(TestCase):
         )
         Prefix.objects.bulk_create(prefixes)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_family(self):
         params = {'family': '6'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
@@ -528,7 +505,7 @@ class PrefixTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
-class IPAddressTestCase(TestCase):
+class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = IPAddress.objects.all()
     filterset = IPAddressFilterSet
 
@@ -607,10 +584,6 @@ class IPAddressTestCase(TestCase):
         )
         IPAddress.objects.bulk_create(ipaddresses)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_family(self):
         params = {'family': '6'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
@@ -708,7 +681,7 @@ class IPAddressTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
-class VLANGroupTestCase(TestCase):
+class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VLANGroup.objects.all()
     filterset = VLANGroupFilterSet
 
@@ -751,10 +724,6 @@ class VLANGroupTestCase(TestCase):
         )
         VLANGroup.objects.bulk_create(vlan_groups)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['VLAN Group 1', 'VLAN Group 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -796,7 +765,7 @@ class VLANGroupTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class VLANTestCase(TestCase):
+class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VLAN.objects.all()
     filterset = VLANFilterSet
 
@@ -965,10 +934,6 @@ class VLANTestCase(TestCase):
         )
         VLAN.objects.bulk_create(vlans)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['VLAN 101', 'VLAN 102']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1041,7 +1006,7 @@ class VLANTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)  # 5 scoped + 1 global
 
 
-class ServiceTestCase(TestCase):
+class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Service.objects.all()
     filterset = ServiceFilterSet
 
@@ -1080,10 +1045,6 @@ class ServiceTestCase(TestCase):
         )
         Service.objects.bulk_create(services)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:3]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
-
     def test_name(self):
         params = {'name': ['Service 1', 'Service 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 39 - 31
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
 
@@ -673,7 +673,7 @@ class VLANGroupView(generic.ObjectView):
     def get_extra_context(self, request, instance):
         vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related(
             Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user))
-        )
+        ).order_by('vid')
         vlans_count = vlans.count()
         vlans = add_available_vlans(instance, vlans)
 
@@ -684,9 +684,17 @@ class VLANGroupView(generic.ObjectView):
         vlans_table.columns.hide('group')
         paginate_table(vlans_table, request)
 
+        # Compile permissions list for rendering the object table
+        permissions = {
+            'add': request.user.has_perm('ipam.add_vlan'),
+            'change': request.user.has_perm('ipam.change_vlan'),
+            'delete': request.user.has_perm('ipam.delete_vlan'),
+        }
+
         return {
             'vlans_count': vlans_count,
             'vlans_table': vlans_table,
+            'permissions': permissions,
         }
 
 
@@ -710,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
 
@@ -719,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
 
 
@@ -729,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
 
@@ -797,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
 
 
@@ -814,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')
@@ -855,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

+ 3 - 0
netbox/netbox/configuration.example.py

@@ -246,6 +246,9 @@ RQ_DEFAULT_TIMEOUT = 300
 # this setting is derived from the installed location.
 # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
 
+# The name to use for the session cookie.
+SESSION_COOKIE_NAME = 'sessionid'
+
 # By default, NetBox will store session data in the database. Alternatively, a file path can be specified here to use
 # local file storage instead. (This can be useful for enabling authentication on a standby instance with read-only
 # database access.) Note that the user as which NetBox runs must have read and write permissions to this path.

+ 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)
+        )

+ 1 - 0
netbox/netbox/settings.py

@@ -114,6 +114,7 @@ REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 're
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
 SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
+SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
 SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
 SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
 SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')

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

+ 4 - 11
netbox/secrets/tests/test_filters.py → netbox/secrets/tests/test_filtersets.py

@@ -1,12 +1,13 @@
 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 utilities.testing import ChangeLoggedFilterSetTests
 from virtualization.models import Cluster, ClusterType, VirtualMachine
 
 
-class SecretRoleTestCase(TestCase):
+class SecretRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = SecretRole.objects.all()
     filterset = SecretRoleFilterSet
 
@@ -20,10 +21,6 @@ class SecretRoleTestCase(TestCase):
         )
         SecretRole.objects.bulk_create(roles)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Secret Role 1', 'Secret Role 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -33,7 +30,7 @@ class SecretRoleTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class SecretTestCase(TestCase):
+class SecretTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Secret.objects.all()
     filterset = SecretFilterSet
 
@@ -80,10 +77,6 @@ class SecretTestCase(TestCase):
         for s in secrets:
             s.save()
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Secret 1', 'Secret 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 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 = ('add', '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

+ 9 - 0
netbox/templates/ipam/vlangroup.html

@@ -10,6 +10,15 @@
   <li class="breadcrumb-item">{{ object }}</li>
 {% endblock %}
 
+{% block buttons %}
+  {% if perms.ipam.add_vlan %}
+    <a href="{% url 'ipam:vlan_add' %}?group={{ object.pk }}" class="btn btn-success">
+      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add VLAN
+    </a>
+  {% endif %}
+  {{ block.super }}
+{% endblock %}
+
 {% block content %}
 <div class="row mb-3">
 	<div class="col col-md-6">

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

+ 4 - 11
netbox/tenancy/tests/test_filters.py → netbox/tenancy/tests/test_filtersets.py

@@ -1,10 +1,11 @@
 from django.test import TestCase
 
-from tenancy.filters import *
+from tenancy.filtersets import *
 from tenancy.models import Tenant, TenantGroup
+from utilities.testing import ChangeLoggedFilterSetTests
 
 
-class TenantGroupTestCase(TestCase):
+class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = TenantGroup.objects.all()
     filterset = TenantGroupFilterSet
 
@@ -27,10 +28,6 @@ class TenantGroupTestCase(TestCase):
         for tenantgroup in tenant_groups:
             tenantgroup.save()
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Tenant Group 1', 'Tenant Group 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -51,7 +48,7 @@ class TenantGroupTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class TenantTestCase(TestCase):
+class TenantTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Tenant.objects.all()
     filterset = TenantFilterSet
 
@@ -73,10 +70,6 @@ class TenantTestCase(TestCase):
         )
         Tenant.objects.bulk_create(tenants)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Tenant 1', 'Tenant 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

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

+ 5 - 16
netbox/users/tests/test_filters.py → netbox/users/tests/test_filtersets.py

@@ -2,11 +2,12 @@ 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
+from utilities.testing import BaseFilterSetTests
 
 
-class UserTestCase(TestCase):
+class UserTestCase(TestCase, BaseFilterSetTests):
     queryset = User.objects.all()
     filterset = UserFilterSet
 
@@ -59,10 +60,6 @@ class UserTestCase(TestCase):
         users[1].groups.set([groups[1]])
         users[2].groups.set([groups[2]])
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_username(self):
         params = {'username': ['User1', 'User2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -95,7 +92,7 @@ class UserTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class GroupTestCase(TestCase):
+class GroupTestCase(TestCase, BaseFilterSetTests):
     queryset = Group.objects.all()
     filterset = GroupFilterSet
 
@@ -109,16 +106,12 @@ class GroupTestCase(TestCase):
         )
         Group.objects.bulk_create(groups)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Group 1', 'Group 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class ObjectPermissionTestCase(TestCase):
+class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
     queryset = ObjectPermission.objects.all()
     filterset = ObjectPermissionFilterSet
 
@@ -160,10 +153,6 @@ class ObjectPermissionTestCase(TestCase):
             permissions[i].users.set([users[i]])
             permissions[i].object_types.set([object_types[i]])
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Permission 1', 'Permission 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 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)
-        )

+ 1 - 0
netbox/utilities/testing/__init__.py

@@ -1,4 +1,5 @@
 from .api import *
 from .base import *
+from .filtersets import *
 from .utils import *
 from .views import *

+ 35 - 0
netbox/utilities/testing/filtersets.py

@@ -0,0 +1,35 @@
+from datetime import date, datetime, timezone
+
+
+__all__ = (
+    'BaseFilterSetTests',
+    'ChangeLoggedFilterSetTests',
+)
+
+
+class BaseFilterSetTests:
+    queryset = None
+    filterset = None
+
+    def test_id(self):
+        """
+        Test filtering for two PKs from a set of >2 objects.
+        """
+        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
+        self.assertGreater(self.queryset.count(), 2)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class ChangeLoggedFilterSetTests(BaseFilterSetTests):
+
+    def test_created(self):
+        pk_list = self.queryset.values_list('pk', flat=True)[:2]
+        self.queryset.filter(pk__in=pk_list).update(created=date(2021, 1, 1))
+        params = {'created': '2021-01-01'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_last_updated(self):
+        pk_list = self.queryset.values_list('pk', flat=True)[:2]
+        self.queryset.filter(pk__in=pk_list).update(last_updated=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc))
+        params = {'last_updated': '2021-01-01T00:00:00'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

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

+ 3 - 6
netbox/virtualization/forms.py

@@ -646,7 +646,7 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
         vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
 
         # Restrict parent interface assignment by VM
-        self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
+        self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id)
 
         # Limit VLAN choices by virtual machine
         self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
@@ -668,7 +668,7 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
         queryset=VMInterface.objects.all(),
         required=False,
         query_params={
-            'virtualmachine_id': 'virtual_machine',
+            'virtual_machine_id': '$virtual_machine',
         }
     )
     mtu = forms.IntegerField(
@@ -711,9 +711,6 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
         super().__init__(*args, **kwargs)
         vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
 
-        # Restrict parent interface assignment by VM
-        self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
-
         # Limit VLAN choices by virtual machine
         self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
         self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
@@ -796,7 +793,7 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
             vm_id = self.initial.get('virtual_machine')
 
             # Restrict parent interface assignment by VM
-            self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
+            self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id)
 
             # Limit VLAN choices by virtual machine
             self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)

+ 7 - 27
netbox/virtualization/tests/test_filters.py → netbox/virtualization/tests/test_filtersets.py

@@ -3,12 +3,13 @@ from django.test import TestCase
 from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
 from ipam.models import IPAddress
 from tenancy.models import Tenant, TenantGroup
+from utilities.testing import ChangeLoggedFilterSetTests
 from virtualization.choices import *
-from virtualization.filters import *
+from virtualization.filtersets import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 
-class ClusterTypeTestCase(TestCase):
+class ClusterTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ClusterType.objects.all()
     filterset = ClusterTypeFilterSet
 
@@ -22,10 +23,6 @@ class ClusterTypeTestCase(TestCase):
         )
         ClusterType.objects.bulk_create(cluster_types)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Cluster Type 1', 'Cluster Type 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -39,7 +36,7 @@ class ClusterTypeTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class ClusterGroupTestCase(TestCase):
+class ClusterGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ClusterGroup.objects.all()
     filterset = ClusterGroupFilterSet
 
@@ -53,10 +50,6 @@ class ClusterGroupTestCase(TestCase):
         )
         ClusterGroup.objects.bulk_create(cluster_groups)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Cluster Group 1', 'Cluster Group 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -70,7 +63,7 @@ class ClusterGroupTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class ClusterTestCase(TestCase):
+class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Cluster.objects.all()
     filterset = ClusterFilterSet
 
@@ -136,10 +129,6 @@ class ClusterTestCase(TestCase):
         )
         Cluster.objects.bulk_create(clusters)
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Cluster 1', 'Cluster 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -194,7 +183,7 @@ class ClusterTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class VirtualMachineTestCase(TestCase):
+class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VirtualMachine.objects.all()
     filterset = VirtualMachineFilterSet
 
@@ -297,10 +286,6 @@ class VirtualMachineTestCase(TestCase):
         VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0])
         VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1])
 
-    def test_id(self):
-        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -409,7 +394,7 @@ class VirtualMachineTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class VMInterfaceTestCase(TestCase):
+class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VMInterface.objects.all()
     filterset = VMInterfaceFilterSet
 
@@ -444,11 +429,6 @@ class VMInterfaceTestCase(TestCase):
         )
         VMInterface.objects.bulk_create(interfaces)
 
-    def test_id(self):
-        id_list = self.queryset.values_list('id', flat=True)[:2]
-        params = {'id': [str(id) for id in id_list]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_name(self):
         params = {'name': ['Interface 1', 'Interface 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 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):