Explorar o código

Merge branch 'feature' into feature-2434

checktheroads %!s(int64=4) %!d(string=hai) anos
pai
achega
515aed7022
Modificáronse 100 ficheiros con 1539 adicións e 1322 borrados
  1. 22 17
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/documentation_change.yaml
  3. 20 16
      .github/ISSUE_TEMPLATE/feature_request.yaml
  4. 7 5
      .github/ISSUE_TEMPLATE/housekeeping.yaml
  5. 3 2
      .github/workflows/stale.yml
  6. 30 27
      README.md
  7. 8 0
      docs/configuration/optional-settings.md
  8. 5 1
      docs/development/release-checklist.md
  9. 27 0
      docs/release-notes/version-2.11.md
  10. 15 0
      docs/release-notes/version-2.12.md
  11. 1 1
      netbox/circuits/api/nested_serializers.py
  12. 6 6
      netbox/circuits/api/views.py
  13. 11 12
      netbox/circuits/filtersets.py
  14. 1 1
      netbox/circuits/models.py
  15. 4 7
      netbox/circuits/signals.py
  16. 7 22
      netbox/circuits/tests/test_filtersets.py
  17. 23 40
      netbox/circuits/views.py
  18. 3 3
      netbox/dcim/api/nested_serializers.py
  19. 14 15
      netbox/dcim/api/serializers.py
  20. 37 37
      netbox/dcim/api/views.py
  21. 2 0
      netbox/dcim/choices.py
  22. 3 2
      netbox/dcim/elevations.py
  23. 46 60
      netbox/dcim/filtersets.py
  24. 1 1
      netbox/dcim/forms.py
  25. 14 25
      netbox/dcim/models/devices.py
  26. 4 8
      netbox/dcim/models/racks.py
  27. 3 3
      netbox/dcim/models/sites.py
  28. 1 0
      netbox/dcim/tables/devices.py
  29. 3 3
      netbox/dcim/tests/test_api.py
  30. 35 166
      netbox/dcim/tests/test_filtersets.py
  31. 84 82
      netbox/dcim/views.py
  32. 1 6
      netbox/extras/api/serializers.py
  33. 12 12
      netbox/extras/api/views.py
  34. 10 358
      netbox/extras/filters.py
  35. 341 0
      netbox/extras/filtersets.py
  36. 1 1
      netbox/extras/tests/test_customfields.py
  37. 26 41
      netbox/extras/tests/test_filtersets.py
  38. 8 8
      netbox/extras/views.py
  39. 2 2
      netbox/ipam/api/nested_serializers.py
  40. 2 3
      netbox/ipam/api/serializers.py
  41. 11 11
      netbox/ipam/api/views.py
  42. 14 14
      netbox/ipam/filtersets.py
  43. 5 13
      netbox/ipam/models/ip.py
  44. 2 6
      netbox/ipam/models/vlans.py
  45. 3 7
      netbox/ipam/models/vrfs.py
  46. 2 0
      netbox/ipam/querysets.py
  47. 2 2
      netbox/ipam/tests/test_api.py
  48. 12 51
      netbox/ipam/tests/test_filtersets.py
  49. 39 31
      netbox/ipam/views.py
  50. 3 0
      netbox/netbox/configuration.example.py
  51. 6 6
      netbox/netbox/constants.py
  52. 238 0
      netbox/netbox/filtersets.py
  53. 1 0
      netbox/netbox/settings.py
  54. 6 5
      netbox/netbox/views/__init__.py
  55. 10 0
      netbox/project-static/_dark.scss
  56. 2 1
      netbox/project-static/_elevations.scss
  57. 4 0
      netbox/project-static/_external.scss
  58. 6 0
      netbox/project-static/_light.scss
  59. 4 2
      netbox/project-static/bundle.js
  60. 0 0
      netbox/project-static/dist/netbox-dark.css
  61. 0 0
      netbox/project-static/dist/netbox-dark.css.map
  62. 0 0
      netbox/project-static/dist/netbox-external.css
  63. 0 0
      netbox/project-static/dist/netbox-external.css.map
  64. 0 0
      netbox/project-static/dist/netbox-light.css
  65. 0 0
      netbox/project-static/dist/netbox-light.css.map
  66. 0 0
      netbox/project-static/dist/netbox.css
  67. 0 0
      netbox/project-static/dist/netbox.css.map
  68. 0 0
      netbox/project-static/dist/netbox.js
  69. 0 0
      netbox/project-static/dist/netbox.js.map
  70. 0 0
      netbox/project-static/dist/rack_elevation.css.map
  71. 0 27
      netbox/project-static/main.scss
  72. 135 26
      netbox/project-static/netbox.scss
  73. 0 1
      netbox/project-static/package.json
  74. 8 0
      netbox/project-static/select.scss
  75. 14 0
      netbox/project-static/src/buttons.ts
  76. 35 31
      netbox/project-static/src/search.ts
  77. 1 1
      netbox/project-static/src/select/api.ts
  78. 34 1
      netbox/project-static/theme-base.scss
  79. 10 6
      netbox/project-static/theme-dark.scss
  80. 7 0
      netbox/project-static/theme-light.scss
  81. 3 3
      netbox/secrets/api/views.py
  82. 4 4
      netbox/secrets/filtersets.py
  83. 1 1
      netbox/secrets/models.py
  84. 4 11
      netbox/secrets/tests/test_filtersets.py
  85. 6 6
      netbox/secrets/views.py
  86. 1 1
      netbox/templates/500.html
  87. 13 3
      netbox/templates/base.html
  88. 0 8
      netbox/templates/bottom.html
  89. 11 11
      netbox/templates/circuits/circuit.html
  90. 3 3
      netbox/templates/circuits/circuittype.html
  91. 6 6
      netbox/templates/circuits/provider.html
  92. 4 4
      netbox/templates/circuits/providernetwork.html
  93. 3 3
      netbox/templates/dcim/cable.html
  94. 5 5
      netbox/templates/dcim/cable_connect.html
  95. 2 2
      netbox/templates/dcim/cable_trace.html
  96. 2 2
      netbox/templates/dcim/connections_list.html
  97. 3 3
      netbox/templates/dcim/consoleport.html
  98. 3 3
      netbox/templates/dcim/consoleserverport.html
  99. 6 6
      netbox/templates/dcim/device.html
  100. 1 1
      netbox/templates/dcim/device/config.html

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

+ 20 - 16
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -5,15 +5,16 @@ 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?"
-      placeholder: v2.10.4
+      description: What version of NetBox are you currently running?
+      placeholder: v2.11.3
     validations:
       required: true
   - type: dropdown
@@ -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

+ 3 - 2
.github/workflows/stale.yml

@@ -17,9 +17,10 @@ jobs:
             necessary.
           close-pr-message: >
             This PR has been automatically closed due to lack of activity.
-          days-before-stale: 45
-          days-before-close: 15
+          days-before-stale: 60
+          days-before-close: 30
           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: >

+ 30 - 27
README.md

@@ -1,4 +1,6 @@
-![NetBox](docs/netbox_logo.svg "NetBox logo")
+<div align="center">
+  <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
+</div>
 
 NetBox is an IP address management (IPAM) and data center infrastructure
 management (DCIM) tool. Initially conceived by the network engineering team at
@@ -10,45 +12,36 @@ 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
-
-* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
-* [Slack](https://slack.netbox.dev/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
-* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being replaced by GitHub discussions
-
-### Build Status
-
-|             | status                                                                                            |
-| ----------- | ------------------------------------------------------------------------------------------------- |
-| **master**  | ![Build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)  |
+|             | status |
+|-------------|------------|
+| **master**  | ![Build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) |
 | **develop** | ![Build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=develop) |
 
-### Screenshots
+<div align="center">
+  <h4>Thank you to our sponsors!</h4>
 
-![Screenshot of Main Page](docs/media/home-light.png "Main Page")
-
----
-
-![Screenshot of Rack Elevation](docs/media/rack-dark.png "Rack Elevation")
+  [![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com/)
+  &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+  [![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech/)
 
----
+</div>
 
-![Screenshot of Prefix Hierarchy](docs/media/prefixes-light.png "Prefix Hierarchy")
-
----
+### Discussion
 
-![Screenshot of Cable Tracing](docs/media/cable-dark.png "Cable Tracing")
+* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
+* [Slack](https://slack.netbox.dev/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
+* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being replaced by GitHub discussions
 
-## Installation
+### Installation
 
 Please see [the documentation](https://netbox.readthedocs.io/en/stable/) for
 instructions on installing NetBox. To upgrade NetBox, please download the
 [latest release](https://github.com/netbox-community/netbox/releases) and
 run `upgrade.sh`.
 
-## Providing Feedback
+### Providing Feedback
 
 The best platform for general feedback, assistance, and other discussion is our
 [GitHub discussions](https://github.com/netbox-community/netbox/discussions).
@@ -58,7 +51,17 @@ the [appropriate template](https://github.com/netbox-community/netbox/issues/new
 If you are interested in contributing to the development of NetBox, please read
 our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
 
-## Related projects
+### Screenshots
+
+![Screenshot of Main Page](docs/media/home-light.png "Main Page")
+
+![Screenshot of Rack Elevation](docs/media/rack-dark.png "Rack Elevation")
+
+![Screenshot of Prefix Hierarchy](docs/media/prefixes-light.png "Prefix Hierarchy")
+
+![Screenshot of Cable Tracing](docs/media/cable-dark.png "Cable Tracing")
+
+### Related projects
 
 Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions)
 for a list of relevant community projects.

+ 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

+ 5 - 1
docs/development/release-checklist.md

@@ -70,7 +70,11 @@ Ensure that continuous integration testing on the `develop` branch is completing
 
 ### Update Version and Changelog
 
-Update the `VERSION` constant in `settings.py` to the new release version and annotate the current data in the release notes for the new version. Commit these changes to the `develop` branch.
+* Update the `VERSION` constant in `settings.py` to the new release version.
+* Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`.
+* Replace the "FUTURE" placeholder in the release notes with the current date.
+
+Commit these changes to the `develop` branch.
 
 ### Submit a Pull Request
 

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

@@ -1,5 +1,32 @@
 # NetBox v2.11
 
+## v2.11.3 (2021-05-07)
+
+### 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
+* [#6351](https://github.com/netbox-community/netbox/issues/6351) - Add aggregates count to tenant view
+* [#6359](https://github.com/netbox-community/netbox/issues/6359) - Enable custom links for organizational and nested group models
+
+### 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
+* [#6312](https://github.com/netbox-community/netbox/issues/6312) - Interface device filter should return all virtual chassis interfaces only if device is master
+* [#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
+* [#6350](https://github.com/netbox-community/netbox/issues/6350) - Include first & last IP addresses when allocating available IPv6 addresses via the REST API
+* [#6355](https://github.com/netbox-community/netbox/issues/6355) - Fix caching error when swapping A/Z circuit terminations
+* [#6357](https://github.com/netbox-community/netbox/issues/6357) - Fix ProviderNetwork nested API serializer
+* [#6363](https://github.com/netbox-community/netbox/issues/6363) - Correct pre-population of cluster group when creating a cluster
+* [#6369](https://github.com/netbox-community/netbox/issues/6369) - Fix interface assignment for VLANs in non-scoped groups
+
+---
+
 ## v2.11.2 (2021-04-27)
 
 ### Enhancements

+ 15 - 0
docs/release-notes/version-2.12.md

@@ -6,3 +6,18 @@
 
 * [#5532](https://github.com/netbox-community/netbox/issues/5532) - Drop support for Python 3.6
 * [#5994](https://github.com/netbox-community/netbox/issues/5994) - Drop support for `display_field` argument on ObjectVar
+
+### REST API Changes
+
+* dcim.Device
+    * Removed the `display_name` attribute (use `display` instead)
+* dcim.DeviceType
+    * Removed the `display_name` attribute (use `display` instead)
+* dcim.Rack
+    * Removed the `display_name` attribute (use `display` instead)
+* extras.ContentType
+    * Removed the `display_name` attribute (use `display` instead)
+* ipam.VLAN
+    * Removed the `display_name` attribute (use `display` instead)
+* ipam.VRF
+    * Removed the `display_name` attribute (use `display` instead)

+ 1 - 1
netbox/circuits/api/nested_serializers.py

@@ -20,7 +20,7 @@ class NestedProviderNetworkSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail')
 
     class Meta:
-        model = Provider
+        model = ProviderNetwork
         fields = ['id', 'url', 'display', 'name']
 
 

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

+ 1 - 1
netbox/circuits/models.py

@@ -149,7 +149,7 @@ class ProviderNetwork(PrimaryModel):
         )
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class CircuitType(OrganizationalModel):
     """
     Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named

+ 4 - 7
netbox/circuits/signals.py

@@ -1,9 +1,8 @@
 from django.db.models.signals import post_delete, post_save
 from django.dispatch import receiver
-from django.utils import timezone
 
 from dcim.signals import rebuild_paths
-from .models import Circuit, CircuitTermination
+from .models import CircuitTermination
 
 
 @receiver(post_save, sender=CircuitTermination)
@@ -11,11 +10,9 @@ def update_circuit(instance, **kwargs):
     """
     When a CircuitTermination has been modified, update its parent Circuit.
     """
-    fields = {
-        'last_updated': timezone.now(),
-        f'termination_{instance.term_side.lower()}': instance.pk,
-    }
-    Circuit.objects.filter(pk=instance.circuit_id).update(**fields)
+    termination_name = f'termination_{instance.term_side.lower()}'
+    setattr(instance.circuit, termination_name, instance)
+    instance.circuit.save()
 
 
 @receiver((post_save, post_delete), sender=CircuitTermination)

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

+ 23 - 40
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
 
@@ -211,27 +211,6 @@ class CircuitListView(generic.ObjectListView):
 class CircuitView(generic.ObjectView):
     queryset = Circuit.objects.all()
 
-    def get_extra_context(self, request, instance):
-
-        # A-side termination
-        termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
-            'site__region'
-        ).filter(
-            circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A
-        ).first()
-
-        # Z-side termination
-        termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
-            'site__region'
-        ).filter(
-            circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z
-        ).first()
-
-        return {
-            'termination_a': termination_a,
-            'termination_z': termination_z,
-        }
-
 
 class CircuitEditView(generic.ObjectEditView):
     queryset = Circuit.objects.all()
@@ -252,7 +231,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 +240,7 @@ class CircuitBulkDeleteView(generic.BulkDeleteView):
     queryset = Circuit.objects.prefetch_related(
         'provider', 'type', 'tenant', 'terminations'
     )
-    filterset = filters.CircuitFilterSet
+    filterset = filtersets.CircuitFilterSet
     table = tables.CircuitTable
 
 
@@ -296,16 +275,11 @@ class CircuitSwapTerminations(generic.ObjectEditView):
 
         if form.is_valid():
 
-            termination_a = CircuitTermination.objects.filter(
-                circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
-            ).first()
-            termination_z = CircuitTermination.objects.filter(
-                circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
-            ).first()
+            termination_a = CircuitTermination.objects.filter(pk=circuit.termination_a_id).first()
+            termination_z = CircuitTermination.objects.filter(pk=circuit.termination_z_id).first()
 
             if termination_a and termination_z:
                 # Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
-                print('swapping')
                 with transaction.atomic():
                     termination_a.term_side = '_'
                     termination_a.save()
@@ -316,11 +290,20 @@ class CircuitSwapTerminations(generic.ObjectEditView):
             elif termination_a:
                 termination_a.term_side = 'Z'
                 termination_a.save()
+                circuit.refresh_from_db()
+                circuit.termination_a = None
+                circuit.save()
             else:
                 termination_z.term_side = 'A'
                 termination_z.save()
+                circuit.refresh_from_db()
+                circuit.termination_z = None
+                circuit.save()
+
+            print(f'term A: {circuit.termination_a}')
+            print(f'term Z: {circuit.termination_z}')
 
-            messages.success(request, "Swapped terminations for circuit {}.".format(circuit))
+            messages.success(request, f"Swapped terminations for circuit {circuit}.")
             return redirect('circuits:circuit', pk=circuit.pk)
 
         return render(request, 'circuits/circuit_terminations_swap.html', {

+ 3 - 3
netbox/dcim/api/nested_serializers.py

@@ -101,7 +101,7 @@ class NestedRackSerializer(WritableNestedSerializer):
 
     class Meta:
         model = models.Rack
-        fields = ['id', 'url', 'display', 'name', 'display_name', 'device_count']
+        fields = ['id', 'url', 'display', 'name', 'device_count']
 
 
 class NestedRackReservationSerializer(WritableNestedSerializer):
@@ -136,7 +136,7 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer):
 
     class Meta:
         model = models.DeviceType
-        fields = ['id', 'url', 'display', 'manufacturer', 'model', 'slug', 'display_name', 'device_count']
+        fields = ['id', 'url', 'display', 'manufacturer', 'model', 'slug', 'device_count']
 
 
 class NestedConsolePortTemplateSerializer(WritableNestedSerializer):
@@ -232,7 +232,7 @@ class NestedDeviceSerializer(WritableNestedSerializer):
 
     class Meta:
         model = models.Device
-        fields = ['id', 'url', 'display', 'name', 'display_name']
+        fields = ['id', 'url', 'display', 'name']
 
 
 class NestedConsoleServerPortSerializer(WritableNestedSerializer):

+ 14 - 15
netbox/dcim/api/serializers.py

@@ -172,10 +172,9 @@ class RackSerializer(PrimaryModelSerializer):
     class Meta:
         model = Rack
         fields = [
-            'id', 'url', 'display', 'name', 'facility_id', 'display_name', 'site', 'location', 'tenant', 'status',
-            'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
-            'outer_unit', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
-            'powerfeed_count',
+            'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial',
+            'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
+            'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
         ]
         # Omit the UniqueTogetherValidator that would be automatically added to validate (location, facility_id). This
         # prevents facility_id from being interpreted as a required field.
@@ -284,9 +283,9 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
     class Meta:
         model = DeviceType
         fields = [
-            'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height',
-            'is_full_depth', 'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated', 'device_count',
+            'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
+            'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created',
+            'last_updated', 'device_count',
         ]
 
 
@@ -465,10 +464,10 @@ class DeviceSerializer(PrimaryModelSerializer):
     class Meta:
         model = Device
         fields = [
-            'id', 'url', 'display', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform',
-            'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status',
-            'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority',
-            'comments', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
+            'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
+            'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
+            'tags', 'custom_fields', 'created', 'last_updated',
         ]
         validators = []
 
@@ -501,10 +500,10 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
 
     class Meta(DeviceSerializer.Meta):
         fields = [
-            'id', 'url', 'display', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform',
-            'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status',
-            'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority',
-            'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
+            'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
+            'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
+            'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
+            'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
         ]
 
     @swagger_serializer_method(serializer_or_field=serializers.DictField)

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

+ 3 - 2
netbox/dcim/elevations.py

@@ -34,10 +34,11 @@ class RackElevationSVG:
 
     @staticmethod
     def _get_device_description(device):
-        return '{} ({}) — {} ({}U) {} {}'.format(
+        return '{} ({}) — {} {} ({}U) {} {}'.format(
             device.name,
             device.device_role,
-            device.device_type.display_name,
+            device.device_type.manufacturer.name,
+            device.device_type.model,
             device.device_type.u_height,
             device.asset_tag or '',
             device.serial or ''

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

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

+ 1 - 1
netbox/dcim/forms.py

@@ -2153,7 +2153,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
                 ip_choices = [(None, '---------')]
 
                 # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member
-                interface_ids = self.instance.vc_interfaces().values_list('pk', flat=True)
+                interface_ids = self.instance.vc_interfaces(if_master=False).values_list('pk', flat=True)
 
                 # Collect interface IPs
                 interface_ips = IPAddress.objects.filter(

+ 14 - 25
netbox/dcim/models/devices.py

@@ -36,7 +36,7 @@ __all__ = (
 # Device Types
 #
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Manufacturer(OrganizationalModel):
     """
     A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
@@ -320,10 +320,6 @@ class DeviceType(PrimaryModel):
         if self.rear_image:
             self.rear_image.delete(save=False)
 
-    @property
-    def display_name(self):
-        return f'{self.manufacturer.name} {self.model}'
-
     @property
     def is_parent_device(self):
         return self.subdevice_role == SubdeviceRoleChoices.ROLE_PARENT
@@ -337,7 +333,7 @@ class DeviceType(PrimaryModel):
 # Devices
 #
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class DeviceRole(OrganizationalModel):
     """
     Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
@@ -388,7 +384,7 @@ class DeviceRole(OrganizationalModel):
         )
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Platform(OrganizationalModel):
     """
     Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
@@ -622,7 +618,13 @@ class Device(PrimaryModel, ConfigContextModel):
         )
 
     def __str__(self):
-        return self.display_name or super().__str__()
+        if self.name:
+            return self.name
+        elif self.virtual_chassis:
+            return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
+        elif self.device_type:
+            return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
+        return super().__str__()
 
     def get_absolute_url(self):
         return reverse('dcim:device', args=[self.pk])
@@ -716,7 +718,7 @@ class Device(PrimaryModel, ConfigContextModel):
                 pass
 
         # Validate primary IP addresses
-        vc_interfaces = self.vc_interfaces()
+        vc_interfaces = self.vc_interfaces(if_master=False)
         if self.primary_ip4:
             if self.primary_ip4.family != 4:
                 raise ValidationError({
@@ -823,17 +825,6 @@ class Device(PrimaryModel, ConfigContextModel):
             self.comments,
         )
 
-    @property
-    def display_name(self):
-        if self.name:
-            return self.name
-        elif self.virtual_chassis:
-            return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
-        elif self.device_type:
-            return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
-        else:
-            return ''  # Device has not yet been created
-
     @property
     def identifier(self):
         """
@@ -856,9 +847,7 @@ class Device(PrimaryModel, ConfigContextModel):
 
     @property
     def interfaces_count(self):
-        if self.virtual_chassis and self.virtual_chassis.master == self:
-            return self.vc_interfaces().count()
-        return self.interfaces.count()
+        return self.vc_interfaces().count()
 
     def get_vc_master(self):
         """
@@ -866,7 +855,7 @@ class Device(PrimaryModel, ConfigContextModel):
         """
         return self.virtual_chassis.master if self.virtual_chassis else None
 
-    def vc_interfaces(self, if_master=False):
+    def vc_interfaces(self, if_master=True):
         """
         Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another
         Device belonging to the same VirtualChassis.
@@ -874,7 +863,7 @@ class Device(PrimaryModel, ConfigContextModel):
         :param if_master: If True, return VC member interfaces only if this Device is the VC master.
         """
         filter = Q(device=self)
-        if self.virtual_chassis and (not if_master or self.virtual_chassis.master == self):
+        if self.virtual_chassis and (self.virtual_chassis.master == self or not if_master):
             filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
         return Interface.objects.filter(filter)
 

+ 4 - 8
netbox/dcim/models/racks.py

@@ -35,7 +35,7 @@ __all__ = (
 # Racks
 #
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class RackRole(OrganizationalModel):
     """
     Racks can be organized by functional role, similar to Devices.
@@ -209,7 +209,9 @@ class Rack(PrimaryModel):
         )
 
     def __str__(self):
-        return self.display_name or super().__str__()
+        if self.facility_id:
+            return f'{self.name} ({self.facility_id})'
+        return self.name
 
     def get_absolute_url(self):
         return reverse('dcim:rack', args=[self.pk])
@@ -277,12 +279,6 @@ class Rack(PrimaryModel):
         else:
             return reversed(range(1, self.u_height + 1))
 
-    @property
-    def display_name(self):
-        if self.facility_id:
-            return f'{self.name} ({self.facility_id})'
-        return self.name
-
     def get_status_class(self):
         return RackStatusChoices.CSS_CLASSES.get(self.status)
 

+ 3 - 3
netbox/dcim/models/sites.py

@@ -26,7 +26,7 @@ __all__ = (
 # Regions
 #
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Region(NestedGroupModel):
     """
     A region represents a geographic collection of sites. For example, you might create regions representing countries,
@@ -78,7 +78,7 @@ class Region(NestedGroupModel):
 # Site groups
 #
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class SiteGroup(NestedGroupModel):
     """
     A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
@@ -285,7 +285,7 @@ class Site(PrimaryModel):
 # Locations
 #
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Location(NestedGroupModel):
     """
     A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a

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

+ 3 - 3
netbox/dcim/tests/test_api.py

@@ -251,7 +251,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
 
 class RackTest(APIViewTestCases.APIViewTestCase):
     model = Rack
-    brief_fields = ['device_count', 'display', 'display_name', 'id', 'name', 'url']
+    brief_fields = ['device_count', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'status': 'planned',
     }
@@ -422,7 +422,7 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
 
 class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
     model = DeviceType
-    brief_fields = ['device_count', 'display', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url']
+    brief_fields = ['device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url']
     bulk_update_data = {
         'part_number': 'ABC123',
     }
@@ -870,7 +870,7 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
 
 class DeviceTest(APIViewTestCases.APIViewTestCase):
     model = Device
-    brief_fields = ['display', 'display_name', 'id', 'name', 'url']
+    brief_fields = ['display', 'id', 'name', 'url']
     bulk_update_data = {
         'status': 'failed',
     }

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

+ 84 - 82
netbox/dcim/views.py

@@ -27,7 +27,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 (
@@ -110,7 +110,7 @@ class RegionListView(generic.ObjectListView):
         'site_count',
         cumulative=True
     )
-    filterset = filters.RegionFilterSet
+    filterset = filtersets.RegionFilterSet
     filterset_form = forms.RegionFilterForm
     table = tables.RegionTable
 
@@ -166,7 +166,7 @@ class RegionBulkEditView(generic.BulkEditView):
         'site_count',
         cumulative=True
     )
-    filterset = filters.RegionFilterSet
+    filterset = filtersets.RegionFilterSet
     table = tables.RegionTable
     form = forms.RegionBulkEditForm
 
@@ -179,7 +179,7 @@ class RegionBulkDeleteView(generic.BulkDeleteView):
         'site_count',
         cumulative=True
     )
-    filterset = filters.RegionFilterSet
+    filterset = filtersets.RegionFilterSet
     table = tables.RegionTable
 
 
@@ -195,7 +195,7 @@ class SiteGroupListView(generic.ObjectListView):
         'site_count',
         cumulative=True
     )
-    filterset = filters.SiteGroupFilterSet
+    filterset = filtersets.SiteGroupFilterSet
     filterset_form = forms.SiteGroupFilterForm
     table = tables.SiteGroupTable
 
@@ -251,7 +251,7 @@ class SiteGroupBulkEditView(generic.BulkEditView):
         'site_count',
         cumulative=True
     )
-    filterset = filters.SiteGroupFilterSet
+    filterset = filtersets.SiteGroupFilterSet
     table = tables.SiteGroupTable
     form = forms.SiteGroupBulkEditForm
 
@@ -264,7 +264,7 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
         'site_count',
         cumulative=True
     )
-    filterset = filters.SiteGroupFilterSet
+    filterset = filtersets.SiteGroupFilterSet
     table = tables.SiteGroupTable
 
 
@@ -274,7 +274,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
 
@@ -329,14 +329,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
 
 
@@ -358,7 +358,7 @@ class LocationListView(generic.ObjectListView):
         'rack_count',
         cumulative=True
     )
-    filterset = filters.LocationFilterSet
+    filterset = filtersets.LocationFilterSet
     filterset_form = forms.LocationFilterForm
     table = tables.LocationTable
 
@@ -417,7 +417,7 @@ class LocationBulkEditView(generic.BulkEditView):
         'rack_count',
         cumulative=True
     ).prefetch_related('site')
-    filterset = filters.LocationFilterSet
+    filterset = filtersets.LocationFilterSet
     table = tables.LocationTable
     form = forms.LocationBulkEditForm
 
@@ -430,7 +430,7 @@ class LocationBulkDeleteView(generic.BulkDeleteView):
         'rack_count',
         cumulative=True
     ).prefetch_related('site')
-    filterset = filters.LocationFilterSet
+    filterset = filtersets.LocationFilterSet
     table = tables.LocationTable
 
 
@@ -481,7 +481,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
 
@@ -503,7 +503,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
 
@@ -516,7 +516,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
@@ -605,14 +605,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
 
 
@@ -622,7 +622,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
 
@@ -665,14 +665,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
 
 
@@ -695,6 +695,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)
@@ -725,7 +727,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
 
@@ -745,7 +747,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
 
@@ -851,7 +853,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
 
@@ -860,7 +862,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
 
 
@@ -1193,7 +1195,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
 
@@ -1252,7 +1254,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
 
@@ -1268,7 +1270,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'
@@ -1408,7 +1410,7 @@ class DeviceInterfacesView(generic.ObjectView):
     template_name = 'dcim/device/interfaces.html'
 
     def get_extra_context(self, request, instance):
-        interfaces = instance.vc_interfaces(if_master=True).restrict(request.user, 'view').prefetch_related(
+        interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related(
             Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
             Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
             'lag', 'cable', '_path__destination', 'tags',
@@ -1530,7 +1532,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView):
     template_name = 'dcim/device/lldp_neighbors.html'
 
     def get_extra_context(self, request, instance):
-        interfaces = instance.vc_interfaces(if_master=True).restrict(request.user, 'view').prefetch_related(
+        interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related(
             '_path__destination'
         ).exclude(
             type__in=NONCONNECTABLE_IFACE_TYPES
@@ -1603,14 +1605,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
 
 
@@ -1620,7 +1622,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')
@@ -1655,7 +1657,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
 
@@ -1670,7 +1672,7 @@ class ConsolePortBulkDisconnectView(BulkDisconnectView):
 
 class ConsolePortBulkDeleteView(generic.BulkDeleteView):
     queryset = ConsolePort.objects.all()
-    filterset = filters.ConsolePortFilterSet
+    filterset = filtersets.ConsolePortFilterSet
     table = tables.ConsolePortTable
 
 
@@ -1680,7 +1682,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')
@@ -1715,7 +1717,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
 
@@ -1730,7 +1732,7 @@ class ConsoleServerPortBulkDisconnectView(BulkDisconnectView):
 
 class ConsoleServerPortBulkDeleteView(generic.BulkDeleteView):
     queryset = ConsoleServerPort.objects.all()
-    filterset = filters.ConsoleServerPortFilterSet
+    filterset = filtersets.ConsoleServerPortFilterSet
     table = tables.ConsoleServerPortTable
 
 
@@ -1740,7 +1742,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')
@@ -1775,7 +1777,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
 
@@ -1790,7 +1792,7 @@ class PowerPortBulkDisconnectView(BulkDisconnectView):
 
 class PowerPortBulkDeleteView(generic.BulkDeleteView):
     queryset = PowerPort.objects.all()
-    filterset = filters.PowerPortFilterSet
+    filterset = filtersets.PowerPortFilterSet
     table = tables.PowerPortTable
 
 
@@ -1800,7 +1802,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')
@@ -1835,7 +1837,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
 
@@ -1850,7 +1852,7 @@ class PowerOutletBulkDisconnectView(BulkDisconnectView):
 
 class PowerOutletBulkDeleteView(generic.BulkDeleteView):
     queryset = PowerOutlet.objects.all()
-    filterset = filters.PowerOutletFilterSet
+    filterset = filtersets.PowerOutletFilterSet
     table = tables.PowerOutletTable
 
 
@@ -1860,7 +1862,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')
@@ -1997,7 +1999,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
 
@@ -2012,7 +2014,7 @@ class InterfaceBulkDisconnectView(BulkDisconnectView):
 
 class InterfaceBulkDeleteView(generic.BulkDeleteView):
     queryset = Interface.objects.all()
-    filterset = filters.InterfaceFilterSet
+    filterset = filtersets.InterfaceFilterSet
     table = tables.InterfaceTable
 
 
@@ -2022,7 +2024,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')
@@ -2057,7 +2059,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
 
@@ -2072,7 +2074,7 @@ class FrontPortBulkDisconnectView(BulkDisconnectView):
 
 class FrontPortBulkDeleteView(generic.BulkDeleteView):
     queryset = FrontPort.objects.all()
-    filterset = filters.FrontPortFilterSet
+    filterset = filtersets.FrontPortFilterSet
     table = tables.FrontPortTable
 
 
@@ -2082,7 +2084,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')
@@ -2117,7 +2119,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
 
@@ -2132,7 +2134,7 @@ class RearPortBulkDisconnectView(BulkDisconnectView):
 
 class RearPortBulkDeleteView(generic.BulkDeleteView):
     queryset = RearPort.objects.all()
-    filterset = filters.RearPortFilterSet
+    filterset = filtersets.RearPortFilterSet
     table = tables.RearPortTable
 
 
@@ -2142,7 +2144,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')
@@ -2242,7 +2244,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
 
@@ -2253,7 +2255,7 @@ class DeviceBayBulkRenameView(generic.BulkRenameView):
 
 class DeviceBayBulkDeleteView(generic.BulkDeleteView):
     queryset = DeviceBay.objects.all()
-    filterset = filters.DeviceBayFilterSet
+    filterset = filtersets.DeviceBayFilterSet
     table = tables.DeviceBayTable
 
 
@@ -2263,7 +2265,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')
@@ -2297,7 +2299,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
 
@@ -2322,7 +2324,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'
 
@@ -2333,7 +2335,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'
 
@@ -2344,7 +2346,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'
 
@@ -2355,7 +2357,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'
 
@@ -2366,7 +2368,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'
 
@@ -2377,7 +2379,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'
 
@@ -2388,7 +2390,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'
 
@@ -2399,7 +2401,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'
 
@@ -2410,7 +2412,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'
 
@@ -2421,7 +2423,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')
@@ -2554,14 +2556,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
 
 
@@ -2571,7 +2573,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'
@@ -2601,7 +2603,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'
@@ -2635,7 +2637,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'
@@ -2674,7 +2676,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
 
 
@@ -2882,14 +2884,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
 
 
@@ -2903,7 +2905,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
 
@@ -2943,7 +2945,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
 
@@ -2954,7 +2956,7 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
     ).annotate(
         powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
-    filterset = filters.PowerPanelFilterSet
+    filterset = filtersets.PowerPanelFilterSet
     table = tables.PowerPanelTable
 
 
@@ -2964,7 +2966,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
 
@@ -2990,7 +2992,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
 
@@ -3001,5 +3003,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

+ 1 - 6
netbox/extras/api/serializers.py

@@ -453,12 +453,7 @@ class ObjectChangeSerializer(BaseModelSerializer):
 
 class ContentTypeSerializer(BaseModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:contenttype-detail')
-    display_name = serializers.SerializerMethodField()
 
     class Meta:
         model = ContentType
-        fields = ['id', 'url', 'display', 'app_label', 'model', 'display_name']
-
-    @swagger_serializer_method(serializer_or_field=serializers.CharField)
-    def get_display_name(self, obj):
-        return obj.app_labeled_name
+        fields = ['id', 'url', 'display', 'app_label', 'model']

+ 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
 
 

+ 2 - 2
netbox/ipam/api/nested_serializers.py

@@ -27,7 +27,7 @@ class NestedVRFSerializer(WritableNestedSerializer):
 
     class Meta:
         model = models.VRF
-        fields = ['id', 'url', 'display', 'name', 'rd', 'display_name', 'prefix_count']
+        fields = ['id', 'url', 'display', 'name', 'rd', 'prefix_count']
 
 
 #
@@ -92,7 +92,7 @@ class NestedVLANSerializer(WritableNestedSerializer):
 
     class Meta:
         model = models.VLAN
-        fields = ['id', 'url', 'display', 'vid', 'name', 'display_name']
+        fields = ['id', 'url', 'display', 'vid', 'name']
 
 
 #

+ 2 - 3
netbox/ipam/api/serializers.py

@@ -44,8 +44,7 @@ class VRFSerializer(PrimaryModelSerializer):
         model = VRF
         fields = [
             'id', 'url', 'display', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets',
-            'export_targets', 'tags', 'display_name', 'custom_fields', 'created', 'last_updated', 'ipaddress_count',
-            'prefix_count',
+            'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', 'prefix_count',
         ]
 
 
@@ -167,7 +166,7 @@ class VLANSerializer(PrimaryModelSerializer):
         model = VLAN
         fields = [
             'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags',
-            'display_name', 'custom_fields', 'created', 'last_updated', 'prefix_count',
+            'custom_fields', 'created', 'last_updated', 'prefix_count',
         ]
         validators = []
 

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

+ 5 - 13
netbox/ipam/models/ip.py

@@ -29,7 +29,7 @@ __all__ = (
 )
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class RIR(OrganizationalModel):
     """
     A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
@@ -184,7 +184,7 @@ class Aggregate(PrimaryModel):
         return int(float(child_prefixes.size) / self.prefix.size * 100)
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Role(OrganizationalModel):
     """
     A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
@@ -426,19 +426,11 @@ class Prefix(PrimaryModel):
         child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
         available_ips = prefix - child_ips
 
-        # All IP addresses within a pool are considered usable
-        if self.is_pool:
-            return available_ips
-
-        # All IP addresses within a point-to-point prefix (IPv4 /31 or IPv6 /127) are considered usable
-        if (
-            self.prefix.version == 4 and self.prefix.prefixlen == 31  # RFC 3021
-        ) or (
-            self.prefix.version == 6 and self.prefix.prefixlen == 127  # RFC 6164
-        ):
+        # IPv6, pool, or IPv4 /31 sets are fully usable
+        if self.family == 6 or self.is_pool or self.prefix.prefixlen == 31:
             return available_ips
 
-        # Omit first and last IP address from the available set
+        # For "normal" IPv4 prefixes, omit first and last addresses
         available_ips -= netaddr.IPSet([
             netaddr.IPAddress(self.prefix.first),
             netaddr.IPAddress(self.prefix.last),

+ 2 - 6
netbox/ipam/models/vlans.py

@@ -21,7 +21,7 @@ __all__ = (
 )
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class VLANGroup(OrganizationalModel):
     """
     A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
@@ -172,7 +172,7 @@ class VLAN(PrimaryModel):
         verbose_name_plural = 'VLANs'
 
     def __str__(self):
-        return self.display_name or super().__str__()
+        return f'{self.name} ({self.vid})'
 
     def get_absolute_url(self):
         return reverse('ipam:vlan', args=[self.pk])
@@ -199,10 +199,6 @@ class VLAN(PrimaryModel):
             self.description,
         )
 
-    @property
-    def display_name(self):
-        return f'{self.name} ({self.vid})'
-
     def get_status_class(self):
         return VLANStatusChoices.CSS_CLASSES.get(self.status)
 

+ 3 - 7
netbox/ipam/models/vrfs.py

@@ -71,7 +71,9 @@ class VRF(PrimaryModel):
         verbose_name_plural = 'VRFs'
 
     def __str__(self):
-        return self.display_name or super().__str__()
+        if self.rd:
+            return f'{self.name} ({self.rd})'
+        return self.name
 
     def get_absolute_url(self):
         return reverse('ipam:vrf', args=[self.pk])
@@ -85,12 +87,6 @@ class VRF(PrimaryModel):
             self.description,
         )
 
-    @property
-    def display_name(self):
-        if self.rd:
-            return f'{self.name} ({self.rd})'
-        return self.name
-
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class RouteTarget(PrimaryModel):

+ 2 - 0
netbox/ipam/querysets.py

@@ -64,6 +64,7 @@ class VLANQuerySet(RestrictedQuerySet):
         return self.filter(
             Q(group__in=VLANGroup.objects.filter(q)) |
             Q(site=device.site) |
+            Q(group__scope_id__isnull=True, site__isnull=True) |  # Global group VLANs
             Q(group__isnull=True, site__isnull=True)  # Global VLANs
         )
 
@@ -104,6 +105,7 @@ class VLANQuerySet(RestrictedQuerySet):
         # Return all applicable VLANs
         q = (
             Q(group__in=vlan_groups) |
+            Q(group__scope_id__isnull=True, site__isnull=True) |  # Global group VLANs
             Q(group__isnull=True, site__isnull=True)  # Global VLANs
         )
         if vm.cluster.site:

+ 2 - 2
netbox/ipam/tests/test_api.py

@@ -22,7 +22,7 @@ class AppTest(APITestCase):
 
 class VRFTest(APIViewTestCases.APIViewTestCase):
     model = VRF
-    brief_fields = ['display', 'display_name', 'id', 'name', 'prefix_count', 'rd', 'url']
+    brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url']
     create_data = [
         {
             'name': 'VRF 4',
@@ -421,7 +421,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
 
 class VLANTest(APIViewTestCases.APIViewTestCase):
     model = VLAN
-    brief_fields = ['display', 'display_name', 'id', 'name', 'url', 'vid']
+    brief_fields = ['display', 'id', 'name', 'url', 'vid']
     bulk_update_data = {
         'description': 'New description',
     }

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

+ 6 - 5
netbox/netbox/views/__init__.py

@@ -109,12 +109,13 @@ class HomeView(View):
             for section_label, section_items in sections:
                 stat = {"label": section_label, "items": []}
                 for perm, item_label, description, get_count in section_items:
+                    app, scope = perm.split(".")
+                    url = ":".join((app, scope.replace("view_", "") + "_list"))
+                    item = {"label": item_label, "description": description, "count": None, "url": url, "disabled": True}
                     if perm in perms:
-                        app, scope = perm.split(".")
-                        url = ":".join((app, scope.replace("view_", "") + "_list"))
-                        stat["items"].append(
-                            {"label": item_label, "description": description, "count": get_count(), "url": url}
-                        )
+                        item["count"] = get_count()
+                        item["disabled"] = False
+                    stat["items"].append(item)
                 stats.append(stat)
             return stats
 

+ 10 - 0
netbox/project-static/_dark.scss

@@ -0,0 +1,10 @@
+// Entry for netbox-dark.css stylesheet.
+
+body[data-netbox-color-mode='dark'] {
+  // Imports are scoped under the body when its data-netbox-color-mode attribute is set to 'dark'.
+  @import './theme-dark.scss';
+  @import './bootstrap.scss';
+  @import './select.scss';
+  @import './flatpickr-dark.scss';
+  @import './netbox.scss';
+}

+ 2 - 1
netbox/project-static/rack_elevation.scss → netbox/project-static/_elevations.scss

@@ -1,4 +1,5 @@
-// Stylesheet for rendering SVG rack elevations
+// Entry for rack_elevation.css stylesheet.
+
 @import './theme-light.scss';
 
 * {

+ 4 - 0
netbox/project-static/_external.scss

@@ -0,0 +1,4 @@
+// Entry for all 3rd party library imports that do not rely on Bootstrap or NetBox styles.
+
+@import '@mdi/font/css/materialdesignicons.min.css';
+@import 'flatpickr/dist/flatpickr.css';

+ 6 - 0
netbox/project-static/_light.scss

@@ -0,0 +1,6 @@
+// Entry for netbox-light.css stylesheet.
+
+@import './theme-light.scss';
+@import './bootstrap.scss';
+@import './select.scss';
+@import './netbox.scss';

+ 4 - 2
netbox/project-static/bundle.js

@@ -27,8 +27,10 @@ if (args.includes('--no-cache')) {
 // Style (SCSS) bundle jobs. Generally, everything should be bundled into netbox.css from main.scss
 // unless there is a specific reason to do otherwise.
 const styles = [
-  ['main.scss', 'netbox.css'],
-  ['rack_elevation.scss', 'rack_elevation.css'],
+  ['_external.scss', 'netbox-external.css'],
+  ['_light.scss', 'netbox-light.css'],
+  ['_dark.scss', 'netbox-dark.css'],
+  ['_elevations.scss', 'rack_elevation.css'],
 ];
 
 // Script (JavaScript) bundle jobs. Generally, everything should be bundled into netbox.js from

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox-dark.css.map


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox-external.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox-external.css.map


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox-light.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox-light.css.map


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.css.map


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.js.map


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/rack_elevation.css.map


+ 0 - 27
netbox/project-static/main.scss

@@ -1,27 +0,0 @@
-// Main Entry Point for all Netbox Styles.
-// Note: The order of these imports is critical for proper inheritance.
-
-// Light Mode Styles.
-@import './theme-light.scss';
-@import './bootstrap.scss';
-
-@import '@mdi/font/css/materialdesignicons.min.css';
-
-@import './select.scss';
-@import 'flatpickr/dist/flatpickr.css';
-
-@import './netbox.scss';
-
-// Dark Mode Styles.
-body[data-netbox-color-mode='dark'] {
-  @import './theme-dark.scss';
-  @import './bootstrap.scss';
-
-  @import '@mdi/font/css/materialdesignicons.min.css';
-
-  @import './select.scss';
-  @import 'flatpickr/dist/flatpickr.css';
-  @import './flatpickr-dark.scss';
-
-  @import './netbox.scss';
-}

+ 135 - 26
netbox/project-static/netbox.scss

@@ -11,7 +11,7 @@
   --nbx-sidebar-title-color: #{$text-muted};
   --nbx-breadcrumb-bg: #{$light};
   --nbx-body-bg: #{$white};
-  --nbx-body-color: #{$black};
+  --nbx-body-color: #{$gray-800};
   --nbx-pre-bg: #{$gray-100};
   --nbx-pre-border-color: #{$gray-600};
   --nbx-change-added: #{rgba($green, 0.4)};
@@ -20,6 +20,9 @@
   --nbx-cable-node-border-color: #{$gray-200};
   --nbx-cable-termination-bg: #{$gray-200};
   --nbx-cable-termination-border-color: #{$gray-300};
+  --nbx-search-filter-border-left-color: #{$gray-300};
+  --nbx-color-mode-toggle-color: #{$primary};
+  --nbx-stat-badge-bg: #{$gray-600};
 
   body[data-netbox-color-mode='dark'] {
     --nbx-logo-color-1: #{$white};
@@ -30,7 +33,7 @@
     --nbx-sidebar-title-color: #{$gray-300};
     --nbx-breadcrumb-bg: #{$gray-800};
     --nbx-body-bg: #{$gray-900};
-    --nbx-body-color: #{$white};
+    --nbx-body-color: #{$gray-50};
     --nbx-pre-bg: #{$gray-700};
     --nbx-pre-border-color: #{$gray-600};
     --nbx-change-added: #{rgba($green-300, 0.4)};
@@ -39,6 +42,9 @@
     --nbx-cable-node-border-color: #{$gray-600};
     --nbx-cable-termination-bg: #{$gray-800};
     --nbx-cable-termination-border-color: #{$gray-700};
+    --nbx-search-filter-border-left-color: #{$gray-600};
+    --nbx-color-mode-toggle-color: #{$yellow-300};
+    --nbx-stat-badge-bg: #{$gray-600};
   }
 }
 
@@ -46,6 +52,31 @@
   transition: background-color, color 0.15s ease-in-out;
 }
 
+.text-xs {
+  font-size: $font-size-xs;
+  line-height: $line-height-sm;
+}
+
+// Automatically space out adjacent columns.
+.col:not(:last-child):not(:only-child) {
+  margin-bottom: $spacer;
+}
+
+// Use proper contrasting color for badge & progress-bar foreground color.
+@each $color, $value in $theme-colors {
+  .badge,
+  .progress-bar {
+    &.bg-#{$color} {
+      color: color-contrast($value);
+    }
+  }
+}
+
+// Ensure progress bars (utilization graph) in tables aren't too narrow to display the percentage.
+table td > .progress {
+  min-width: 6rem;
+}
+
 body {
   background-color: var(--nbx-body-bg);
   color: var(--nbx-body-color);
@@ -58,9 +89,16 @@ body {
     fill: #1685fc;
     stroke: #1685fc;
   }
+  span.badge.stat-badge {
+    margin-left: map.get($spacers, 2);
+    background-color: var(--nbx-stat-badge-bg);
+  }
+
   &[data-netbox-color-mode='light'] {
-    .btn.btn-primary {
-      color: $white;
+    .btn.btn-primary,
+    .progress-bar.bg-primary,
+    .badge.bg-primary {
+      color: $gray-50;
     }
   }
   &[data-netbox-color-mode='dark'] {
@@ -92,6 +130,32 @@ body {
       stroke: $gray-200;
     }
   }
+  & table,
+  &[data-netbox-color-mode] table {
+    a {
+      text-decoration: none;
+      &:hover {
+        text-decoration: underline;
+      }
+    }
+    &.table > :not(caption) > * > * {
+      padding-left: $table-cell-padding-x-sm !important;
+      padding-right: $table-cell-padding-x-sm !important;
+    }
+    td,
+    th {
+      font-size: $font-size-xs;
+      line-height: $line-height-sm;
+      vertical-align: middle;
+    }
+    &.attr-table {
+      td,
+      th {
+        font-size: $font-size-sm;
+        line-height: $line-height-sm;
+      }
+    }
+  }
 }
 
 div.title-container {
@@ -113,17 +177,35 @@ div.title-container {
 
 nav.search {
   background-color: var(--nbx-body-bg);
+  form button.dropdown-toggle {
+    border-color: $input-border-color;
+    font-weight: $input-group-addon-font-weight;
+    line-height: $input-line-height;
+    color: $input-group-addon-color;
+    background-color: $input-group-addon-bg;
+    border: $input-border-width solid $input-group-addon-border-color;
+    @include border-radius($input-border-radius);
+    border-left: 1px solid var(--nbx-search-filter-border-left-color);
+    &:focus {
+      box-shadow: unset !important;
+    }
+  }
 }
 
 main.login-container {
   display: flex;
   height: calc(100vh - 4rem);
-  width: 100vw;
+  width: 100%;
+  max-width: 100vw;
   align-items: center;
   justify-content: center;
   flex-direction: column;
   padding-top: 40px;
   padding-bottom: 40px;
+
+  & + footer.footer button.color-mode-toggle {
+    color: var(--nbx-color-mode-toggle-color);
+  }
 }
 
 footer.login-footer {
@@ -161,7 +243,8 @@ h3.accordion-item-title,
 h4.accordion-item-title,
 h5.accordion-item-title,
 h6.accordion-item-title {
-  padding: 0 0.5rem;
+  // padding: 0 0.5rem;
+  padding: 0.25rem 0.5rem;
   font-weight: $font-weight-bold;
   text-transform: uppercase;
   color: var(--nbx-sidebar-title-color);
@@ -201,12 +284,6 @@ li.dropdown-item.dropdown-item-btns {
   align-items: center;
 }
 
-@media (max-width: 767.98px) {
-  .sidebar {
-    top: 5rem;
-  }
-}
-
 .sidebar-sticky {
   position: relative;
   top: 0;
@@ -225,15 +302,26 @@ li.dropdown-item.dropdown-item-btns {
 nav.nav.nav-pills {
   .nav-item.nav-link {
     padding: 0.25rem 0.5rem;
-    font-size: $font-size-base;
+    // font-size: $font-size-base;
+    font-size: $font-size-sm;
     border-radius: $border-radius;
     &:hover {
-      color: $body-color;
-      background-color: var(--nbx-sidebar-link-hover-bg);
+      // color: $body-color;
+      // background-color: var(--nbx-sidebar-link-hover-bg);
+      background-color: $accordion-button-active-bg;
+      color: $accordion-button-active-color;
     }
   }
 }
 
+// Prevent scrolling of body content when nav menu is open on mobile.
+.sidebar.collapse.show ~ .content-container {
+  @media (max-width: map.get($grid-breakpoints, 'md')) {
+    position: fixed;
+    overflow-y: hidden;
+  }
+}
+
 .sidebar {
   position: fixed;
   top: 0;
@@ -242,26 +330,51 @@ nav.nav.nav-pills {
   z-index: 100; /* Behind the navbar */
   box-shadow: inset -1px 0 0 rgba(0, 0, 0, 0.1);
   background-color: var(--nbx-sidebar-bg);
-  .sidebar-nav-link {
-    color: var(--nbx-sidebar-link-color);
+
+  @media (max-width: map.get($grid-breakpoints, 'md')) {
+    top: 8.125rem;
+  }
+
+  div.accordion-item > a.accordion-button.nav-link {
+    &:hover {
+      // color: $body-color;
+      // background-color: var(--nbx-sidebar-link-hover-bg);
+      color: $accordion-button-active-color;
+      background-color: $accordion-button-active-bg;
+    }
+    &:focus {
+      border-color: unset;
+      box-shadow: unset;
+    }
   }
+
   .accordion-body {
     max-height: calc(100vh - 24rem);
     overflow-y: auto;
     .nav-item {
       .nav-link {
-        padding: 0.25rem 0.5rem;
-        font-size: $font-size-base;
+        padding: 0.25rem 0.6rem;
+        // font-size: $font-size-base;
+        font-size: $font-size-sm;
         border-radius: $border-radius;
         &:hover {
-          color: $body-color;
-          background-color: var(--nbx-sidebar-link-hover-bg);
+          // color: $body-color;
+          // background-color: var(--nbx-sidebar-link-hover-bg);
+          color: $accordion-button-active-color;
+          background-color: $accordion-button-active-bg;
         }
       }
     }
   }
+  // Ensure navigation accounts for the height of the header on mobile when nav is expanded.
+  &.collapse.show div.position-sticky {
+    @media (max-width: map.get($grid-breakpoints, 'md')) {
+      height: calc(100vh - 16.125rem);
+      overflow-y: auto;
+    }
+  }
   div.position-sticky {
-    height: calc(100% - 8rem);
+    height: calc(100vh - 8rem);
   }
   div.sidebar-bottom {
     padding-left: 0.5rem;
@@ -499,10 +612,6 @@ table tbody {
     }
   }
 }
-table td,
-table th {
-  font-size: $font-size-sm;
-}
 
 // Cable Tracing
 .cable-trace {

+ 0 - 1
netbox/project-static/package.json

@@ -54,7 +54,6 @@
     "plugins": [
       "@babel/plugin-proposal-optional-chaining",
       "@babel/proposal-class-properties",
-      "@babel/proposal-object-rest-spread",
       "@babel/plugin-proposal-nullish-coalescing-operator"
     ]
   },

+ 8 - 0
netbox/project-static/select.scss

@@ -60,6 +60,7 @@ div.form-floating div.ss-main div.ss-multi-selected {
   div.ss-multi-selected .ss-values .ss-disabled,
   div.ss-single-selected span.placeholder .ss-disabled {
     color: var(--nbx-select-placeholder-color);
+    font-size: $font-size-xs;
   }
 
   .ss-single-selected {
@@ -109,12 +110,19 @@ div.form-floating div.ss-main div.ss-multi-selected {
         border-bottom-left-radius: $form-select-border-radius;
         border-bottom-right-radius: $form-select-border-radius;
       }
+      .ss-option.ss-disabled {
+        background-color: transparent;
+      }
+      .ss-option.ss-disabled:hover {
+        color: unset;
+      }
     }
     border-bottom-left-radius: $form-select-border-radius;
     border-bottom-right-radius: $form-select-border-radius;
     .ss-search {
       input[type='search'] {
         background-color: $form-select-bg;
+        color: $input-color;
         border: $form-select-border-width solid $form-select-border-color;
         &:focus {
           border-color: $form-select-focus-border-color;

+ 14 - 0
netbox/project-static/src/buttons.ts

@@ -265,6 +265,19 @@ function initSelectAll() {
   }
 }
 
+function handlePerPageSelect(event: Event) {
+  const select = event.currentTarget as HTMLSelectElement;
+  if (select.form !== null) {
+    select.form.submit();
+  }
+}
+
+function initPerPage() {
+  for (const element of getElements<HTMLSelectElement>('select.per-page')) {
+    element.addEventListener('change', handlePerPageSelect);
+  }
+}
+
 export function initButtons() {
   for (const func of [
     initRackElevation,
@@ -272,6 +285,7 @@ export function initButtons() {
     initReslug,
     initSelectAll,
     initPreferenceUpdate,
+    initPerPage,
   ]) {
     func();
   }

+ 35 - 31
netbox/project-static/src/search.ts

@@ -1,40 +1,44 @@
 import debounce from 'just-debounce-it';
-import { getElements, getRowValues, findFirstAdjacent } from './util';
+import { getElements, getRowValues, findFirstAdjacent, isTruthy } from './util';
 
-interface SearchFilterButton extends EventTarget {
-  dataset: { searchValue: string };
-}
+/**
+ * Change the display value and hidden input values of the search filter based on dropdown
+ * selection.
+ *
+ * @param event "click" event for each dropdown item.
+ * @param button Each dropdown item element.
+ */
+function handleSearchDropdownClick(event: Event, button: HTMLButtonElement) {
+  const dropdown = event.currentTarget as HTMLButtonElement;
+  const selectedValue = findFirstAdjacent<HTMLSpanElement>(dropdown, 'span.search-obj-selected');
+  const selectedType = findFirstAdjacent<HTMLInputElement>(dropdown, 'input.search-obj-type');
+  const searchValue = dropdown.getAttribute('data-search-value');
+  let selected = '' as string;
 
-function isSearchButton(el: any): el is SearchFilterButton {
-  return el?.dataset?.searchValue ?? null !== null;
+  if (selectedValue !== null && selectedType !== null) {
+    if (isTruthy(searchValue) && selected !== searchValue) {
+      selected = searchValue;
+      selectedValue.innerHTML = button.textContent ?? 'Error';
+      selectedType.value = searchValue;
+    } else {
+      selected = '';
+      selectedValue.innerHTML = 'All Objects';
+      selectedType.value = '';
+    }
+  }
 }
 
+/**
+ * Initialize Search Bar Elements.
+ */
 function initSearchBar() {
-  const dropdown = document.getElementById('object-type-selector');
-  const selectedValue = document.getElementById('selected-value') as HTMLSpanElement;
-  const selectedType = document.getElementById('search-obj-type') as HTMLInputElement;
-  let selected = '';
-
-  if (dropdown !== null) {
-    const buttons = dropdown.querySelectorAll('li > button.dropdown-item');
-    for (const button of buttons) {
-      if (button !== null) {
-        function handleClick(event: Event) {
-          if (isSearchButton(event.target)) {
-            const objectType = event.target.dataset.searchValue;
-            if (objectType !== '' && selected !== objectType) {
-              selected = objectType;
-              selectedValue.innerHTML = button.textContent ?? 'Error';
-              selectedType.value = objectType;
-            } else {
-              selected = '';
-              selectedType.innerHTML = 'All Objects';
-              selectedType.value = '';
-            }
-          }
-        }
-        button.addEventListener('click', handleClick);
-      }
+  for (const dropdown of getElements<HTMLUListElement>(
+    'div.search-container ul.search-obj-selector',
+  )) {
+    for (const button of dropdown.querySelectorAll<HTMLButtonElement>(
+      'li > button.dropdown-item',
+    )) {
+      button.addEventListener('click', event => handleSearchDropdownClick(event, button));
     }
   }
 }

+ 1 - 1
netbox/project-static/src/select/api.ts

@@ -184,7 +184,7 @@ export function initApiSelect() {
     // element's value.
     const event = new Event(`netbox.select.onload.${select.name}`);
     // Query Parameters - will have attributes added below.
-    const query = {} as Record<string, string>;
+    const query = { limit: 0 } as Record<string, string | number>;
 
     if (hasUrl(select)) {
       // Store the original URL, so it can be referred back to as filter-by elements change.

+ 34 - 1
netbox/project-static/theme-base.scss

@@ -1,4 +1,5 @@
-// Base Netbox Theme Overrides and Settings - color mode agnostic.
+// Base NetBox Theme Overrides and Settings - color mode agnostic.
+
 @import 'bootstrap/scss/functions';
 
 $alt: #13293d;
@@ -11,6 +12,7 @@ $green: #10b981;
 $blue: #3b82f6;
 $purple: #8b5cf6;
 $pink: #ec4899;
+$cyan: #06b6d4;
 
 $gray-50: #f9fafb;
 $gray-100: #f3f4f6;
@@ -67,6 +69,17 @@ $blue-700: #1d4ed8;
 $blue-800: #1e40af;
 $blue-900: #1e3a8a;
 
+$cyan-50: #ecfeff;
+$cyan-100: #cffafe;
+$cyan-200: #a5f3fc;
+$cyan-300: #67e8f9;
+$cyan-400: #22d3ee;
+$cyan-500: #06b6d4;
+$cyan-600: #0891b2;
+$cyan-700: #0e7490;
+$cyan-800: #155e75;
+$cyan-900: #164e63;
+
 $indigo-50: #eef2ff;
 $indigo-100: #e0e7ff;
 $indigo-200: #c7d2fe;
@@ -119,6 +132,13 @@ $font-weight-lighter: 200;
 $font-weight-medium: 600;
 $font-weight-bolder: 800;
 
+$font-size-xs: 0.9rem;
+
+$line-height-base: 1.5;
+$line-height-xs: 1;
+$line-height-sm: 1.25;
+$line-height-lg: 1.75;
+
 $theme-color-addons: (
   'alt': $alt,
   'gray': $gray-400,
@@ -173,6 +193,16 @@ $theme-color-addons: (
   'blue-700': $blue-700,
   'blue-800': $blue-800,
   'blue-900': $blue-900,
+  'cyan-50': $cyan-50,
+  'cyan-100': $cyan-100,
+  'cyan-200': $cyan-200,
+  'cyan-300': $cyan-300,
+  'cyan-400': $cyan-400,
+  'cyan-500': $cyan-500,
+  'cyan-600': $cyan-600,
+  'cyan-700': $cyan-700,
+  'cyan-800': $cyan-800,
+  'cyan-900': $cyan-900,
   'indigo-50': $indigo-50,
   'indigo-100': $indigo-100,
   'indigo-200': $indigo-200,
@@ -210,3 +240,6 @@ $font-family-sans-serif: ui-sans-serif, system-ui, -apple-system, BlinkMacSystem
   'Segoe UI Symbol', 'Noto Color Emoji';
 $font-family-monospace: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
   'Courier New', monospace;
+
+$accordion-padding-y: 0.8125rem;
+$accordion-padding-x: 0.8125rem;

+ 10 - 6
netbox/project-static/theme-dark.scss

@@ -47,11 +47,11 @@ $box-shadow: 0 0.5rem 1rem rgba($black, 0.15);
 $box-shadow-sm: 0 0.125rem 0.25rem rgba($black, 0.075);
 $box-shadow-lg: 0 1rem 3rem rgba($black, 0.175);
 $box-shadow-inset: inset 0 1px 2px rgba($black, 0.075);
-// $component-active-color:      $white;
-// $component-active-bg:         $primary;
-$text-muted: $gray-500;
+$text-muted: $gray-400;
 $blockquote-footer-color: $gray-600;
 $mark-bg: #fcf8e3;
+$link-color: $primary;
+$link-hover-color: $blue-200;
 
 // Tables
 $table-color: $gray-100;
@@ -129,6 +129,10 @@ $nav-tabs-link-active-border-color: $gray-800 $gray-800 $nav-tabs-link-active-bg
 $nav-pills-link-active-color: $component-active-color;
 $nav-pills-link-active-bg: $component-active-bg;
 
+$navbar-light-color: $gray-500;
+$navbar-light-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>");
+$navbar-light-toggler-border-color: $gray-700;
+
 // Dropdowns
 $dropdown-color: $body-color;
 $dropdown-bg: $gray-900;
@@ -170,8 +174,8 @@ $accordion-bg: transparent;
 $accordion-border-color: rgba($white, 0.125);
 $accordion-button-color: $accordion-color;
 $accordion-button-bg: $accordion-bg;
-$accordion-button-active-bg: tint-color($component-active-bg, 5%);
-$accordion-button-active-color: shade-color($primary, 10%);
+$accordion-button-active-bg: rgba($blue-300, 0.15);
+$accordion-button-active-color: $gray-300;
 $accordion-button-focus-border-color: $input-focus-border-color;
 $accordion-icon-color: $accordion-color;
 $accordion-icon-active-color: $accordion-button-active-color;
@@ -259,7 +263,7 @@ $btn-close-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' v
 $btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);
 
 // Code
-$code-color: $pink-300;
+$code-color: $gray-200;
 $kbd-color: $white;
 $kbd-bg: $gray-300;
 $pre-color: null;

+ 7 - 0
netbox/project-static/theme-light.scss

@@ -17,5 +17,12 @@ $card-cap-color: $gray-800;
 
 $accordion-bg: transparent;
 $accordion-button-bg: $accordion-bg;
+$accordion-button-active-bg: $blue-100;
+$accordion-button-active-color: $gray-800;
 
 $breadcrumb-divider: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='currentColor'/%3E%3C/svg%3E");
+
+$code-color: $gray-900;
+
+$list-group-color: $gray-700;
+$list-group-disabled-color: $gray-500;

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

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

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

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

+ 1 - 1
netbox/secrets/models.py

@@ -233,7 +233,7 @@ class SessionKey(BigIDModel):
         return session_key
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class SecretRole(OrganizationalModel):
     """
     A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles

+ 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

+ 1 - 1
netbox/templates/500.html

@@ -11,7 +11,7 @@
 <body>
     <div class="container-fluid">
         <div class="row">
-            <div class="col-md-6 offset-md-3">
+            <div class="col col-md-6 offset-md-3">
                 <div class="card bg-danger mt-5">
                     <h5 class="card-header">
                         <i class="mdi mdi-alert"></i> Server Error

+ 13 - 3
netbox/templates/base.html

@@ -2,11 +2,21 @@
 <!DOCTYPE html>
 <html lang="en">
   <head>
-    <title>{% block title %}Home{% endblock %} - NetBox</title>
+    <title>{% block title %}Home{% endblock %} | NetBox</title>
     <link
       rel="stylesheet"
-      href="{% static 'netbox.css'%}"
-      onerror="window.location='{% url 'media_failure' %}?filename=netbox.css'"
+      href="{% static 'netbox-external.css'%}"
+      onerror="window.location='{% url 'media_failure' %}?filename=netbox-external.css'"
+    />
+    <link
+      rel="stylesheet"
+      href="{% static 'netbox-light.css'%}"
+      onerror="window.location='{% url 'media_failure' %}?filename=netbox-light.css'"
+    />
+    <link
+      rel="stylesheet"
+      href="{% static 'netbox-dark.css'%}"
+      onerror="window.location='{% url 'media_failure' %}?filename=netbox-dark.css'"
     />
 
     <link rel="icon" type="image/png" href="{% static 'netbox.ico' %}" />

+ 0 - 8
netbox/templates/bottom.html

@@ -1,12 +1,4 @@
 <div class="d-flex flex-column container-fluid mt-auto justify-content-end sidebar-bottom">
-  <div class="d-flex flex-column align-items-center px-2 fw-light">
-    <small class="text-muted">{% now 'Y-m-d H:i:s T' %}</small>
-  </div>
-  <div class="d-flex flex-column align-items-center px-2">
-    <small class="text-muted">
-      {{ settings.HOSTNAME }} (v{{ settings.VERSION }})
-    </small>
-  </div>
   <nav class="nav justify-content-between mb-2 mt-4 px-2">
     <a type="button" target="_blank" class="nav-link" href="https://netbox.readthedocs.io/">
       <i title="Docs" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>

+ 11 - 11
netbox/templates/circuits/circuit.html

@@ -10,19 +10,13 @@
 
 {% block content %}
 <div class="row">
-	<div class="col-md-6">
+	<div class="col col-md-6">
         <div class="card">
             <h5 class="card-header">
                 Circuit
             </h5>
             <div class="card-body">
                 <table class="table table-hover attr-table">
-                    <tr>
-                        <td colspan="2">
-                        <span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span>
-                        </td>
-
-                    </tr>
                     <tr>
                         <th scope="row">Provider</th>
                         <td>
@@ -50,6 +44,12 @@
                             {% endif %}
                         </td>
                     </tr>
+                    <tr>
+                        <th scope="row">Status</th>
+                        <td>
+                            <span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span>
+                        </td>
+                    </tr>
                     <tr>
                         <th scope="row">Install Date</th>
                         <td>{{ object.install_date|placeholder }}</td>
@@ -81,14 +81,14 @@
         </div>
         {% plugin_left_page object %}
 	</div>
-	<div class="col-md-6">
-        {% include 'circuits/inc/circuit_termination.html' with termination=termination_a side='A' %}
-        {% include 'circuits/inc/circuit_termination.html' with termination=termination_z side='Z' %}
+	<div class="col col-md-6">
+        {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
+        {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
         {% plugin_right_page object %}
     </div>
 </div>
 <div class="row">
-    <div class="col-md-12">
+    <div class="col col-md-12">
         {% plugin_full_width_page object %}
     </div>
 </div>

+ 3 - 3
netbox/templates/circuits/circuittype.html

@@ -9,7 +9,7 @@
 
 {% block content %}
 <div class="row mb-3">
-	<div class="col-md-6">
+	<div class="col col-md-6">
     <div class="card">
       <h5 class="card-header">
         Circuit Type
@@ -35,13 +35,13 @@
     </div>
     {% plugin_left_page object %}
   </div>
-	<div class="col-md-6">
+	<div class="col col-md-6">
     {% include 'inc/custom_fields_panel.html' %}
     {% plugin_right_page object %}
 	</div>
 </div>
 <div class="row mb-3">
-	<div class="col-md-12">
+	<div class="col col-md-12">
     <div class="card">
       <h5 class="card-header">
         Circuits

+ 6 - 6
netbox/templates/circuits/provider.html

@@ -4,13 +4,13 @@
 {% load plugins %}
 
 {% block breadcrumbs %}
-  <li><a href="{% url 'circuits:provider_list' %}">Providers</a></li>
-  <li>{{ object }}</li>
+  <li class="breadcrumb-item"><a href="{% url 'circuits:provider_list' %}">Providers</a></li>
+  <li class="breadcrumb-item">{{ object }}</li>
 {% endblock %}
 
 {% block content %}
 <div class="row">
-	<div class="col-md-4">
+	<div class="col col-md-4">
         <div class="card">
             <h5 class="card-header">
                 Provider
@@ -54,7 +54,7 @@
         </div>
         {% plugin_left_page object %}
     </div>
-    <div class="col-md-6">
+    <div class="col col-md-6">
         {% include 'inc/custom_fields_panel.html' %}
         {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %}
         <div class="card">
@@ -71,7 +71,7 @@
         </div>
         {% plugin_right_page object %}
 	</div>
-	<div class="col-md-8">
+	<div class="col col-md-8">
         <div class="card">
             <h5 class="card-header">
                 Circuits
@@ -91,7 +91,7 @@
     </div>
 </div>
 <div class="row">
-    <div class="col-md-12">
+    <div class="col col-md-12">
         {% plugin_full_width_page object %}
     </div>
 </div>

+ 4 - 4
netbox/templates/circuits/providernetwork.html

@@ -11,7 +11,7 @@
 
 {% block content %}
 <div class="row">
-	  <div class="col-md-6">
+	  <div class="col col-md-6">
         <div class="card">
             <h5 class="card-header">
                 Provider Network
@@ -37,7 +37,7 @@
         </div>
         {% plugin_left_page object %}
     </div>
-    <div class="col-md-6">
+    <div class="col col-md-6">
         <div class="card">
             <h5 class="card-header">
                 Comments
@@ -56,7 +56,7 @@
     </div>
 </div>
 <div class="row">
-    <div class="col-md-12">
+    <div class="col col-md-12">
         <div class="card">
             <h5 class="card-header">
                 Circuits
@@ -69,7 +69,7 @@
     </div>
 </div>
 <div class="row">
-    <div class="col-md-12">
+    <div class="col col-md-12">
         {% plugin_full_width_page object %}
     </div>
 </div>

+ 3 - 3
netbox/templates/dcim/cable.html

@@ -25,7 +25,7 @@
 
 {% block content %}
     <div class="row">
-        <div class="col-md-6">
+        <div class="col col-md-6">
             <div class="card">
                 <h5 class="card-header">
                     Cable
@@ -73,7 +73,7 @@
             {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:cable_list' %}
             {% plugin_left_page object %}
         </div>
-        <div class="col-md-6">
+        <div class="col col-md-6">
             <div class="card">
                 <h5 class="card-header">
                     Termination A
@@ -94,7 +94,7 @@
         </div>
     </div>
     <div class="row">
-        <div class="col-md-12">
+        <div class="col col-md-12">
             {% plugin_full_width_page object %}
         </div>
     </div>

+ 5 - 5
netbox/templates/dcim/cable_connect.html

@@ -14,7 +14,7 @@
         {{ field }}
     {% endfor %}
     <div class="row my-3">
-        <div class="col-md-5">
+        <div class="col col-md-5">
             <div class="card h-100">
                 <h5 class="card-header">
                     A Side
@@ -68,10 +68,10 @@
                 </div>
             </div>
         </div>
-        <div class="col-md-2 d-flex flex-column justify-content-center align-items-center">
+        <div class="col col-md-2 d-flex flex-column justify-content-center align-items-center">
             <i class="mdi mdi-swap-horizontal-bold mdi-48px"></i>
         </div>
-        <div class="col-md-5">
+        <div class="col col-md-5">
             <div class="card h-100">
                 <h5 class="card-header">
                     B Side
@@ -123,12 +123,12 @@
         </div>
     </div>
     <div class="row my-3 justify-content-center">
-        <div class="col-md-8">
+        <div class="col col-md-8">
             {% include 'dcim/inc/cable_form.html' %}
         </div>
     </div>
     <div class="row my-3">
-        <div class="col-md-12 text-center">
+        <div class="col col-md-12 text-center">
             <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
             <button type="submit" name="_update" class="btn btn-primary">Connect</button>
         </div>

+ 2 - 2
netbox/templates/dcim/cable_trace.html

@@ -7,7 +7,7 @@
 
 {% block content %}
     <div class="row">
-        <div class="col-md-5 col-sm-12">
+        <div class="col col-md-5">
             <div class="cable-trace">
                 {% with traced_path=path.origin.trace %}
                     {% for near_end, cable, far_end in traced_path %}
@@ -87,7 +87,7 @@
                 {% endwith %}
             </div>
         </div>
-        <div class="col-md-7 col-sm-12">
+        <div class="col col-md-7">
 
             <div class="card">
                 <h5 class="card-header">

+ 2 - 2
netbox/templates/dcim/connections_list.html

@@ -7,7 +7,7 @@
 
 {% block content %}
 <div class="row mb-3">
-    <div class="col-md-9">
+    <div class="col col-md-9">
         <div class="card">
             <div class="card-body">
                 {% include 'responsive_table.html' %}
@@ -15,7 +15,7 @@
         </div>
         {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
     </div>
-    <div class="col-md-3 float-end right-side-panel noprint">
+    <div class="col col-md-3 float-end right-side-panel noprint">
         {% include 'inc/search_panel.html' %}
     </div>
 </div>

+ 3 - 3
netbox/templates/dcim/consoleport.html

@@ -9,7 +9,7 @@
 
 {% block content %}
     <div class="row">
-        <div class="col-md-6">
+        <div class="col col-md-6">
             <div class="card">
                 <h5 class="card-header">
                     Console Port
@@ -49,7 +49,7 @@
             {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %}
             {% plugin_left_page object %}
         </div>
-        <div class="col-md-6">
+        <div class="col col-md-6">
             <div class="card">
                 <h5 class="card-header">
                     Connection
@@ -145,7 +145,7 @@
         </div>
     </div>
     <div class="row">
-        <div class="col-md-12">
+        <div class="col col-md-12">
             {% plugin_full_width_page object %}
         </div>
     </div>

+ 3 - 3
netbox/templates/dcim/consoleserverport.html

@@ -9,7 +9,7 @@
 
 {% block content %}
     <div class="row">
-        <div class="col-md-6">
+        <div class="col col-md-6">
             <div class="card">
                 <h5 class="card-header">
                     Console Server Port
@@ -49,7 +49,7 @@
             {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %}
             {% plugin_left_page object %}
         </div>
-        <div class="col-md-6">
+        <div class="col col-md-6">
             <div class="card">
                 <h5 class="card-header">
                     Connection
@@ -145,7 +145,7 @@
         </div>
     </div>
     <div class="row">
-        <div class="col-md-12">
+        <div class="col col-md-12">
             {% plugin_full_width_page object %}
         </div>
     </div>

+ 6 - 6
netbox/templates/dcim/device.html

@@ -7,11 +7,11 @@
 
 {% block content %}
 <div class="row">
-    <div class="col-md-12">
+    <div class="col col-md-12">
         <div class="tab-content">
             <div role="tabpanel" class="tab-pane active" id="details">
                 <div class="row">
-                    <div class="col-md-6">
+                    <div class="col col-md-6">
                         <div class="card">
                             <h5 class="card-header">
                                 Device
@@ -94,7 +94,7 @@
                                     <tr>
                                         <th scope="row">Device Type</th>
                                         <td>
-                                            <span><a href="{{ object.device_type.get_absolute_url }}">{{ object.device_type.display_name }}</a> ({{ object.device_type.u_height }}U)</span>
+                                            <span><a href="{{ object.device_type.get_absolute_url }}">{{ object.device_type }}</a> ({{ object.device_type.u_height }}U)</span>
                                         </td>
                                     </tr>
                                     <tr>
@@ -228,7 +228,7 @@
                         </div>
                         {% plugin_left_page object %}
                     </div>
-                    <div class="col-md-6">
+                    <div class="col col-md-6">
                         {% if object.powerports.exists and object.poweroutlets.exists %}
                             <div class="card">
                                 <h5 class="card-header">
@@ -356,7 +356,7 @@
                                                 <span class="text-muted">&mdash;</span>
                                             {% endif %}
                                         </td>
-                                        <td>{{ rd.device_type.display_name }}</td>
+                                        <td>{{ rd.device_type }}</td>
                                     </tr>
                                     {% endfor %}
                                 </table>
@@ -369,7 +369,7 @@
                     </div>
                 </div>
                 <div class="row">
-                    <div class="col-md-12">
+                    <div class="col col-md-12">
                         {% plugin_full_width_page object %}
                     </div>
                 </div>

+ 1 - 1
netbox/templates/dcim/device/config.html

@@ -9,7 +9,7 @@
 
 {% block content %}
 <div class="row">
-    <div class="col-md-10 col-md-offset-1">
+    <div class="col col-md-10">
         <div class="card">
             <div class="card-overlay">
                 <div class="spinner-border" role="status">

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio