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

Merge branch 'feature' into feature-2434

checktheroads 4 лет назад
Родитель
Сommit
515aed7022
100 измененных файлов с 1539 добавлено и 1322 удалено
  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:
 body:
   - type: markdown
   - type: markdown
     attributes:
     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
   - type: input
     attributes:
     attributes:
       label: NetBox version
       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:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown
     attributes:
     attributes:
       label: Python version
       label: Python version
-      description: "What version of Python are you currently running?"
+      description: What version of Python are you currently running?
       options:
       options:
         - 3.6
         - 3.6
         - 3.7
         - 3.7
@@ -30,12 +34,13 @@ body:
   - type: textarea
   - type: textarea
     attributes:
     attributes:
       label: Steps to Reproduce
       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: |
       placeholder: |
         1. Click on "create widget"
         1. Click on "create widget"
         2. Set foo to 12 and bar to G
         2. Set foo to 12 and bar to G
@@ -45,14 +50,14 @@ body:
   - type: textarea
   - type: textarea
     attributes:
     attributes:
       label: Expected Behavior
       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:
     validations:
       required: true
       required: true
   - type: textarea
   - type: textarea
     attributes:
     attributes:
       label: Observed Behavior
       label: Observed Behavior
-      description: "What happened instead?"
-      placeholder: "A TypeError exception was raised"
+      description: What happened instead?
+      placeholder: A TypeError exception was raised
     validations:
     validations:
       required: true
       required: true

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

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

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

@@ -5,15 +5,16 @@ labels: ["type: feature"]
 body:
 body:
   - type: markdown
   - type: markdown
     attributes:
     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
   - type: input
     attributes:
     attributes:
       label: NetBox version
       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:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown
@@ -28,26 +29,29 @@ body:
   - type: textarea
   - type: textarea
     attributes:
     attributes:
       label: Proposed functionality
       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:
     validations:
       required: true
       required: true
   - type: textarea
   - type: textarea
     attributes:
     attributes:
       label: Use case
       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:
     validations:
       required: true
       required: true
   - type: textarea
   - type: textarea
     attributes:
     attributes:
       label: Database changes
       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
   - type: textarea
     attributes:
     attributes:
       label: External dependencies
       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:
 body:
   - type: markdown
   - type: markdown
     attributes:
     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
   - type: textarea
     attributes:
     attributes:
       label: Proposed Changes
       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:
     validations:
       required: true
       required: true
   - type: textarea
   - type: textarea
     attributes:
     attributes:
       label: Justification
       label: Justification
-      description: "Please provide justification for the proposed change(s)."
+      description: Please provide justification for the proposed change(s).
     validations:
     validations:
       required: true
       required: true

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

@@ -17,9 +17,10 @@ jobs:
             necessary.
             necessary.
           close-pr-message: >
           close-pr-message: >
             This PR has been automatically closed due to lack of activity.
             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'
           exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
+          operations-per-run: 100
           remove-stale-when-updated: false
           remove-stale-when-updated: false
           stale-issue-label: 'pending closure'
           stale-issue-label: 'pending closure'
           stale-issue-message: >
           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
 NetBox is an IP address management (IPAM) and data center infrastructure
 management (DCIM) tool. Initially conceived by the network engineering team at
 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
 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).
 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) |
 | **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
 Please see [the documentation](https://netbox.readthedocs.io/en/stable/) for
 instructions on installing NetBox. To upgrade NetBox, please download the
 instructions on installing NetBox. To upgrade NetBox, please download the
 [latest release](https://github.com/netbox-community/netbox/releases) and
 [latest release](https://github.com/netbox-community/netbox/releases) and
 run `upgrade.sh`.
 run `upgrade.sh`.
 
 
-## Providing Feedback
+### Providing Feedback
 
 
 The best platform for general feedback, assistance, and other discussion is our
 The best platform for general feedback, assistance, and other discussion is our
 [GitHub discussions](https://github.com/netbox-community/netbox/discussions).
 [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
 If you are interested in contributing to the development of NetBox, please read
 our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
 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)
 Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions)
 for a list of relevant community projects.
 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
 ## SESSION_FILE_PATH
 
 
 Default: None
 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 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
 ### Submit a Pull Request
 
 

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

@@ -1,5 +1,32 @@
 # NetBox v2.11
 # 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)
 ## v2.11.2 (2021-04-27)
 
 
 ### Enhancements
 ### 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
 * [#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
 * [#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')
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail')
 
 
     class Meta:
     class Meta:
-        model = Provider
+        model = ProviderNetwork
         fields = ['id', 'url', 'display', 'name']
         fields = ['id', 'url', 'display', 'name']
 
 
 
 

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

@@ -1,6 +1,6 @@
 from rest_framework.routers import APIRootView
 from rest_framework.routers import APIRootView
 
 
-from circuits import filters
+from circuits import filtersets
 from circuits.models import *
 from circuits.models import *
 from dcim.api.views import PassThroughPortMixin
 from dcim.api.views import PassThroughPortMixin
 from extras.api.views import CustomFieldModelViewSet
 from extras.api.views import CustomFieldModelViewSet
@@ -26,7 +26,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
         circuit_count=count_related(Circuit, 'provider')
         circuit_count=count_related(Circuit, 'provider')
     )
     )
     serializer_class = serializers.ProviderSerializer
     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')
         circuit_count=count_related(Circuit, 'type')
     )
     )
     serializer_class = serializers.CircuitTypeSerializer
     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'
         'type', 'tenant', 'provider', 'termination_a', 'termination_z'
     ).prefetch_related('tags')
     ).prefetch_related('tags')
     serializer_class = serializers.CircuitSerializer
     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'
         'circuit', 'site', 'provider_network', 'cable'
     )
     )
     serializer_class = serializers.CircuitTerminationSerializer
     serializer_class = serializers.CircuitTerminationSerializer
-    filterset_class = filters.CircuitTerminationFilterSet
+    filterset_class = filtersets.CircuitTerminationFilterSet
     brief_prefetch_fields = ['circuit']
     brief_prefetch_fields = ['circuit']
 
 
 
 
@@ -73,4 +73,4 @@ class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet):
 class ProviderNetworkViewSet(CustomFieldModelViewSet):
 class ProviderNetworkViewSet(CustomFieldModelViewSet):
     queryset = ProviderNetwork.objects.prefetch_related('tags')
     queryset = ProviderNetwork.objects.prefetch_related('tags')
     serializer_class = serializers.ProviderNetworkSerializer
     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
 import django_filters
 from django.db.models import Q
 from django.db.models import Q
 
 
-from dcim.filters import CableTerminationFilterSet
+from dcim.filtersets import CableTerminationFilterSet
 from dcim.models import Region, Site, SiteGroup
 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 .choices import *
 from .models import *
 from .models import *
 
 
@@ -20,7 +19,7 @@ __all__ = (
 )
 )
 
 
 
 
-class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class ProviderFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -80,7 +79,7 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdated
         )
         )
 
 
 
 
-class ProviderNetworkFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class ProviderNetworkFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -110,14 +109,14 @@ class ProviderNetworkFilterSet(BaseFilterSet, CustomFieldModelFilterSet, Created
         ).distinct()
         ).distinct()
 
 
 
 
-class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class CircuitTypeFilterSet(OrganizationalModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
+class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -207,7 +206,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe
         ).distinct()
         ).distinct()
 
 
 
 
-class CircuitTerminationFilterSet(BaseFilterSet, CreatedUpdatedFilterSet, CableTerminationFilterSet):
+class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -233,7 +232,7 @@ class CircuitTerminationFilterSet(BaseFilterSet, CreatedUpdatedFilterSet, CableT
 
 
     class Meta:
     class Meta:
         model = CircuitTermination
         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):
     def search(self, queryset, name, value):
         if not value.strip():
         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):
 class CircuitType(OrganizationalModel):
     """
     """
     Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
     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.db.models.signals import post_delete, post_save
 from django.dispatch import receiver
 from django.dispatch import receiver
-from django.utils import timezone
 
 
 from dcim.signals import rebuild_paths
 from dcim.signals import rebuild_paths
-from .models import Circuit, CircuitTermination
+from .models import CircuitTermination
 
 
 
 
 @receiver(post_save, sender=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.
     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)
 @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 django.test import TestCase
 
 
 from circuits.choices import *
 from circuits.choices import *
-from circuits.filters import *
+from circuits.filtersets import *
 from circuits.models import *
 from circuits.models import *
 from dcim.models import Cable, Region, Site, SiteGroup
 from dcim.models import Cable, Region, Site, SiteGroup
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
+from utilities.testing import ChangeLoggedFilterSetTests
 
 
 
 
-class ProviderTestCase(TestCase):
+class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Provider.objects.all()
     queryset = Provider.objects.all()
     filterset = ProviderFilterSet
     filterset = ProviderFilterSet
 
 
@@ -61,10 +62,6 @@ class ProviderTestCase(TestCase):
             CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A'),
             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):
     def test_name(self):
         params = {'name': ['Provider 1', 'Provider 2']}
         params = {'name': ['Provider 1', 'Provider 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class CircuitTypeTestCase(TestCase):
+class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = CircuitType.objects.all()
     queryset = CircuitType.objects.all()
     filterset = CircuitTypeFilterSet
     filterset = CircuitTypeFilterSet
 
 
@@ -116,10 +113,6 @@ class CircuitTypeTestCase(TestCase):
             CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
             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):
     def test_name(self):
         params = {'name': ['Circuit Type 1']}
         params = {'name': ['Circuit Type 1']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class CircuitTestCase(TestCase):
+class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Circuit.objects.all()
     queryset = Circuit.objects.all()
     filterset = CircuitFilterSet
     filterset = CircuitFilterSet
 
 
@@ -213,10 +206,6 @@ class CircuitTestCase(TestCase):
         ))
         ))
         CircuitTermination.objects.bulk_create(circuit_terminations)
         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):
     def test_cid(self):
         params = {'cid': ['Test Circuit 1', 'Test Circuit 2']}
         params = {'cid': ['Test Circuit 1', 'Test Circuit 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
 
 
-class CircuitTerminationTestCase(TestCase):
+class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = CircuitTermination.objects.all()
     queryset = CircuitTermination.objects.all()
     filterset = CircuitTerminationFilterSet
     filterset = CircuitTerminationFilterSet
 
 
@@ -382,7 +371,7 @@ class CircuitTerminationTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class ProviderNetworkTestCase(TestCase):
+class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ProviderNetwork.objects.all()
     queryset = ProviderNetwork.objects.all()
     filterset = ProviderNetworkFilterSet
     filterset = ProviderNetworkFilterSet
 
 
@@ -403,10 +392,6 @@ class ProviderNetworkTestCase(TestCase):
         )
         )
         ProviderNetwork.objects.bulk_create(provider_networks)
         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):
     def test_name(self):
         params = {'name': ['Provider Network 1', 'Provider Network 2']}
         params = {'name': ['Provider Network 1', 'Provider Network 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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.forms import ConfirmationForm
 from utilities.tables import paginate_table
 from utilities.tables import paginate_table
 from utilities.utils import count_related
 from utilities.utils import count_related
-from . import filters, forms, tables
+from . import filtersets, forms, tables
 from .choices import CircuitTerminationSideChoices
 from .choices import CircuitTerminationSideChoices
 from .models import *
 from .models import *
 
 
@@ -20,7 +20,7 @@ class ProviderListView(generic.ObjectListView):
     queryset = Provider.objects.annotate(
     queryset = Provider.objects.annotate(
         count_circuits=count_related(Circuit, 'provider')
         count_circuits=count_related(Circuit, 'provider')
     )
     )
-    filterset = filters.ProviderFilterSet
+    filterset = filtersets.ProviderFilterSet
     filterset_form = forms.ProviderFilterForm
     filterset_form = forms.ProviderFilterForm
     table = tables.ProviderTable
     table = tables.ProviderTable
 
 
@@ -63,7 +63,7 @@ class ProviderBulkEditView(generic.BulkEditView):
     queryset = Provider.objects.annotate(
     queryset = Provider.objects.annotate(
         count_circuits=count_related(Circuit, 'provider')
         count_circuits=count_related(Circuit, 'provider')
     )
     )
-    filterset = filters.ProviderFilterSet
+    filterset = filtersets.ProviderFilterSet
     table = tables.ProviderTable
     table = tables.ProviderTable
     form = forms.ProviderBulkEditForm
     form = forms.ProviderBulkEditForm
 
 
@@ -72,7 +72,7 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
     queryset = Provider.objects.annotate(
     queryset = Provider.objects.annotate(
         count_circuits=count_related(Circuit, 'provider')
         count_circuits=count_related(Circuit, 'provider')
     )
     )
-    filterset = filters.ProviderFilterSet
+    filterset = filtersets.ProviderFilterSet
     table = tables.ProviderTable
     table = tables.ProviderTable
 
 
 
 
@@ -82,7 +82,7 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
 
 
 class ProviderNetworkListView(generic.ObjectListView):
 class ProviderNetworkListView(generic.ObjectListView):
     queryset = ProviderNetwork.objects.all()
     queryset = ProviderNetwork.objects.all()
-    filterset = filters.ProviderNetworkFilterSet
+    filterset = filtersets.ProviderNetworkFilterSet
     filterset_form = forms.ProviderNetworkFilterForm
     filterset_form = forms.ProviderNetworkFilterForm
     table = tables.ProviderNetworkTable
     table = tables.ProviderNetworkTable
 
 
@@ -125,14 +125,14 @@ class ProviderNetworkBulkImportView(generic.BulkImportView):
 
 
 class ProviderNetworkBulkEditView(generic.BulkEditView):
 class ProviderNetworkBulkEditView(generic.BulkEditView):
     queryset = ProviderNetwork.objects.all()
     queryset = ProviderNetwork.objects.all()
-    filterset = filters.ProviderNetworkFilterSet
+    filterset = filtersets.ProviderNetworkFilterSet
     table = tables.ProviderNetworkTable
     table = tables.ProviderNetworkTable
     form = forms.ProviderNetworkBulkEditForm
     form = forms.ProviderNetworkBulkEditForm
 
 
 
 
 class ProviderNetworkBulkDeleteView(generic.BulkDeleteView):
 class ProviderNetworkBulkDeleteView(generic.BulkDeleteView):
     queryset = ProviderNetwork.objects.all()
     queryset = ProviderNetwork.objects.all()
-    filterset = filters.ProviderNetworkFilterSet
+    filterset = filtersets.ProviderNetworkFilterSet
     table = tables.ProviderNetworkTable
     table = tables.ProviderNetworkTable
 
 
 
 
@@ -183,7 +183,7 @@ class CircuitTypeBulkEditView(generic.BulkEditView):
     queryset = CircuitType.objects.annotate(
     queryset = CircuitType.objects.annotate(
         circuit_count=count_related(Circuit, 'type')
         circuit_count=count_related(Circuit, 'type')
     )
     )
-    filterset = filters.CircuitTypeFilterSet
+    filterset = filtersets.CircuitTypeFilterSet
     table = tables.CircuitTypeTable
     table = tables.CircuitTypeTable
     form = forms.CircuitTypeBulkEditForm
     form = forms.CircuitTypeBulkEditForm
 
 
@@ -203,7 +203,7 @@ class CircuitListView(generic.ObjectListView):
     queryset = Circuit.objects.prefetch_related(
     queryset = Circuit.objects.prefetch_related(
         'provider', 'type', 'tenant', 'termination_a', 'termination_z'
         'provider', 'type', 'tenant', 'termination_a', 'termination_z'
     )
     )
-    filterset = filters.CircuitFilterSet
+    filterset = filtersets.CircuitFilterSet
     filterset_form = forms.CircuitFilterForm
     filterset_form = forms.CircuitFilterForm
     table = tables.CircuitTable
     table = tables.CircuitTable
 
 
@@ -211,27 +211,6 @@ class CircuitListView(generic.ObjectListView):
 class CircuitView(generic.ObjectView):
 class CircuitView(generic.ObjectView):
     queryset = Circuit.objects.all()
     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):
 class CircuitEditView(generic.ObjectEditView):
     queryset = Circuit.objects.all()
     queryset = Circuit.objects.all()
@@ -252,7 +231,7 @@ class CircuitBulkEditView(generic.BulkEditView):
     queryset = Circuit.objects.prefetch_related(
     queryset = Circuit.objects.prefetch_related(
         'provider', 'type', 'tenant', 'terminations'
         'provider', 'type', 'tenant', 'terminations'
     )
     )
-    filterset = filters.CircuitFilterSet
+    filterset = filtersets.CircuitFilterSet
     table = tables.CircuitTable
     table = tables.CircuitTable
     form = forms.CircuitBulkEditForm
     form = forms.CircuitBulkEditForm
 
 
@@ -261,7 +240,7 @@ class CircuitBulkDeleteView(generic.BulkDeleteView):
     queryset = Circuit.objects.prefetch_related(
     queryset = Circuit.objects.prefetch_related(
         'provider', 'type', 'tenant', 'terminations'
         'provider', 'type', 'tenant', 'terminations'
     )
     )
-    filterset = filters.CircuitFilterSet
+    filterset = filtersets.CircuitFilterSet
     table = tables.CircuitTable
     table = tables.CircuitTable
 
 
 
 
@@ -296,16 +275,11 @@ class CircuitSwapTerminations(generic.ObjectEditView):
 
 
         if form.is_valid():
         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:
             if termination_a and termination_z:
                 # Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
                 # Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
-                print('swapping')
                 with transaction.atomic():
                 with transaction.atomic():
                     termination_a.term_side = '_'
                     termination_a.term_side = '_'
                     termination_a.save()
                     termination_a.save()
@@ -316,11 +290,20 @@ class CircuitSwapTerminations(generic.ObjectEditView):
             elif termination_a:
             elif termination_a:
                 termination_a.term_side = 'Z'
                 termination_a.term_side = 'Z'
                 termination_a.save()
                 termination_a.save()
+                circuit.refresh_from_db()
+                circuit.termination_a = None
+                circuit.save()
             else:
             else:
                 termination_z.term_side = 'A'
                 termination_z.term_side = 'A'
                 termination_z.save()
                 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 redirect('circuits:circuit', pk=circuit.pk)
 
 
         return render(request, 'circuits/circuit_terminations_swap.html', {
         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:
     class Meta:
         model = models.Rack
         model = models.Rack
-        fields = ['id', 'url', 'display', 'name', 'display_name', 'device_count']
+        fields = ['id', 'url', 'display', 'name', 'device_count']
 
 
 
 
 class NestedRackReservationSerializer(WritableNestedSerializer):
 class NestedRackReservationSerializer(WritableNestedSerializer):
@@ -136,7 +136,7 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.DeviceType
         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):
 class NestedConsolePortTemplateSerializer(WritableNestedSerializer):
@@ -232,7 +232,7 @@ class NestedDeviceSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.Device
         model = models.Device
-        fields = ['id', 'url', 'display', 'name', 'display_name']
+        fields = ['id', 'url', 'display', 'name']
 
 
 
 
 class NestedConsoleServerPortSerializer(WritableNestedSerializer):
 class NestedConsoleServerPortSerializer(WritableNestedSerializer):

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

@@ -172,10 +172,9 @@ class RackSerializer(PrimaryModelSerializer):
     class Meta:
     class Meta:
         model = Rack
         model = Rack
         fields = [
         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
         # Omit the UniqueTogetherValidator that would be automatically added to validate (location, facility_id). This
         # prevents facility_id from being interpreted as a required field.
         # prevents facility_id from being interpreted as a required field.
@@ -284,9 +283,9 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
         fields = [
         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:
     class Meta:
         model = Device
         model = Device
         fields = [
         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 = []
         validators = []
 
 
@@ -501,10 +500,10 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
 
 
     class Meta(DeviceSerializer.Meta):
     class Meta(DeviceSerializer.Meta):
         fields = [
         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)
     @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 rest_framework.viewsets import GenericViewSet, ViewSet
 
 
 from circuits.models import Circuit
 from circuits.models import Circuit
-from dcim import filters
+from dcim import filtersets
 from dcim.models import *
 from dcim.models import *
 from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
 from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
 from ipam.models import Prefix, VLAN
 from ipam.models import Prefix, VLAN
@@ -103,7 +103,7 @@ class RegionViewSet(CustomFieldModelViewSet):
         cumulative=True
         cumulative=True
     )
     )
     serializer_class = serializers.RegionSerializer
     serializer_class = serializers.RegionSerializer
-    filterset_class = filters.RegionFilterSet
+    filterset_class = filtersets.RegionFilterSet
 
 
 
 
 #
 #
@@ -119,7 +119,7 @@ class SiteGroupViewSet(CustomFieldModelViewSet):
         cumulative=True
         cumulative=True
     )
     )
     serializer_class = serializers.SiteGroupSerializer
     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')
         virtualmachine_count=count_related(VirtualMachine, 'cluster__site')
     )
     )
     serializer_class = serializers.SiteSerializer
     serializer_class = serializers.SiteSerializer
-    filterset_class = filters.SiteFilterSet
+    filterset_class = filtersets.SiteFilterSet
 
 
 
 
 #
 #
@@ -160,7 +160,7 @@ class LocationViewSet(CustomFieldModelViewSet):
         cumulative=True
         cumulative=True
     ).prefetch_related('site')
     ).prefetch_related('site')
     serializer_class = serializers.LocationSerializer
     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')
         rack_count=count_related(Rack, 'role')
     )
     )
     serializer_class = serializers.RackRoleSerializer
     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')
         powerfeed_count=count_related(PowerFeed, 'rack')
     )
     )
     serializer_class = serializers.RackSerializer
     serializer_class = serializers.RackSerializer
-    filterset_class = filters.RackFilterSet
+    filterset_class = filtersets.RackFilterSet
 
 
     @swagger_auto_schema(
     @swagger_auto_schema(
         responses={200: serializers.RackUnitSerializer(many=True)},
         responses={200: serializers.RackUnitSerializer(many=True)},
@@ -244,7 +244,7 @@ class RackViewSet(CustomFieldModelViewSet):
 class RackReservationViewSet(ModelViewSet):
 class RackReservationViewSet(ModelViewSet):
     queryset = RackReservation.objects.prefetch_related('rack', 'user', 'tenant')
     queryset = RackReservation.objects.prefetch_related('rack', 'user', 'tenant')
     serializer_class = serializers.RackReservationSerializer
     serializer_class = serializers.RackReservationSerializer
-    filterset_class = filters.RackReservationFilterSet
+    filterset_class = filtersets.RackReservationFilterSet
 
 
     # Assign user from request
     # Assign user from request
     def perform_create(self, serializer):
     def perform_create(self, serializer):
@@ -262,7 +262,7 @@ class ManufacturerViewSet(CustomFieldModelViewSet):
         platform_count=count_related(Platform, 'manufacturer')
         platform_count=count_related(Platform, 'manufacturer')
     )
     )
     serializer_class = serializers.ManufacturerSerializer
     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')
         device_count=count_related(Device, 'device_type')
     )
     )
     serializer_class = serializers.DeviceTypeSerializer
     serializer_class = serializers.DeviceTypeSerializer
-    filterset_class = filters.DeviceTypeFilterSet
+    filterset_class = filtersets.DeviceTypeFilterSet
     brief_prefetch_fields = ['manufacturer']
     brief_prefetch_fields = ['manufacturer']
 
 
 
 
@@ -285,49 +285,49 @@ class DeviceTypeViewSet(CustomFieldModelViewSet):
 class ConsolePortTemplateViewSet(ModelViewSet):
 class ConsolePortTemplateViewSet(ModelViewSet):
     queryset = ConsolePortTemplate.objects.prefetch_related('device_type__manufacturer')
     queryset = ConsolePortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.ConsolePortTemplateSerializer
     serializer_class = serializers.ConsolePortTemplateSerializer
-    filterset_class = filters.ConsolePortTemplateFilterSet
+    filterset_class = filtersets.ConsolePortTemplateFilterSet
 
 
 
 
 class ConsoleServerPortTemplateViewSet(ModelViewSet):
 class ConsoleServerPortTemplateViewSet(ModelViewSet):
     queryset = ConsoleServerPortTemplate.objects.prefetch_related('device_type__manufacturer')
     queryset = ConsoleServerPortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.ConsoleServerPortTemplateSerializer
     serializer_class = serializers.ConsoleServerPortTemplateSerializer
-    filterset_class = filters.ConsoleServerPortTemplateFilterSet
+    filterset_class = filtersets.ConsoleServerPortTemplateFilterSet
 
 
 
 
 class PowerPortTemplateViewSet(ModelViewSet):
 class PowerPortTemplateViewSet(ModelViewSet):
     queryset = PowerPortTemplate.objects.prefetch_related('device_type__manufacturer')
     queryset = PowerPortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.PowerPortTemplateSerializer
     serializer_class = serializers.PowerPortTemplateSerializer
-    filterset_class = filters.PowerPortTemplateFilterSet
+    filterset_class = filtersets.PowerPortTemplateFilterSet
 
 
 
 
 class PowerOutletTemplateViewSet(ModelViewSet):
 class PowerOutletTemplateViewSet(ModelViewSet):
     queryset = PowerOutletTemplate.objects.prefetch_related('device_type__manufacturer')
     queryset = PowerOutletTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.PowerOutletTemplateSerializer
     serializer_class = serializers.PowerOutletTemplateSerializer
-    filterset_class = filters.PowerOutletTemplateFilterSet
+    filterset_class = filtersets.PowerOutletTemplateFilterSet
 
 
 
 
 class InterfaceTemplateViewSet(ModelViewSet):
 class InterfaceTemplateViewSet(ModelViewSet):
     queryset = InterfaceTemplate.objects.prefetch_related('device_type__manufacturer')
     queryset = InterfaceTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.InterfaceTemplateSerializer
     serializer_class = serializers.InterfaceTemplateSerializer
-    filterset_class = filters.InterfaceTemplateFilterSet
+    filterset_class = filtersets.InterfaceTemplateFilterSet
 
 
 
 
 class FrontPortTemplateViewSet(ModelViewSet):
 class FrontPortTemplateViewSet(ModelViewSet):
     queryset = FrontPortTemplate.objects.prefetch_related('device_type__manufacturer')
     queryset = FrontPortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.FrontPortTemplateSerializer
     serializer_class = serializers.FrontPortTemplateSerializer
-    filterset_class = filters.FrontPortTemplateFilterSet
+    filterset_class = filtersets.FrontPortTemplateFilterSet
 
 
 
 
 class RearPortTemplateViewSet(ModelViewSet):
 class RearPortTemplateViewSet(ModelViewSet):
     queryset = RearPortTemplate.objects.prefetch_related('device_type__manufacturer')
     queryset = RearPortTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.RearPortTemplateSerializer
     serializer_class = serializers.RearPortTemplateSerializer
-    filterset_class = filters.RearPortTemplateFilterSet
+    filterset_class = filtersets.RearPortTemplateFilterSet
 
 
 
 
 class DeviceBayTemplateViewSet(ModelViewSet):
 class DeviceBayTemplateViewSet(ModelViewSet):
     queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer')
     queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.DeviceBayTemplateSerializer
     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')
         virtualmachine_count=count_related(VirtualMachine, 'role')
     )
     )
     serializer_class = serializers.DeviceRoleSerializer
     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')
         virtualmachine_count=count_related(VirtualMachine, 'platform')
     )
     )
     serializer_class = serializers.PlatformSerializer
     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',
         'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
         'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
         '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):
     def get_serializer_class(self):
         """
         """
@@ -510,7 +510,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
 class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
 class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
     queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
     queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
     serializer_class = serializers.ConsolePortSerializer
     serializer_class = serializers.ConsolePortSerializer
-    filterset_class = filters.ConsolePortFilterSet
+    filterset_class = filtersets.ConsolePortFilterSet
     brief_prefetch_fields = ['device']
     brief_prefetch_fields = ['device']
 
 
 
 
@@ -519,21 +519,21 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
         'device', '_path__destination', 'cable', '_cable_peer', 'tags'
         'device', '_path__destination', 'cable', '_cable_peer', 'tags'
     )
     )
     serializer_class = serializers.ConsoleServerPortSerializer
     serializer_class = serializers.ConsoleServerPortSerializer
-    filterset_class = filters.ConsoleServerPortFilterSet
+    filterset_class = filtersets.ConsoleServerPortFilterSet
     brief_prefetch_fields = ['device']
     brief_prefetch_fields = ['device']
 
 
 
 
 class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
 class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
     queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
     queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
     serializer_class = serializers.PowerPortSerializer
     serializer_class = serializers.PowerPortSerializer
-    filterset_class = filters.PowerPortFilterSet
+    filterset_class = filtersets.PowerPortFilterSet
     brief_prefetch_fields = ['device']
     brief_prefetch_fields = ['device']
 
 
 
 
 class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
 class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
     queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
     queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
     serializer_class = serializers.PowerOutletSerializer
     serializer_class = serializers.PowerOutletSerializer
-    filterset_class = filters.PowerOutletFilterSet
+    filterset_class = filtersets.PowerOutletFilterSet
     brief_prefetch_fields = ['device']
     brief_prefetch_fields = ['device']
 
 
 
 
@@ -542,35 +542,35 @@ class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
         'device', 'parent', 'lag', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags'
         'device', 'parent', 'lag', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags'
     )
     )
     serializer_class = serializers.InterfaceSerializer
     serializer_class = serializers.InterfaceSerializer
-    filterset_class = filters.InterfaceFilterSet
+    filterset_class = filtersets.InterfaceFilterSet
     brief_prefetch_fields = ['device']
     brief_prefetch_fields = ['device']
 
 
 
 
 class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
 class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
     queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
     queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
     serializer_class = serializers.FrontPortSerializer
     serializer_class = serializers.FrontPortSerializer
-    filterset_class = filters.FrontPortFilterSet
+    filterset_class = filtersets.FrontPortFilterSet
     brief_prefetch_fields = ['device']
     brief_prefetch_fields = ['device']
 
 
 
 
 class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
 class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
     queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
     queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
     serializer_class = serializers.RearPortSerializer
     serializer_class = serializers.RearPortSerializer
-    filterset_class = filters.RearPortFilterSet
+    filterset_class = filtersets.RearPortFilterSet
     brief_prefetch_fields = ['device']
     brief_prefetch_fields = ['device']
 
 
 
 
 class DeviceBayViewSet(ModelViewSet):
 class DeviceBayViewSet(ModelViewSet):
     queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags')
     queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags')
     serializer_class = serializers.DeviceBaySerializer
     serializer_class = serializers.DeviceBaySerializer
-    filterset_class = filters.DeviceBayFilterSet
+    filterset_class = filtersets.DeviceBayFilterSet
     brief_prefetch_fields = ['device']
     brief_prefetch_fields = ['device']
 
 
 
 
 class InventoryItemViewSet(ModelViewSet):
 class InventoryItemViewSet(ModelViewSet):
     queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags')
     queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags')
     serializer_class = serializers.InventoryItemSerializer
     serializer_class = serializers.InventoryItemSerializer
-    filterset_class = filters.InventoryItemFilterSet
+    filterset_class = filtersets.InventoryItemFilterSet
     brief_prefetch_fields = ['device']
     brief_prefetch_fields = ['device']
 
 
 
 
@@ -583,7 +583,7 @@ class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet):
         _path__destination_id__isnull=False
         _path__destination_id__isnull=False
     )
     )
     serializer_class = serializers.ConsolePortSerializer
     serializer_class = serializers.ConsolePortSerializer
-    filterset_class = filters.ConsoleConnectionFilterSet
+    filterset_class = filtersets.ConsoleConnectionFilterSet
 
 
 
 
 class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
 class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
@@ -591,7 +591,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
         _path__destination_id__isnull=False
         _path__destination_id__isnull=False
     )
     )
     serializer_class = serializers.PowerPortSerializer
     serializer_class = serializers.PowerPortSerializer
-    filterset_class = filters.PowerConnectionFilterSet
+    filterset_class = filtersets.PowerConnectionFilterSet
 
 
 
 
 class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
 class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
@@ -603,7 +603,7 @@ class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
         pk__lt=F('_path__destination_id')
         pk__lt=F('_path__destination_id')
     )
     )
     serializer_class = serializers.InterfaceConnectionSerializer
     serializer_class = serializers.InterfaceConnectionSerializer
-    filterset_class = filters.InterfaceConnectionFilterSet
+    filterset_class = filtersets.InterfaceConnectionFilterSet
 
 
 
 
 #
 #
@@ -616,7 +616,7 @@ class CableViewSet(ModelViewSet):
         'termination_a', 'termination_b'
         'termination_a', 'termination_b'
     )
     )
     serializer_class = serializers.CableSerializer
     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')
         member_count=count_related(Device, 'virtual_chassis')
     )
     )
     serializer_class = serializers.VirtualChassisSerializer
     serializer_class = serializers.VirtualChassisSerializer
-    filterset_class = filters.VirtualChassisFilterSet
+    filterset_class = filtersets.VirtualChassisFilterSet
     brief_prefetch_fields = ['master']
     brief_prefetch_fields = ['master']
 
 
 
 
@@ -643,7 +643,7 @@ class PowerPanelViewSet(ModelViewSet):
         powerfeed_count=count_related(PowerFeed, 'power_panel')
         powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
     )
     serializer_class = serializers.PowerPanelSerializer
     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'
         'power_panel', 'rack', '_path__destination', 'cable', '_cable_peer', 'tags'
     )
     )
     serializer_class = serializers.PowerFeedSerializer
     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_OM2 = 'mmf-om2'
     TYPE_MMF_OM3 = 'mmf-om3'
     TYPE_MMF_OM3 = 'mmf-om3'
     TYPE_MMF_OM4 = 'mmf-om4'
     TYPE_MMF_OM4 = 'mmf-om4'
+    TYPE_MMF_OM5 = 'mmf-om5'
     TYPE_SMF = 'smf'
     TYPE_SMF = 'smf'
     TYPE_SMF_OS1 = 'smf-os1'
     TYPE_SMF_OS1 = 'smf-os1'
     TYPE_SMF_OS2 = 'smf-os2'
     TYPE_SMF_OS2 = 'smf-os2'
@@ -1031,6 +1032,7 @@ class CableTypeChoices(ChoiceSet):
                 (TYPE_MMF_OM2, 'Multimode Fiber (OM2)'),
                 (TYPE_MMF_OM2, 'Multimode Fiber (OM2)'),
                 (TYPE_MMF_OM3, 'Multimode Fiber (OM3)'),
                 (TYPE_MMF_OM3, 'Multimode Fiber (OM3)'),
                 (TYPE_MMF_OM4, 'Multimode Fiber (OM4)'),
                 (TYPE_MMF_OM4, 'Multimode Fiber (OM4)'),
+                (TYPE_MMF_OM5, 'Multimode Fiber (OM5)'),
                 (TYPE_SMF, 'Singlemode Fiber'),
                 (TYPE_SMF, 'Singlemode Fiber'),
                 (TYPE_SMF_OS1, 'Singlemode Fiber (OS1)'),
                 (TYPE_SMF_OS1, 'Singlemode Fiber (OS1)'),
                 (TYPE_SMF_OS2, 'Singlemode Fiber (OS2)'),
                 (TYPE_SMF_OS2, 'Singlemode Fiber (OS2)'),

+ 3 - 2
netbox/dcim/elevations.py

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

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

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

+ 1 - 1
netbox/dcim/forms.py

@@ -2153,7 +2153,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
                 ip_choices = [(None, '---------')]
                 ip_choices = [(None, '---------')]
 
 
                 # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member
                 # 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
                 # Collect interface IPs
                 interface_ips = IPAddress.objects.filter(
                 interface_ips = IPAddress.objects.filter(

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

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

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

@@ -35,7 +35,7 @@ __all__ = (
 # Racks
 # Racks
 #
 #
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class RackRole(OrganizationalModel):
 class RackRole(OrganizationalModel):
     """
     """
     Racks can be organized by functional role, similar to Devices.
     Racks can be organized by functional role, similar to Devices.
@@ -209,7 +209,9 @@ class Rack(PrimaryModel):
         )
         )
 
 
     def __str__(self):
     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):
     def get_absolute_url(self):
         return reverse('dcim:rack', args=[self.pk])
         return reverse('dcim:rack', args=[self.pk])
@@ -277,12 +279,6 @@ class Rack(PrimaryModel):
         else:
         else:
             return reversed(range(1, self.u_height + 1))
             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):
     def get_status_class(self):
         return RackStatusChoices.CSS_CLASSES.get(self.status)
         return RackStatusChoices.CSS_CLASSES.get(self.status)
 
 

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

@@ -26,7 +26,7 @@ __all__ = (
 # Regions
 # Regions
 #
 #
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Region(NestedGroupModel):
 class Region(NestedGroupModel):
     """
     """
     A region represents a geographic collection of sites. For example, you might create regions representing countries,
     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
 # Site groups
 #
 #
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class SiteGroup(NestedGroupModel):
 class SiteGroup(NestedGroupModel):
     """
     """
     A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
     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
 # Locations
 #
 #
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Location(NestedGroupModel):
 class Location(NestedGroupModel):
     """
     """
     A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a
     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',
             'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses',
             'untagged_vlan', 'tagged_vlans', 'actions',
             'untagged_vlan', 'tagged_vlans', 'actions',
         )
         )
+        order_by = ('name',)
         default_columns = (
         default_columns = (
             'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
             'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
             'cable', 'connection', 'actions',
             'cable', 'connection', 'actions',

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

@@ -251,7 +251,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
 
 
 class RackTest(APIViewTestCases.APIViewTestCase):
 class RackTest(APIViewTestCases.APIViewTestCase):
     model = Rack
     model = Rack
-    brief_fields = ['device_count', 'display', 'display_name', 'id', 'name', 'url']
+    brief_fields = ['device_count', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'status': 'planned',
         'status': 'planned',
     }
     }
@@ -422,7 +422,7 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
 
 
 class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
 class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
     model = DeviceType
     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 = {
     bulk_update_data = {
         'part_number': 'ABC123',
         'part_number': 'ABC123',
     }
     }
@@ -870,7 +870,7 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
 
 
 class DeviceTest(APIViewTestCases.APIViewTestCase):
 class DeviceTest(APIViewTestCases.APIViewTestCase):
     model = Device
     model = Device
-    brief_fields = ['display', 'display_name', 'id', 'name', 'url']
+    brief_fields = ['display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'status': 'failed',
         '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 django.test import TestCase
 
 
 from dcim.choices import *
 from dcim.choices import *
-from dcim.filters import *
+from dcim.filtersets import *
 from dcim.models import *
 from dcim.models import *
 from ipam.models import IPAddress
 from ipam.models import IPAddress
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
+from utilities.testing import ChangeLoggedFilterSetTests
 from virtualization.models import Cluster, ClusterType
 from virtualization.models import Cluster, ClusterType
 
 
 
 
-class RegionTestCase(TestCase):
+class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Region.objects.all()
     queryset = Region.objects.all()
     filterset = RegionFilterSet
     filterset = RegionFilterSet
 
 
@@ -35,10 +36,6 @@ class RegionTestCase(TestCase):
         for region in child_regions:
         for region in child_regions:
             region.save()
             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):
     def test_name(self):
         params = {'name': ['Region 1', 'Region 2']}
         params = {'name': ['Region 1', 'Region 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
 
 
-class SiteGroupTestCase(TestCase):
+class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = SiteGroup.objects.all()
     queryset = SiteGroup.objects.all()
     filterset = SiteGroupFilterSet
     filterset = SiteGroupFilterSet
 
 
@@ -85,10 +82,6 @@ class SiteGroupTestCase(TestCase):
         for sitegroup in child_sitegroups:
         for sitegroup in child_sitegroups:
             sitegroup.save()
             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):
     def test_name(self):
         params = {'name': ['Site Group 1', 'Site Group 2']}
         params = {'name': ['Site Group 1', 'Site Group 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
 
 
-class SiteTestCase(TestCase):
+class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Site.objects.all()
     queryset = Site.objects.all()
     filterset = SiteFilterSet
     filterset = SiteFilterSet
 
 
@@ -154,10 +147,6 @@ class SiteTestCase(TestCase):
         )
         )
         Site.objects.bulk_create(sites)
         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):
     def test_name(self):
         params = {'name': ['Site 1', 'Site 2']}
         params = {'name': ['Site 1', 'Site 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class LocationTestCase(TestCase):
+class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Location.objects.all()
     queryset = Location.objects.all()
     filterset = LocationFilterSet
     filterset = LocationFilterSet
 
 
@@ -273,10 +262,6 @@ class LocationTestCase(TestCase):
         for location in locations:
         for location in locations:
             location.save()
             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):
     def test_name(self):
         params = {'name': ['Location 1', 'Location 2']}
         params = {'name': ['Location 1', 'Location 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class RackRoleTestCase(TestCase):
+class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RackRole.objects.all()
     queryset = RackRole.objects.all()
     filterset = RackRoleFilterSet
     filterset = RackRoleFilterSet
 
 
@@ -332,10 +317,6 @@ class RackRoleTestCase(TestCase):
         )
         )
         RackRole.objects.bulk_create(rack_roles)
         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):
     def test_name(self):
         params = {'name': ['Rack Role 1', 'Rack Role 2']}
         params = {'name': ['Rack Role 1', 'Rack Role 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class RackTestCase(TestCase):
+class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Rack.objects.all()
     queryset = Rack.objects.all()
     filterset = RackFilterSet
     filterset = RackFilterSet
 
 
@@ -416,10 +397,6 @@ class RackTestCase(TestCase):
         )
         )
         Rack.objects.bulk_create(racks)
         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):
     def test_name(self):
         params = {'name': ['Rack 1', 'Rack 2']}
         params = {'name': ['Rack 1', 'Rack 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class RackReservationTestCase(TestCase):
+class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RackReservation.objects.all()
     queryset = RackReservation.objects.all()
     filterset = RackReservationFilterSet
     filterset = RackReservationFilterSet
 
 
@@ -581,10 +558,6 @@ class RackReservationTestCase(TestCase):
         )
         )
         RackReservation.objects.bulk_create(reservations)
         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):
     def test_site(self):
         sites = Site.objects.all()[:2]
         sites = Site.objects.all()[:2]
         params = {'site_id': [sites[0].pk, sites[1].pk]}
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class ManufacturerTestCase(TestCase):
+class ManufacturerTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Manufacturer.objects.all()
     queryset = Manufacturer.objects.all()
     filterset = ManufacturerFilterSet
     filterset = ManufacturerFilterSet
 
 
@@ -635,10 +608,6 @@ class ManufacturerTestCase(TestCase):
         )
         )
         Manufacturer.objects.bulk_create(manufacturers)
         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):
     def test_name(self):
         params = {'name': ['Manufacturer 1', 'Manufacturer 2']}
         params = {'name': ['Manufacturer 1', 'Manufacturer 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class DeviceTypeTestCase(TestCase):
+class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = DeviceType.objects.all()
     queryset = DeviceType.objects.all()
     filterset = DeviceTypeFilterSet
     filterset = DeviceTypeFilterSet
 
 
@@ -708,10 +677,6 @@ class DeviceTypeTestCase(TestCase):
             DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2'),
             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):
     def test_model(self):
         params = {'model': ['Model 1', 'Model 2']}
         params = {'model': ['Model 1', 'Model 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class ConsolePortTemplateTestCase(TestCase):
+class ConsolePortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConsolePortTemplate.objects.all()
     queryset = ConsolePortTemplate.objects.all()
     filterset = ConsolePortTemplateFilterSet
     filterset = ConsolePortTemplateFilterSet
 
 
@@ -810,10 +775,6 @@ class ConsolePortTemplateTestCase(TestCase):
             ConsolePortTemplate(device_type=device_types[2], name='Console Port 3'),
             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):
     def test_name(self):
         params = {'name': ['Console Port 1', 'Console Port 2']}
         params = {'name': ['Console Port 1', 'Console Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class ConsoleServerPortTemplateTestCase(TestCase):
+class ConsoleServerPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConsoleServerPortTemplate.objects.all()
     queryset = ConsoleServerPortTemplate.objects.all()
     filterset = ConsoleServerPortTemplateFilterSet
     filterset = ConsoleServerPortTemplateFilterSet
 
 
@@ -846,10 +807,6 @@ class ConsoleServerPortTemplateTestCase(TestCase):
             ConsoleServerPortTemplate(device_type=device_types[2], name='Console Server Port 3'),
             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):
     def test_name(self):
         params = {'name': ['Console Server Port 1', 'Console Server Port 2']}
         params = {'name': ['Console Server Port 1', 'Console Server Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class PowerPortTemplateTestCase(TestCase):
+class PowerPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = PowerPortTemplate.objects.all()
     queryset = PowerPortTemplate.objects.all()
     filterset = PowerPortTemplateFilterSet
     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),
             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):
     def test_name(self):
         params = {'name': ['Power Port 1', 'Power Port 2']}
         params = {'name': ['Power Port 1', 'Power Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class PowerOutletTemplateTestCase(TestCase):
+class PowerOutletTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = PowerOutletTemplate.objects.all()
     queryset = PowerOutletTemplate.objects.all()
     filterset = PowerOutletTemplateFilterSet
     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),
             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):
     def test_name(self):
         params = {'name': ['Power Outlet 1', 'Power Outlet 2']}
         params = {'name': ['Power Outlet 1', 'Power Outlet 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class InterfaceTemplateTestCase(TestCase):
+class InterfaceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = InterfaceTemplate.objects.all()
     queryset = InterfaceTemplate.objects.all()
     filterset = InterfaceTemplateFilterSet
     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),
             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):
     def test_name(self):
         params = {'name': ['Interface 1', 'Interface 2']}
         params = {'name': ['Interface 1', 'Interface 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class FrontPortTemplateTestCase(TestCase):
+class FrontPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = FrontPortTemplate.objects.all()
     queryset = FrontPortTemplate.objects.all()
     filterset = FrontPortTemplateFilterSet
     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),
             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):
     def test_name(self):
         params = {'name': ['Front Port 1', 'Front Port 2']}
         params = {'name': ['Front Port 1', 'Front Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class RearPortTemplateTestCase(TestCase):
+class RearPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RearPortTemplate.objects.all()
     queryset = RearPortTemplate.objects.all()
     filterset = RearPortTemplateFilterSet
     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),
             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):
     def test_name(self):
         params = {'name': ['Rear Port 1', 'Rear Port 2']}
         params = {'name': ['Rear Port 1', 'Rear Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class DeviceBayTemplateTestCase(TestCase):
+class DeviceBayTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = DeviceBayTemplate.objects.all()
     queryset = DeviceBayTemplate.objects.all()
     filterset = DeviceBayTemplateFilterSet
     filterset = DeviceBayTemplateFilterSet
 
 
@@ -1103,10 +1040,6 @@ class DeviceBayTemplateTestCase(TestCase):
             DeviceBayTemplate(device_type=device_types[2], name='Device Bay 3'),
             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):
     def test_name(self):
         params = {'name': ['Device Bay 1', 'Device Bay 2']}
         params = {'name': ['Device Bay 1', 'Device Bay 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class DeviceRoleTestCase(TestCase):
+class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = DeviceRole.objects.all()
     queryset = DeviceRole.objects.all()
     filterset = DeviceRoleFilterSet
     filterset = DeviceRoleFilterSet
 
 
@@ -1131,10 +1064,6 @@ class DeviceRoleTestCase(TestCase):
         )
         )
         DeviceRole.objects.bulk_create(device_roles)
         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):
     def test_name(self):
         params = {'name': ['Device Role 1', 'Device Role 2']}
         params = {'name': ['Device Role 1', 'Device Role 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class PlatformTestCase(TestCase):
+class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Platform.objects.all()
     queryset = Platform.objects.all()
     filterset = PlatformFilterSet
     filterset = PlatformFilterSet
 
 
@@ -1175,10 +1104,6 @@ class PlatformTestCase(TestCase):
         )
         )
         Platform.objects.bulk_create(platforms)
         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):
     def test_name(self):
         params = {'name': ['Platform 1', 'Platform 2']}
         params = {'name': ['Platform 1', 'Platform 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class DeviceTestCase(TestCase):
+class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Device.objects.all()
     queryset = Device.objects.all()
     filterset = DeviceFilterSet
     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[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)
         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):
     def test_name(self):
         params = {'name': ['Device 1', 'Device 2']}
         params = {'name': ['Device 1', 'Device 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class ConsolePortTestCase(TestCase):
+class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConsolePort.objects.all()
     queryset = ConsolePort.objects.all()
     filterset = ConsolePortFilterSet
     filterset = ConsolePortFilterSet
 
 
@@ -1608,10 +1529,6 @@ class ConsolePortTestCase(TestCase):
         Cable(termination_a=console_ports[1], termination_b=console_server_ports[1]).save()
         Cable(termination_a=console_ports[1], termination_b=console_server_ports[1]).save()
         # Third port is not connected
         # 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):
     def test_name(self):
         params = {'name': ['Console Port 1', 'Console Port 2']}
         params = {'name': ['Console Port 1', 'Console Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class ConsoleServerPortTestCase(TestCase):
+class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
     filterset = ConsoleServerPortFilterSet
     filterset = ConsoleServerPortFilterSet
 
 
@@ -1724,10 +1641,6 @@ class ConsoleServerPortTestCase(TestCase):
         Cable(termination_a=console_server_ports[1], termination_b=console_ports[1]).save()
         Cable(termination_a=console_server_ports[1], termination_b=console_ports[1]).save()
         # Third port is not connected
         # 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):
     def test_name(self):
         params = {'name': ['Console Server Port 1', 'Console Server Port 2']}
         params = {'name': ['Console Server Port 1', 'Console Server Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class PowerPortTestCase(TestCase):
+class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = PowerPort.objects.all()
     queryset = PowerPort.objects.all()
     filterset = PowerPortFilterSet
     filterset = PowerPortFilterSet
 
 
@@ -1840,10 +1753,6 @@ class PowerPortTestCase(TestCase):
         Cable(termination_a=power_ports[1], termination_b=power_outlets[1]).save()
         Cable(termination_a=power_ports[1], termination_b=power_outlets[1]).save()
         # Third port is not connected
         # 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):
     def test_name(self):
         params = {'name': ['Power Port 1', 'Power Port 2']}
         params = {'name': ['Power Port 1', 'Power Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class PowerOutletTestCase(TestCase):
+class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
     filterset = PowerOutletFilterSet
     filterset = PowerOutletFilterSet
 
 
@@ -1964,10 +1873,6 @@ class PowerOutletTestCase(TestCase):
         Cable(termination_a=power_outlets[1], termination_b=power_ports[1]).save()
         Cable(termination_a=power_outlets[1], termination_b=power_ports[1]).save()
         # Third port is not connected
         # 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):
     def test_name(self):
         params = {'name': ['Power Outlet 1', 'Power Outlet 2']}
         params = {'name': ['Power Outlet 1', 'Power Outlet 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class InterfaceTestCase(TestCase):
+class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
     filterset = InterfaceFilterSet
     filterset = InterfaceFilterSet
 
 
@@ -2081,10 +1986,6 @@ class InterfaceTestCase(TestCase):
         Cable(termination_a=interfaces[1], termination_b=interfaces[4]).save()
         Cable(termination_a=interfaces[1], termination_b=interfaces[4]).save()
         # Third pair is not connected
         # 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):
     def test_name(self):
         params = {'name': ['Interface 1', 'Interface 2']}
         params = {'name': ['Interface 1', 'Interface 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class FrontPortTestCase(TestCase):
+class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
     filterset = FrontPortFilterSet
     filterset = FrontPortFilterSet
 
 
@@ -2266,10 +2167,6 @@ class FrontPortTestCase(TestCase):
         Cable(termination_a=front_ports[1], termination_b=front_ports[4]).save()
         Cable(termination_a=front_ports[1], termination_b=front_ports[4]).save()
         # Third port is not connected
         # 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):
     def test_name(self):
         params = {'name': ['Front Port 1', 'Front Port 2']}
         params = {'name': ['Front Port 1', 'Front Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class RearPortTestCase(TestCase):
+class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
     filterset = RearPortFilterSet
     filterset = RearPortFilterSet
 
 
@@ -2377,10 +2274,6 @@ class RearPortTestCase(TestCase):
         Cable(termination_a=rear_ports[1], termination_b=rear_ports[4]).save()
         Cable(termination_a=rear_ports[1], termination_b=rear_ports[4]).save()
         # Third port is not connected
         # 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):
     def test_name(self):
         params = {'name': ['Rear Port 1', 'Rear Port 2']}
         params = {'name': ['Rear Port 1', 'Rear Port 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class DeviceBayTestCase(TestCase):
+class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = DeviceBay.objects.all()
     queryset = DeviceBay.objects.all()
     filterset = DeviceBayFilterSet
     filterset = DeviceBayFilterSet
 
 
@@ -2483,10 +2376,6 @@ class DeviceBayTestCase(TestCase):
         )
         )
         DeviceBay.objects.bulk_create(device_bays)
         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):
     def test_name(self):
         params = {'name': ['Device Bay 1', 'Device Bay 2']}
         params = {'name': ['Device Bay 1', 'Device Bay 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class InventoryItemTestCase(TestCase):
+class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = InventoryItem.objects.all()
     queryset = InventoryItem.objects.all()
     filterset = InventoryItemFilterSet
     filterset = InventoryItemFilterSet
 
 
@@ -2591,10 +2480,6 @@ class InventoryItemTestCase(TestCase):
         for i in child_inventory_items:
         for i in child_inventory_items:
             i.save()
             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):
     def test_name(self):
         params = {'name': ['Inventory Item 1', 'Inventory Item 2']}
         params = {'name': ['Inventory Item 1', 'Inventory Item 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class VirtualChassisTestCase(TestCase):
+class VirtualChassisTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VirtualChassis.objects.all()
     queryset = VirtualChassis.objects.all()
     filterset = VirtualChassisFilterSet
     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[3].pk).update(virtual_chassis=virtual_chassis[1])
         Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[2])
         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):
     def test_domain(self):
         params = {'domain': ['Domain 1', 'Domain 2']}
         params = {'domain': ['Domain 1', 'Domain 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class CableTestCase(TestCase):
+class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Cable.objects.all()
     queryset = Cable.objects.all()
     filterset = CableFilterSet
     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[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()
         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):
     def test_label(self):
         params = {'label': ['Cable 1', 'Cable 2']}
         params = {'label': ['Cable 1', 'Cable 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
 
 
-class PowerPanelTestCase(TestCase):
+class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = PowerPanel.objects.all()
     queryset = PowerPanel.objects.all()
     filterset = PowerPanelFilterSet
     filterset = PowerPanelFilterSet
 
 
@@ -2931,10 +2808,6 @@ class PowerPanelTestCase(TestCase):
         )
         )
         PowerPanel.objects.bulk_create(power_panels)
         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):
     def test_name(self):
         params = {'name': ['Power Panel 1', 'Power Panel 2']}
         params = {'name': ['Power Panel 1', 'Power Panel 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class PowerFeedTestCase(TestCase):
+class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = PowerFeed.objects.all()
     queryset = PowerFeed.objects.all()
     filterset = PowerFeedFilterSet
     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[0], termination_b=power_ports[0]).save()
         Cable(termination_a=power_feeds[1], termination_b=power_ports[1]).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):
     def test_name(self):
         params = {'name': ['Power Feed 1', 'Power Feed 2']}
         params = {'name': ['Power Feed 1', 'Power Feed 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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.utils import csv_format, count_related
 from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
 from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
-from . import filters, forms, tables
+from . import filtersets, forms, tables
 from .choices import DeviceFaceChoices
 from .choices import DeviceFaceChoices
 from .constants import NONCONNECTABLE_IFACE_TYPES
 from .constants import NONCONNECTABLE_IFACE_TYPES
 from .models import (
 from .models import (
@@ -110,7 +110,7 @@ class RegionListView(generic.ObjectListView):
         'site_count',
         'site_count',
         cumulative=True
         cumulative=True
     )
     )
-    filterset = filters.RegionFilterSet
+    filterset = filtersets.RegionFilterSet
     filterset_form = forms.RegionFilterForm
     filterset_form = forms.RegionFilterForm
     table = tables.RegionTable
     table = tables.RegionTable
 
 
@@ -166,7 +166,7 @@ class RegionBulkEditView(generic.BulkEditView):
         'site_count',
         'site_count',
         cumulative=True
         cumulative=True
     )
     )
-    filterset = filters.RegionFilterSet
+    filterset = filtersets.RegionFilterSet
     table = tables.RegionTable
     table = tables.RegionTable
     form = forms.RegionBulkEditForm
     form = forms.RegionBulkEditForm
 
 
@@ -179,7 +179,7 @@ class RegionBulkDeleteView(generic.BulkDeleteView):
         'site_count',
         'site_count',
         cumulative=True
         cumulative=True
     )
     )
-    filterset = filters.RegionFilterSet
+    filterset = filtersets.RegionFilterSet
     table = tables.RegionTable
     table = tables.RegionTable
 
 
 
 
@@ -195,7 +195,7 @@ class SiteGroupListView(generic.ObjectListView):
         'site_count',
         'site_count',
         cumulative=True
         cumulative=True
     )
     )
-    filterset = filters.SiteGroupFilterSet
+    filterset = filtersets.SiteGroupFilterSet
     filterset_form = forms.SiteGroupFilterForm
     filterset_form = forms.SiteGroupFilterForm
     table = tables.SiteGroupTable
     table = tables.SiteGroupTable
 
 
@@ -251,7 +251,7 @@ class SiteGroupBulkEditView(generic.BulkEditView):
         'site_count',
         'site_count',
         cumulative=True
         cumulative=True
     )
     )
-    filterset = filters.SiteGroupFilterSet
+    filterset = filtersets.SiteGroupFilterSet
     table = tables.SiteGroupTable
     table = tables.SiteGroupTable
     form = forms.SiteGroupBulkEditForm
     form = forms.SiteGroupBulkEditForm
 
 
@@ -264,7 +264,7 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
         'site_count',
         'site_count',
         cumulative=True
         cumulative=True
     )
     )
-    filterset = filters.SiteGroupFilterSet
+    filterset = filtersets.SiteGroupFilterSet
     table = tables.SiteGroupTable
     table = tables.SiteGroupTable
 
 
 
 
@@ -274,7 +274,7 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
 
 
 class SiteListView(generic.ObjectListView):
 class SiteListView(generic.ObjectListView):
     queryset = Site.objects.all()
     queryset = Site.objects.all()
-    filterset = filters.SiteFilterSet
+    filterset = filtersets.SiteFilterSet
     filterset_form = forms.SiteFilterForm
     filterset_form = forms.SiteFilterForm
     table = tables.SiteTable
     table = tables.SiteTable
 
 
@@ -329,14 +329,14 @@ class SiteBulkImportView(generic.BulkImportView):
 
 
 class SiteBulkEditView(generic.BulkEditView):
 class SiteBulkEditView(generic.BulkEditView):
     queryset = Site.objects.prefetch_related('region', 'tenant')
     queryset = Site.objects.prefetch_related('region', 'tenant')
-    filterset = filters.SiteFilterSet
+    filterset = filtersets.SiteFilterSet
     table = tables.SiteTable
     table = tables.SiteTable
     form = forms.SiteBulkEditForm
     form = forms.SiteBulkEditForm
 
 
 
 
 class SiteBulkDeleteView(generic.BulkDeleteView):
 class SiteBulkDeleteView(generic.BulkDeleteView):
     queryset = Site.objects.prefetch_related('region', 'tenant')
     queryset = Site.objects.prefetch_related('region', 'tenant')
-    filterset = filters.SiteFilterSet
+    filterset = filtersets.SiteFilterSet
     table = tables.SiteTable
     table = tables.SiteTable
 
 
 
 
@@ -358,7 +358,7 @@ class LocationListView(generic.ObjectListView):
         'rack_count',
         'rack_count',
         cumulative=True
         cumulative=True
     )
     )
-    filterset = filters.LocationFilterSet
+    filterset = filtersets.LocationFilterSet
     filterset_form = forms.LocationFilterForm
     filterset_form = forms.LocationFilterForm
     table = tables.LocationTable
     table = tables.LocationTable
 
 
@@ -417,7 +417,7 @@ class LocationBulkEditView(generic.BulkEditView):
         'rack_count',
         'rack_count',
         cumulative=True
         cumulative=True
     ).prefetch_related('site')
     ).prefetch_related('site')
-    filterset = filters.LocationFilterSet
+    filterset = filtersets.LocationFilterSet
     table = tables.LocationTable
     table = tables.LocationTable
     form = forms.LocationBulkEditForm
     form = forms.LocationBulkEditForm
 
 
@@ -430,7 +430,7 @@ class LocationBulkDeleteView(generic.BulkDeleteView):
         'rack_count',
         'rack_count',
         cumulative=True
         cumulative=True
     ).prefetch_related('site')
     ).prefetch_related('site')
-    filterset = filters.LocationFilterSet
+    filterset = filtersets.LocationFilterSet
     table = tables.LocationTable
     table = tables.LocationTable
 
 
 
 
@@ -481,7 +481,7 @@ class RackRoleBulkEditView(generic.BulkEditView):
     queryset = RackRole.objects.annotate(
     queryset = RackRole.objects.annotate(
         rack_count=count_related(Rack, 'role')
         rack_count=count_related(Rack, 'role')
     )
     )
-    filterset = filters.RackRoleFilterSet
+    filterset = filtersets.RackRoleFilterSet
     table = tables.RackRoleTable
     table = tables.RackRoleTable
     form = forms.RackRoleBulkEditForm
     form = forms.RackRoleBulkEditForm
 
 
@@ -503,7 +503,7 @@ class RackListView(generic.ObjectListView):
     ).annotate(
     ).annotate(
         device_count=count_related(Device, 'rack')
         device_count=count_related(Device, 'rack')
     )
     )
-    filterset = filters.RackFilterSet
+    filterset = filtersets.RackFilterSet
     filterset_form = forms.RackFilterForm
     filterset_form = forms.RackFilterForm
     table = tables.RackDetailTable
     table = tables.RackDetailTable
 
 
@@ -516,7 +516,7 @@ class RackElevationListView(generic.ObjectListView):
 
 
     def get(self, request):
     def get(self, request):
 
 
-        racks = filters.RackFilterSet(request.GET, self.queryset).qs
+        racks = filtersets.RackFilterSet(request.GET, self.queryset).qs
         total_count = racks.count()
         total_count = racks.count()
 
 
         # Determine ordering
         # Determine ordering
@@ -605,14 +605,14 @@ class RackBulkImportView(generic.BulkImportView):
 
 
 class RackBulkEditView(generic.BulkEditView):
 class RackBulkEditView(generic.BulkEditView):
     queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
     queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
-    filterset = filters.RackFilterSet
+    filterset = filtersets.RackFilterSet
     table = tables.RackTable
     table = tables.RackTable
     form = forms.RackBulkEditForm
     form = forms.RackBulkEditForm
 
 
 
 
 class RackBulkDeleteView(generic.BulkDeleteView):
 class RackBulkDeleteView(generic.BulkDeleteView):
     queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
     queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
-    filterset = filters.RackFilterSet
+    filterset = filtersets.RackFilterSet
     table = tables.RackTable
     table = tables.RackTable
 
 
 
 
@@ -622,7 +622,7 @@ class RackBulkDeleteView(generic.BulkDeleteView):
 
 
 class RackReservationListView(generic.ObjectListView):
 class RackReservationListView(generic.ObjectListView):
     queryset = RackReservation.objects.all()
     queryset = RackReservation.objects.all()
-    filterset = filters.RackReservationFilterSet
+    filterset = filtersets.RackReservationFilterSet
     filterset_form = forms.RackReservationFilterForm
     filterset_form = forms.RackReservationFilterForm
     table = tables.RackReservationTable
     table = tables.RackReservationTable
 
 
@@ -665,14 +665,14 @@ class RackReservationImportView(generic.BulkImportView):
 
 
 class RackReservationBulkEditView(generic.BulkEditView):
 class RackReservationBulkEditView(generic.BulkEditView):
     queryset = RackReservation.objects.prefetch_related('rack', 'user')
     queryset = RackReservation.objects.prefetch_related('rack', 'user')
-    filterset = filters.RackReservationFilterSet
+    filterset = filtersets.RackReservationFilterSet
     table = tables.RackReservationTable
     table = tables.RackReservationTable
     form = forms.RackReservationBulkEditForm
     form = forms.RackReservationBulkEditForm
 
 
 
 
 class RackReservationBulkDeleteView(generic.BulkDeleteView):
 class RackReservationBulkDeleteView(generic.BulkDeleteView):
     queryset = RackReservation.objects.prefetch_related('rack', 'user')
     queryset = RackReservation.objects.prefetch_related('rack', 'user')
-    filterset = filters.RackReservationFilterSet
+    filterset = filtersets.RackReservationFilterSet
     table = tables.RackReservationTable
     table = tables.RackReservationTable
 
 
 
 
@@ -695,6 +695,8 @@ class ManufacturerView(generic.ObjectView):
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         devicetypes = DeviceType.objects.restrict(request.user, 'view').filter(
         devicetypes = DeviceType.objects.restrict(request.user, 'view').filter(
             manufacturer=instance
             manufacturer=instance
+        ).annotate(
+            instance_count=count_related(Device, 'device_type')
         )
         )
 
 
         devicetypes_table = tables.DeviceTypeTable(devicetypes)
         devicetypes_table = tables.DeviceTypeTable(devicetypes)
@@ -725,7 +727,7 @@ class ManufacturerBulkEditView(generic.BulkEditView):
     queryset = Manufacturer.objects.annotate(
     queryset = Manufacturer.objects.annotate(
         devicetype_count=count_related(DeviceType, 'manufacturer')
         devicetype_count=count_related(DeviceType, 'manufacturer')
     )
     )
-    filterset = filters.ManufacturerFilterSet
+    filterset = filtersets.ManufacturerFilterSet
     table = tables.ManufacturerTable
     table = tables.ManufacturerTable
     form = forms.ManufacturerBulkEditForm
     form = forms.ManufacturerBulkEditForm
 
 
@@ -745,7 +747,7 @@ class DeviceTypeListView(generic.ObjectListView):
     queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
     queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
         instance_count=count_related(Device, 'device_type')
         instance_count=count_related(Device, 'device_type')
     )
     )
-    filterset = filters.DeviceTypeFilterSet
+    filterset = filtersets.DeviceTypeFilterSet
     filterset_form = forms.DeviceTypeFilterForm
     filterset_form = forms.DeviceTypeFilterForm
     table = tables.DeviceTypeTable
     table = tables.DeviceTypeTable
 
 
@@ -851,7 +853,7 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
     queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
     queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
         instance_count=count_related(Device, 'device_type')
         instance_count=count_related(Device, 'device_type')
     )
     )
-    filterset = filters.DeviceTypeFilterSet
+    filterset = filtersets.DeviceTypeFilterSet
     table = tables.DeviceTypeTable
     table = tables.DeviceTypeTable
     form = forms.DeviceTypeBulkEditForm
     form = forms.DeviceTypeBulkEditForm
 
 
@@ -860,7 +862,7 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
     queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
         instance_count=count_related(Device, 'device_type')
         instance_count=count_related(Device, 'device_type')
     )
     )
-    filterset = filters.DeviceTypeFilterSet
+    filterset = filtersets.DeviceTypeFilterSet
     table = tables.DeviceTypeTable
     table = tables.DeviceTypeTable
 
 
 
 
@@ -1193,7 +1195,7 @@ class DeviceRoleBulkEditView(generic.BulkEditView):
         device_count=count_related(Device, 'device_role'),
         device_count=count_related(Device, 'device_role'),
         vm_count=count_related(VirtualMachine, 'role')
         vm_count=count_related(VirtualMachine, 'role')
     )
     )
-    filterset = filters.DeviceRoleFilterSet
+    filterset = filtersets.DeviceRoleFilterSet
     table = tables.DeviceRoleTable
     table = tables.DeviceRoleTable
     form = forms.DeviceRoleBulkEditForm
     form = forms.DeviceRoleBulkEditForm
 
 
@@ -1252,7 +1254,7 @@ class PlatformBulkImportView(generic.BulkImportView):
 
 
 class PlatformBulkEditView(generic.BulkEditView):
 class PlatformBulkEditView(generic.BulkEditView):
     queryset = Platform.objects.all()
     queryset = Platform.objects.all()
-    filterset = filters.PlatformFilterSet
+    filterset = filtersets.PlatformFilterSet
     table = tables.PlatformTable
     table = tables.PlatformTable
     form = forms.PlatformBulkEditForm
     form = forms.PlatformBulkEditForm
 
 
@@ -1268,7 +1270,7 @@ class PlatformBulkDeleteView(generic.BulkDeleteView):
 
 
 class DeviceListView(generic.ObjectListView):
 class DeviceListView(generic.ObjectListView):
     queryset = Device.objects.all()
     queryset = Device.objects.all()
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     filterset_form = forms.DeviceFilterForm
     filterset_form = forms.DeviceFilterForm
     table = tables.DeviceTable
     table = tables.DeviceTable
     template_name = 'dcim/device_list.html'
     template_name = 'dcim/device_list.html'
@@ -1408,7 +1410,7 @@ class DeviceInterfacesView(generic.ObjectView):
     template_name = 'dcim/device/interfaces.html'
     template_name = 'dcim/device/interfaces.html'
 
 
     def get_extra_context(self, request, instance):
     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('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
             Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
             Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
             'lag', 'cable', '_path__destination', 'tags',
             'lag', 'cable', '_path__destination', 'tags',
@@ -1530,7 +1532,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView):
     template_name = 'dcim/device/lldp_neighbors.html'
     template_name = 'dcim/device/lldp_neighbors.html'
 
 
     def get_extra_context(self, request, instance):
     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'
             '_path__destination'
         ).exclude(
         ).exclude(
             type__in=NONCONNECTABLE_IFACE_TYPES
             type__in=NONCONNECTABLE_IFACE_TYPES
@@ -1603,14 +1605,14 @@ class ChildDeviceBulkImportView(generic.BulkImportView):
 
 
 class DeviceBulkEditView(generic.BulkEditView):
 class DeviceBulkEditView(generic.BulkEditView):
     queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
     queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     table = tables.DeviceTable
     form = forms.DeviceBulkEditForm
     form = forms.DeviceBulkEditForm
 
 
 
 
 class DeviceBulkDeleteView(generic.BulkDeleteView):
 class DeviceBulkDeleteView(generic.BulkDeleteView):
     queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
     queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     table = tables.DeviceTable
 
 
 
 
@@ -1620,7 +1622,7 @@ class DeviceBulkDeleteView(generic.BulkDeleteView):
 
 
 class ConsolePortListView(generic.ObjectListView):
 class ConsolePortListView(generic.ObjectListView):
     queryset = ConsolePort.objects.all()
     queryset = ConsolePort.objects.all()
-    filterset = filters.ConsolePortFilterSet
+    filterset = filtersets.ConsolePortFilterSet
     filterset_form = forms.ConsolePortFilterForm
     filterset_form = forms.ConsolePortFilterForm
     table = tables.ConsolePortTable
     table = tables.ConsolePortTable
     action_buttons = ('import', 'export')
     action_buttons = ('import', 'export')
@@ -1655,7 +1657,7 @@ class ConsolePortBulkImportView(generic.BulkImportView):
 
 
 class ConsolePortBulkEditView(generic.BulkEditView):
 class ConsolePortBulkEditView(generic.BulkEditView):
     queryset = ConsolePort.objects.all()
     queryset = ConsolePort.objects.all()
-    filterset = filters.ConsolePortFilterSet
+    filterset = filtersets.ConsolePortFilterSet
     table = tables.ConsolePortTable
     table = tables.ConsolePortTable
     form = forms.ConsolePortBulkEditForm
     form = forms.ConsolePortBulkEditForm
 
 
@@ -1670,7 +1672,7 @@ class ConsolePortBulkDisconnectView(BulkDisconnectView):
 
 
 class ConsolePortBulkDeleteView(generic.BulkDeleteView):
 class ConsolePortBulkDeleteView(generic.BulkDeleteView):
     queryset = ConsolePort.objects.all()
     queryset = ConsolePort.objects.all()
-    filterset = filters.ConsolePortFilterSet
+    filterset = filtersets.ConsolePortFilterSet
     table = tables.ConsolePortTable
     table = tables.ConsolePortTable
 
 
 
 
@@ -1680,7 +1682,7 @@ class ConsolePortBulkDeleteView(generic.BulkDeleteView):
 
 
 class ConsoleServerPortListView(generic.ObjectListView):
 class ConsoleServerPortListView(generic.ObjectListView):
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
-    filterset = filters.ConsoleServerPortFilterSet
+    filterset = filtersets.ConsoleServerPortFilterSet
     filterset_form = forms.ConsoleServerPortFilterForm
     filterset_form = forms.ConsoleServerPortFilterForm
     table = tables.ConsoleServerPortTable
     table = tables.ConsoleServerPortTable
     action_buttons = ('import', 'export')
     action_buttons = ('import', 'export')
@@ -1715,7 +1717,7 @@ class ConsoleServerPortBulkImportView(generic.BulkImportView):
 
 
 class ConsoleServerPortBulkEditView(generic.BulkEditView):
 class ConsoleServerPortBulkEditView(generic.BulkEditView):
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
-    filterset = filters.ConsoleServerPortFilterSet
+    filterset = filtersets.ConsoleServerPortFilterSet
     table = tables.ConsoleServerPortTable
     table = tables.ConsoleServerPortTable
     form = forms.ConsoleServerPortBulkEditForm
     form = forms.ConsoleServerPortBulkEditForm
 
 
@@ -1730,7 +1732,7 @@ class ConsoleServerPortBulkDisconnectView(BulkDisconnectView):
 
 
 class ConsoleServerPortBulkDeleteView(generic.BulkDeleteView):
 class ConsoleServerPortBulkDeleteView(generic.BulkDeleteView):
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
-    filterset = filters.ConsoleServerPortFilterSet
+    filterset = filtersets.ConsoleServerPortFilterSet
     table = tables.ConsoleServerPortTable
     table = tables.ConsoleServerPortTable
 
 
 
 
@@ -1740,7 +1742,7 @@ class ConsoleServerPortBulkDeleteView(generic.BulkDeleteView):
 
 
 class PowerPortListView(generic.ObjectListView):
 class PowerPortListView(generic.ObjectListView):
     queryset = PowerPort.objects.all()
     queryset = PowerPort.objects.all()
-    filterset = filters.PowerPortFilterSet
+    filterset = filtersets.PowerPortFilterSet
     filterset_form = forms.PowerPortFilterForm
     filterset_form = forms.PowerPortFilterForm
     table = tables.PowerPortTable
     table = tables.PowerPortTable
     action_buttons = ('import', 'export')
     action_buttons = ('import', 'export')
@@ -1775,7 +1777,7 @@ class PowerPortBulkImportView(generic.BulkImportView):
 
 
 class PowerPortBulkEditView(generic.BulkEditView):
 class PowerPortBulkEditView(generic.BulkEditView):
     queryset = PowerPort.objects.all()
     queryset = PowerPort.objects.all()
-    filterset = filters.PowerPortFilterSet
+    filterset = filtersets.PowerPortFilterSet
     table = tables.PowerPortTable
     table = tables.PowerPortTable
     form = forms.PowerPortBulkEditForm
     form = forms.PowerPortBulkEditForm
 
 
@@ -1790,7 +1792,7 @@ class PowerPortBulkDisconnectView(BulkDisconnectView):
 
 
 class PowerPortBulkDeleteView(generic.BulkDeleteView):
 class PowerPortBulkDeleteView(generic.BulkDeleteView):
     queryset = PowerPort.objects.all()
     queryset = PowerPort.objects.all()
-    filterset = filters.PowerPortFilterSet
+    filterset = filtersets.PowerPortFilterSet
     table = tables.PowerPortTable
     table = tables.PowerPortTable
 
 
 
 
@@ -1800,7 +1802,7 @@ class PowerPortBulkDeleteView(generic.BulkDeleteView):
 
 
 class PowerOutletListView(generic.ObjectListView):
 class PowerOutletListView(generic.ObjectListView):
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
-    filterset = filters.PowerOutletFilterSet
+    filterset = filtersets.PowerOutletFilterSet
     filterset_form = forms.PowerOutletFilterForm
     filterset_form = forms.PowerOutletFilterForm
     table = tables.PowerOutletTable
     table = tables.PowerOutletTable
     action_buttons = ('import', 'export')
     action_buttons = ('import', 'export')
@@ -1835,7 +1837,7 @@ class PowerOutletBulkImportView(generic.BulkImportView):
 
 
 class PowerOutletBulkEditView(generic.BulkEditView):
 class PowerOutletBulkEditView(generic.BulkEditView):
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
-    filterset = filters.PowerOutletFilterSet
+    filterset = filtersets.PowerOutletFilterSet
     table = tables.PowerOutletTable
     table = tables.PowerOutletTable
     form = forms.PowerOutletBulkEditForm
     form = forms.PowerOutletBulkEditForm
 
 
@@ -1850,7 +1852,7 @@ class PowerOutletBulkDisconnectView(BulkDisconnectView):
 
 
 class PowerOutletBulkDeleteView(generic.BulkDeleteView):
 class PowerOutletBulkDeleteView(generic.BulkDeleteView):
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
-    filterset = filters.PowerOutletFilterSet
+    filterset = filtersets.PowerOutletFilterSet
     table = tables.PowerOutletTable
     table = tables.PowerOutletTable
 
 
 
 
@@ -1860,7 +1862,7 @@ class PowerOutletBulkDeleteView(generic.BulkDeleteView):
 
 
 class InterfaceListView(generic.ObjectListView):
 class InterfaceListView(generic.ObjectListView):
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
-    filterset = filters.InterfaceFilterSet
+    filterset = filtersets.InterfaceFilterSet
     filterset_form = forms.InterfaceFilterForm
     filterset_form = forms.InterfaceFilterForm
     table = tables.InterfaceTable
     table = tables.InterfaceTable
     action_buttons = ('import', 'export')
     action_buttons = ('import', 'export')
@@ -1997,7 +1999,7 @@ class InterfaceBulkImportView(generic.BulkImportView):
 
 
 class InterfaceBulkEditView(generic.BulkEditView):
 class InterfaceBulkEditView(generic.BulkEditView):
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
-    filterset = filters.InterfaceFilterSet
+    filterset = filtersets.InterfaceFilterSet
     table = tables.InterfaceTable
     table = tables.InterfaceTable
     form = forms.InterfaceBulkEditForm
     form = forms.InterfaceBulkEditForm
 
 
@@ -2012,7 +2014,7 @@ class InterfaceBulkDisconnectView(BulkDisconnectView):
 
 
 class InterfaceBulkDeleteView(generic.BulkDeleteView):
 class InterfaceBulkDeleteView(generic.BulkDeleteView):
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
-    filterset = filters.InterfaceFilterSet
+    filterset = filtersets.InterfaceFilterSet
     table = tables.InterfaceTable
     table = tables.InterfaceTable
 
 
 
 
@@ -2022,7 +2024,7 @@ class InterfaceBulkDeleteView(generic.BulkDeleteView):
 
 
 class FrontPortListView(generic.ObjectListView):
 class FrontPortListView(generic.ObjectListView):
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
-    filterset = filters.FrontPortFilterSet
+    filterset = filtersets.FrontPortFilterSet
     filterset_form = forms.FrontPortFilterForm
     filterset_form = forms.FrontPortFilterForm
     table = tables.FrontPortTable
     table = tables.FrontPortTable
     action_buttons = ('import', 'export')
     action_buttons = ('import', 'export')
@@ -2057,7 +2059,7 @@ class FrontPortBulkImportView(generic.BulkImportView):
 
 
 class FrontPortBulkEditView(generic.BulkEditView):
 class FrontPortBulkEditView(generic.BulkEditView):
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
-    filterset = filters.FrontPortFilterSet
+    filterset = filtersets.FrontPortFilterSet
     table = tables.FrontPortTable
     table = tables.FrontPortTable
     form = forms.FrontPortBulkEditForm
     form = forms.FrontPortBulkEditForm
 
 
@@ -2072,7 +2074,7 @@ class FrontPortBulkDisconnectView(BulkDisconnectView):
 
 
 class FrontPortBulkDeleteView(generic.BulkDeleteView):
 class FrontPortBulkDeleteView(generic.BulkDeleteView):
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
-    filterset = filters.FrontPortFilterSet
+    filterset = filtersets.FrontPortFilterSet
     table = tables.FrontPortTable
     table = tables.FrontPortTable
 
 
 
 
@@ -2082,7 +2084,7 @@ class FrontPortBulkDeleteView(generic.BulkDeleteView):
 
 
 class RearPortListView(generic.ObjectListView):
 class RearPortListView(generic.ObjectListView):
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
-    filterset = filters.RearPortFilterSet
+    filterset = filtersets.RearPortFilterSet
     filterset_form = forms.RearPortFilterForm
     filterset_form = forms.RearPortFilterForm
     table = tables.RearPortTable
     table = tables.RearPortTable
     action_buttons = ('import', 'export')
     action_buttons = ('import', 'export')
@@ -2117,7 +2119,7 @@ class RearPortBulkImportView(generic.BulkImportView):
 
 
 class RearPortBulkEditView(generic.BulkEditView):
 class RearPortBulkEditView(generic.BulkEditView):
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
-    filterset = filters.RearPortFilterSet
+    filterset = filtersets.RearPortFilterSet
     table = tables.RearPortTable
     table = tables.RearPortTable
     form = forms.RearPortBulkEditForm
     form = forms.RearPortBulkEditForm
 
 
@@ -2132,7 +2134,7 @@ class RearPortBulkDisconnectView(BulkDisconnectView):
 
 
 class RearPortBulkDeleteView(generic.BulkDeleteView):
 class RearPortBulkDeleteView(generic.BulkDeleteView):
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
-    filterset = filters.RearPortFilterSet
+    filterset = filtersets.RearPortFilterSet
     table = tables.RearPortTable
     table = tables.RearPortTable
 
 
 
 
@@ -2142,7 +2144,7 @@ class RearPortBulkDeleteView(generic.BulkDeleteView):
 
 
 class DeviceBayListView(generic.ObjectListView):
 class DeviceBayListView(generic.ObjectListView):
     queryset = DeviceBay.objects.all()
     queryset = DeviceBay.objects.all()
-    filterset = filters.DeviceBayFilterSet
+    filterset = filtersets.DeviceBayFilterSet
     filterset_form = forms.DeviceBayFilterForm
     filterset_form = forms.DeviceBayFilterForm
     table = tables.DeviceBayTable
     table = tables.DeviceBayTable
     action_buttons = ('import', 'export')
     action_buttons = ('import', 'export')
@@ -2242,7 +2244,7 @@ class DeviceBayBulkImportView(generic.BulkImportView):
 
 
 class DeviceBayBulkEditView(generic.BulkEditView):
 class DeviceBayBulkEditView(generic.BulkEditView):
     queryset = DeviceBay.objects.all()
     queryset = DeviceBay.objects.all()
-    filterset = filters.DeviceBayFilterSet
+    filterset = filtersets.DeviceBayFilterSet
     table = tables.DeviceBayTable
     table = tables.DeviceBayTable
     form = forms.DeviceBayBulkEditForm
     form = forms.DeviceBayBulkEditForm
 
 
@@ -2253,7 +2255,7 @@ class DeviceBayBulkRenameView(generic.BulkRenameView):
 
 
 class DeviceBayBulkDeleteView(generic.BulkDeleteView):
 class DeviceBayBulkDeleteView(generic.BulkDeleteView):
     queryset = DeviceBay.objects.all()
     queryset = DeviceBay.objects.all()
-    filterset = filters.DeviceBayFilterSet
+    filterset = filtersets.DeviceBayFilterSet
     table = tables.DeviceBayTable
     table = tables.DeviceBayTable
 
 
 
 
@@ -2263,7 +2265,7 @@ class DeviceBayBulkDeleteView(generic.BulkDeleteView):
 
 
 class InventoryItemListView(generic.ObjectListView):
 class InventoryItemListView(generic.ObjectListView):
     queryset = InventoryItem.objects.all()
     queryset = InventoryItem.objects.all()
-    filterset = filters.InventoryItemFilterSet
+    filterset = filtersets.InventoryItemFilterSet
     filterset_form = forms.InventoryItemFilterForm
     filterset_form = forms.InventoryItemFilterForm
     table = tables.InventoryItemTable
     table = tables.InventoryItemTable
     action_buttons = ('import', 'export')
     action_buttons = ('import', 'export')
@@ -2297,7 +2299,7 @@ class InventoryItemBulkImportView(generic.BulkImportView):
 
 
 class InventoryItemBulkEditView(generic.BulkEditView):
 class InventoryItemBulkEditView(generic.BulkEditView):
     queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
     queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
-    filterset = filters.InventoryItemFilterSet
+    filterset = filtersets.InventoryItemFilterSet
     table = tables.InventoryItemTable
     table = tables.InventoryItemTable
     form = forms.InventoryItemBulkEditForm
     form = forms.InventoryItemBulkEditForm
 
 
@@ -2322,7 +2324,7 @@ class DeviceBulkAddConsolePortView(generic.BulkComponentCreateView):
     form = forms.ConsolePortBulkCreateForm
     form = forms.ConsolePortBulkCreateForm
     queryset = ConsolePort.objects.all()
     queryset = ConsolePort.objects.all()
     model_form = forms.ConsolePortForm
     model_form = forms.ConsolePortForm
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
 
 
@@ -2333,7 +2335,7 @@ class DeviceBulkAddConsoleServerPortView(generic.BulkComponentCreateView):
     form = forms.ConsoleServerPortBulkCreateForm
     form = forms.ConsoleServerPortBulkCreateForm
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
     model_form = forms.ConsoleServerPortForm
     model_form = forms.ConsoleServerPortForm
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
 
 
@@ -2344,7 +2346,7 @@ class DeviceBulkAddPowerPortView(generic.BulkComponentCreateView):
     form = forms.PowerPortBulkCreateForm
     form = forms.PowerPortBulkCreateForm
     queryset = PowerPort.objects.all()
     queryset = PowerPort.objects.all()
     model_form = forms.PowerPortForm
     model_form = forms.PowerPortForm
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
 
 
@@ -2355,7 +2357,7 @@ class DeviceBulkAddPowerOutletView(generic.BulkComponentCreateView):
     form = forms.PowerOutletBulkCreateForm
     form = forms.PowerOutletBulkCreateForm
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
     model_form = forms.PowerOutletForm
     model_form = forms.PowerOutletForm
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
 
 
@@ -2366,7 +2368,7 @@ class DeviceBulkAddInterfaceView(generic.BulkComponentCreateView):
     form = forms.InterfaceBulkCreateForm
     form = forms.InterfaceBulkCreateForm
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
     model_form = forms.InterfaceForm
     model_form = forms.InterfaceForm
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
 
 
@@ -2377,7 +2379,7 @@ class DeviceBulkAddInterfaceView(generic.BulkComponentCreateView):
 #     form = forms.FrontPortBulkCreateForm
 #     form = forms.FrontPortBulkCreateForm
 #     queryset = FrontPort.objects.all()
 #     queryset = FrontPort.objects.all()
 #     model_form = forms.FrontPortForm
 #     model_form = forms.FrontPortForm
-#     filterset = filters.DeviceFilterSet
+#     filterset = filtersets.DeviceFilterSet
 #     table = tables.DeviceTable
 #     table = tables.DeviceTable
 #     default_return_url = 'dcim:device_list'
 #     default_return_url = 'dcim:device_list'
 
 
@@ -2388,7 +2390,7 @@ class DeviceBulkAddRearPortView(generic.BulkComponentCreateView):
     form = forms.RearPortBulkCreateForm
     form = forms.RearPortBulkCreateForm
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
     model_form = forms.RearPortForm
     model_form = forms.RearPortForm
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
 
 
@@ -2399,7 +2401,7 @@ class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView):
     form = forms.DeviceBayBulkCreateForm
     form = forms.DeviceBayBulkCreateForm
     queryset = DeviceBay.objects.all()
     queryset = DeviceBay.objects.all()
     model_form = forms.DeviceBayForm
     model_form = forms.DeviceBayForm
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
 
 
@@ -2410,7 +2412,7 @@ class DeviceBulkAddInventoryItemView(generic.BulkComponentCreateView):
     form = forms.InventoryItemBulkCreateForm
     form = forms.InventoryItemBulkCreateForm
     queryset = InventoryItem.objects.all()
     queryset = InventoryItem.objects.all()
     model_form = forms.InventoryItemForm
     model_form = forms.InventoryItemForm
-    filterset = filters.DeviceFilterSet
+    filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
 
 
@@ -2421,7 +2423,7 @@ class DeviceBulkAddInventoryItemView(generic.BulkComponentCreateView):
 
 
 class CableListView(generic.ObjectListView):
 class CableListView(generic.ObjectListView):
     queryset = Cable.objects.all()
     queryset = Cable.objects.all()
-    filterset = filters.CableFilterSet
+    filterset = filtersets.CableFilterSet
     filterset_form = forms.CableFilterForm
     filterset_form = forms.CableFilterForm
     table = tables.CableTable
     table = tables.CableTable
     action_buttons = ('import', 'export')
     action_buttons = ('import', 'export')
@@ -2554,14 +2556,14 @@ class CableBulkImportView(generic.BulkImportView):
 
 
 class CableBulkEditView(generic.BulkEditView):
 class CableBulkEditView(generic.BulkEditView):
     queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
     queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
-    filterset = filters.CableFilterSet
+    filterset = filtersets.CableFilterSet
     table = tables.CableTable
     table = tables.CableTable
     form = forms.CableBulkEditForm
     form = forms.CableBulkEditForm
 
 
 
 
 class CableBulkDeleteView(generic.BulkDeleteView):
 class CableBulkDeleteView(generic.BulkDeleteView):
     queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
     queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
-    filterset = filters.CableFilterSet
+    filterset = filtersets.CableFilterSet
     table = tables.CableTable
     table = tables.CableTable
 
 
 
 
@@ -2571,7 +2573,7 @@ class CableBulkDeleteView(generic.BulkDeleteView):
 
 
 class ConsoleConnectionsListView(generic.ObjectListView):
 class ConsoleConnectionsListView(generic.ObjectListView):
     queryset = ConsolePort.objects.filter(_path__isnull=False).order_by('device')
     queryset = ConsolePort.objects.filter(_path__isnull=False).order_by('device')
-    filterset = filters.ConsoleConnectionFilterSet
+    filterset = filtersets.ConsoleConnectionFilterSet
     filterset_form = forms.ConsoleConnectionFilterForm
     filterset_form = forms.ConsoleConnectionFilterForm
     table = tables.ConsoleConnectionTable
     table = tables.ConsoleConnectionTable
     template_name = 'dcim/connections_list.html'
     template_name = 'dcim/connections_list.html'
@@ -2601,7 +2603,7 @@ class ConsoleConnectionsListView(generic.ObjectListView):
 
 
 class PowerConnectionsListView(generic.ObjectListView):
 class PowerConnectionsListView(generic.ObjectListView):
     queryset = PowerPort.objects.filter(_path__isnull=False).order_by('device')
     queryset = PowerPort.objects.filter(_path__isnull=False).order_by('device')
-    filterset = filters.PowerConnectionFilterSet
+    filterset = filtersets.PowerConnectionFilterSet
     filterset_form = forms.PowerConnectionFilterForm
     filterset_form = forms.PowerConnectionFilterForm
     table = tables.PowerConnectionTable
     table = tables.PowerConnectionTable
     template_name = 'dcim/connections_list.html'
     template_name = 'dcim/connections_list.html'
@@ -2635,7 +2637,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
         _path__isnull=False,
         _path__isnull=False,
         pk__lt=F('_path__destination_id')
         pk__lt=F('_path__destination_id')
     ).order_by('device')
     ).order_by('device')
-    filterset = filters.InterfaceConnectionFilterSet
+    filterset = filtersets.InterfaceConnectionFilterSet
     filterset_form = forms.InterfaceConnectionFilterForm
     filterset_form = forms.InterfaceConnectionFilterForm
     table = tables.InterfaceConnectionTable
     table = tables.InterfaceConnectionTable
     template_name = 'dcim/connections_list.html'
     template_name = 'dcim/connections_list.html'
@@ -2674,7 +2676,7 @@ class VirtualChassisListView(generic.ObjectListView):
         member_count=count_related(Device, 'virtual_chassis')
         member_count=count_related(Device, 'virtual_chassis')
     )
     )
     table = tables.VirtualChassisTable
     table = tables.VirtualChassisTable
-    filterset = filters.VirtualChassisFilterSet
+    filterset = filtersets.VirtualChassisFilterSet
     filterset_form = forms.VirtualChassisFilterForm
     filterset_form = forms.VirtualChassisFilterForm
 
 
 
 
@@ -2882,14 +2884,14 @@ class VirtualChassisBulkImportView(generic.BulkImportView):
 
 
 class VirtualChassisBulkEditView(generic.BulkEditView):
 class VirtualChassisBulkEditView(generic.BulkEditView):
     queryset = VirtualChassis.objects.all()
     queryset = VirtualChassis.objects.all()
-    filterset = filters.VirtualChassisFilterSet
+    filterset = filtersets.VirtualChassisFilterSet
     table = tables.VirtualChassisTable
     table = tables.VirtualChassisTable
     form = forms.VirtualChassisBulkEditForm
     form = forms.VirtualChassisBulkEditForm
 
 
 
 
 class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
 class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
     queryset = VirtualChassis.objects.all()
     queryset = VirtualChassis.objects.all()
-    filterset = filters.VirtualChassisFilterSet
+    filterset = filtersets.VirtualChassisFilterSet
     table = tables.VirtualChassisTable
     table = tables.VirtualChassisTable
 
 
 
 
@@ -2903,7 +2905,7 @@ class PowerPanelListView(generic.ObjectListView):
     ).annotate(
     ).annotate(
         powerfeed_count=count_related(PowerFeed, 'power_panel')
         powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
     )
-    filterset = filters.PowerPanelFilterSet
+    filterset = filtersets.PowerPanelFilterSet
     filterset_form = forms.PowerPanelFilterForm
     filterset_form = forms.PowerPanelFilterForm
     table = tables.PowerPanelTable
     table = tables.PowerPanelTable
 
 
@@ -2943,7 +2945,7 @@ class PowerPanelBulkImportView(generic.BulkImportView):
 
 
 class PowerPanelBulkEditView(generic.BulkEditView):
 class PowerPanelBulkEditView(generic.BulkEditView):
     queryset = PowerPanel.objects.prefetch_related('site', 'location')
     queryset = PowerPanel.objects.prefetch_related('site', 'location')
-    filterset = filters.PowerPanelFilterSet
+    filterset = filtersets.PowerPanelFilterSet
     table = tables.PowerPanelTable
     table = tables.PowerPanelTable
     form = forms.PowerPanelBulkEditForm
     form = forms.PowerPanelBulkEditForm
 
 
@@ -2954,7 +2956,7 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
     ).annotate(
     ).annotate(
         powerfeed_count=count_related(PowerFeed, 'power_panel')
         powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
     )
-    filterset = filters.PowerPanelFilterSet
+    filterset = filtersets.PowerPanelFilterSet
     table = tables.PowerPanelTable
     table = tables.PowerPanelTable
 
 
 
 
@@ -2964,7 +2966,7 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
 
 
 class PowerFeedListView(generic.ObjectListView):
 class PowerFeedListView(generic.ObjectListView):
     queryset = PowerFeed.objects.all()
     queryset = PowerFeed.objects.all()
-    filterset = filters.PowerFeedFilterSet
+    filterset = filtersets.PowerFeedFilterSet
     filterset_form = forms.PowerFeedFilterForm
     filterset_form = forms.PowerFeedFilterForm
     table = tables.PowerFeedTable
     table = tables.PowerFeedTable
 
 
@@ -2990,7 +2992,7 @@ class PowerFeedBulkImportView(generic.BulkImportView):
 
 
 class PowerFeedBulkEditView(generic.BulkEditView):
 class PowerFeedBulkEditView(generic.BulkEditView):
     queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
     queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
-    filterset = filters.PowerFeedFilterSet
+    filterset = filtersets.PowerFeedFilterSet
     table = tables.PowerFeedTable
     table = tables.PowerFeedTable
     form = forms.PowerFeedBulkEditForm
     form = forms.PowerFeedBulkEditForm
 
 
@@ -3001,5 +3003,5 @@ class PowerFeedBulkDisconnectView(BulkDisconnectView):
 
 
 class PowerFeedBulkDeleteView(generic.BulkDeleteView):
 class PowerFeedBulkDeleteView(generic.BulkDeleteView):
     queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
     queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
-    filterset = filters.PowerFeedFilterSet
+    filterset = filtersets.PowerFeedFilterSet
     table = tables.PowerFeedTable
     table = tables.PowerFeedTable

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

@@ -453,12 +453,7 @@ class ObjectChangeSerializer(BaseModelSerializer):
 
 
 class ContentTypeSerializer(BaseModelSerializer):
 class ContentTypeSerializer(BaseModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:contenttype-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:contenttype-detail')
-    display_name = serializers.SerializerMethodField()
 
 
     class Meta:
     class Meta:
         model = ContentType
         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 rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
 from rq import Worker
 from rq import Worker
 
 
-from extras import filters
+from extras import filtersets
 from extras.choices import JobResultStatusChoices
 from extras.choices import JobResultStatusChoices
 from extras.models import *
 from extras.models import *
 from extras.models import CustomField
 from extras.models import CustomField
@@ -61,7 +61,7 @@ class WebhookViewSet(ModelViewSet):
     metadata_class = ContentTypeMetadata
     metadata_class = ContentTypeMetadata
     queryset = Webhook.objects.all()
     queryset = Webhook.objects.all()
     serializer_class = serializers.WebhookSerializer
     serializer_class = serializers.WebhookSerializer
-    filterset_class = filters.WebhookFilterSet
+    filterset_class = filtersets.WebhookFilterSet
 
 
 
 
 #
 #
@@ -72,7 +72,7 @@ class CustomFieldViewSet(ModelViewSet):
     metadata_class = ContentTypeMetadata
     metadata_class = ContentTypeMetadata
     queryset = CustomField.objects.all()
     queryset = CustomField.objects.all()
     serializer_class = serializers.CustomFieldSerializer
     serializer_class = serializers.CustomFieldSerializer
-    filterset_class = filters.CustomFieldFilterSet
+    filterset_class = filtersets.CustomFieldFilterSet
 
 
 
 
 class CustomFieldModelViewSet(ModelViewSet):
 class CustomFieldModelViewSet(ModelViewSet):
@@ -101,7 +101,7 @@ class CustomLinkViewSet(ModelViewSet):
     metadata_class = ContentTypeMetadata
     metadata_class = ContentTypeMetadata
     queryset = CustomLink.objects.all()
     queryset = CustomLink.objects.all()
     serializer_class = serializers.CustomLinkSerializer
     serializer_class = serializers.CustomLinkSerializer
-    filterset_class = filters.CustomLinkFilterSet
+    filterset_class = filtersets.CustomLinkFilterSet
 
 
 
 
 #
 #
@@ -112,7 +112,7 @@ class ExportTemplateViewSet(ModelViewSet):
     metadata_class = ContentTypeMetadata
     metadata_class = ContentTypeMetadata
     queryset = ExportTemplate.objects.all()
     queryset = ExportTemplate.objects.all()
     serializer_class = serializers.ExportTemplateSerializer
     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')
         tagged_items=count_related(TaggedItem, 'tag')
     )
     )
     serializer_class = serializers.TagSerializer
     serializer_class = serializers.TagSerializer
-    filterset_class = filters.TagFilterSet
+    filterset_class = filtersets.TagFilterSet
 
 
 
 
 #
 #
@@ -135,7 +135,7 @@ class ImageAttachmentViewSet(ModelViewSet):
     metadata_class = ContentTypeMetadata
     metadata_class = ContentTypeMetadata
     queryset = ImageAttachment.objects.all()
     queryset = ImageAttachment.objects.all()
     serializer_class = serializers.ImageAttachmentSerializer
     serializer_class = serializers.ImageAttachmentSerializer
-    filterset_class = filters.ImageAttachmentFilterSet
+    filterset_class = filtersets.ImageAttachmentFilterSet
 
 
 
 
 #
 #
@@ -146,7 +146,7 @@ class JournalEntryViewSet(ModelViewSet):
     metadata_class = ContentTypeMetadata
     metadata_class = ContentTypeMetadata
     queryset = JournalEntry.objects.all()
     queryset = JournalEntry.objects.all()
     serializer_class = serializers.JournalEntrySerializer
     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',
         'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants',
     )
     )
     serializer_class = serializers.ConfigContextSerializer
     serializer_class = serializers.ConfigContextSerializer
-    filterset_class = filters.ConfigContextFilterSet
+    filterset_class = filtersets.ConfigContextFilterSet
 
 
 
 
 #
 #
@@ -358,7 +358,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
     metadata_class = ContentTypeMetadata
     metadata_class = ContentTypeMetadata
     queryset = ObjectChange.objects.prefetch_related('user')
     queryset = ObjectChange.objects.prefetch_related('user')
     serializer_class = serializers.ObjectChangeSerializer
     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')
     queryset = JobResult.objects.prefetch_related('user')
     serializer_class = serializers.JobResultSerializer
     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')
     queryset = ContentType.objects.order_by('app_label', 'model')
     serializer_class = serializers.ContentTypeSerializer
     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
 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 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 .choices import *
-from .models import *
-
 
 
 __all__ = (
 __all__ = (
-    'ConfigContextFilterSet',
-    'ContentTypeFilterSet',
-    'CreatedUpdatedFilterSet',
     'CustomFieldFilter',
     'CustomFieldFilter',
-    'CustomLinkFilterSet',
-    'CustomFieldModelFilterSet',
-    'ExportTemplateFilterSet',
-    'ImageAttachmentFilterSet',
-    'JournalEntryFilterSet',
-    'LocalConfigContextFilterSet',
-    'ObjectChangeFilterSet',
-    'TagFilterSet',
-    'WebhookFilterSet',
+    'TagFilter',
 )
 )
 
 
 EXACT_FILTER_TYPES = (
 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):
 class CustomFieldFilter(django_filters.Filter):
     """
     """
     Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
     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'
                 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):
     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 django.urls import reverse
 from rest_framework import status
 from rest_framework import status
 
 
-from dcim.filters import SiteFilterSet
+from dcim.filtersets import SiteFilterSet
 from dcim.forms import SiteCSVForm
 from dcim.forms import SiteCSVForm
 from dcim.models import Site, Rack
 from dcim.models import Site, Rack
 from extras.choices import *
 from extras.choices import *

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

@@ -1,4 +1,5 @@
 import uuid
 import uuid
+from datetime import datetime, timezone
 
 
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 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 dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
 from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
 from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
-from extras.filters import *
+from extras.filtersets import *
 from extras.models import *
 from extras.models import *
 from ipam.models import IPAddress
 from ipam.models import IPAddress
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
+from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
 
 
-class WebhookTestCase(TestCase):
+class WebhookTestCase(TestCase, BaseFilterSetTests):
     queryset = Webhook.objects.all()
     queryset = Webhook.objects.all()
     filterset = WebhookFilterSet
     filterset = WebhookFilterSet
 
 
@@ -52,10 +54,6 @@ class WebhookTestCase(TestCase):
         webhooks[1].content_types.add(content_types[1])
         webhooks[1].content_types.add(content_types[1])
         webhooks[2].content_types.add(content_types[2])
         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):
     def test_name(self):
         params = {'name': ['Webhook 1', 'Webhook 2']}
         params = {'name': ['Webhook 1', 'Webhook 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class CustomLinkTestCase(TestCase):
+class CustomLinkTestCase(TestCase, BaseFilterSetTests):
     queryset = CustomLink.objects.all()
     queryset = CustomLink.objects.all()
     filterset = CustomLinkFilterSet
     filterset = CustomLinkFilterSet
 
 
@@ -125,10 +123,6 @@ class CustomLinkTestCase(TestCase):
         )
         )
         CustomLink.objects.bulk_create(custom_links)
         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):
     def test_name(self):
         params = {'name': ['Custom Link 1', 'Custom Link 2']}
         params = {'name': ['Custom Link 1', 'Custom Link 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class ExportTemplateTestCase(TestCase):
+class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
     queryset = ExportTemplate.objects.all()
     queryset = ExportTemplate.objects.all()
     filterset = ExportTemplateFilterSet
     filterset = ExportTemplateFilterSet
 
 
@@ -164,10 +158,6 @@ class ExportTemplateTestCase(TestCase):
         )
         )
         ExportTemplate.objects.bulk_create(export_templates)
         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):
     def test_name(self):
         params = {'name': ['Export Template 1', 'Export Template 2']}
         params = {'name': ['Export Template 1', 'Export Template 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class ImageAttachmentTestCase(TestCase):
+class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
     queryset = ImageAttachment.objects.all()
     queryset = ImageAttachment.objects.all()
     filterset = ImageAttachmentFilterSet
     filterset = ImageAttachmentFilterSet
 
 
@@ -235,10 +225,6 @@ class ImageAttachmentTestCase(TestCase):
         )
         )
         ImageAttachment.objects.bulk_create(image_attachments)
         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):
     def test_name(self):
         params = {'name': ['Image Attachment 1', 'Image Attachment 2']}
         params = {'name': ['Image Attachment 1', 'Image Attachment 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         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()
     queryset = JournalEntry.objects.all()
     filterset = JournalEntryFilterSet
     filterset = JournalEntryFilterSet
 
 
@@ -320,10 +312,6 @@ class JournalEntryTestCase(TestCase):
         )
         )
         JournalEntry.objects.bulk_create(journal_entries)
         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):
     def test_created_by(self):
         users = User.objects.filter(username__in=['Alice', 'Bob'])
         users = User.objects.filter(username__in=['Alice', 'Bob'])
         params = {'created_by': [users[0].username, users[1].username]}
         params = {'created_by': [users[0].username, users[1].username]}
@@ -348,8 +336,17 @@ class JournalEntryTestCase(TestCase):
         params = {'kind': [JournalEntryKindChoices.KIND_INFO, JournalEntryKindChoices.KIND_SUCCESS]}
         params = {'kind': [JournalEntryKindChoices.KIND_INFO, JournalEntryKindChoices.KIND_SUCCESS]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         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()
     queryset = ConfigContext.objects.all()
     filterset = ConfigContextFilterSet
     filterset = ConfigContextFilterSet
 
 
@@ -449,10 +446,6 @@ class ConfigContextTestCase(TestCase):
             c.tenant_groups.set([tenant_groups[i]])
             c.tenant_groups.set([tenant_groups[i]])
             c.tenants.set([tenants[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):
     def test_name(self):
         params = {'name': ['Config Context 1', 'Config Context 2']}
         params = {'name': ['Config Context 1', 'Config Context 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class TagTestCase(TestCase):
+class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Tag.objects.all()
     queryset = Tag.objects.all()
     filterset = TagFilterSet
     filterset = TagFilterSet
 
 
@@ -544,10 +537,6 @@ class TagTestCase(TestCase):
         )
         )
         Tag.objects.bulk_create(tags)
         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):
     def test_name(self):
         params = {'name': ['Tag 1', 'Tag 2']}
         params = {'name': ['Tag 1', 'Tag 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class ObjectChangeTestCase(TestCase):
+class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
     queryset = ObjectChange.objects.all()
     queryset = ObjectChange.objects.all()
     filterset = ObjectChangeFilterSet
     filterset = ObjectChangeFilterSet
 
 
@@ -635,10 +624,6 @@ class ObjectChangeTestCase(TestCase):
         )
         )
         ObjectChange.objects.bulk_create(object_changes)
         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):
     def test_user(self):
         params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
         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)
         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.tables import paginate_table
 from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
 from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
 from utilities.views import ContentTypePermissionRequiredMixin
 from utilities.views import ContentTypePermissionRequiredMixin
-from . import filters, forms, tables
+from . import filtersets, forms, tables
 from .choices import JobResultStatusChoices
 from .choices import JobResultStatusChoices
 from .models import ConfigContext, ImageAttachment, JournalEntry, ObjectChange, JobResult, Tag, TaggedItem
 from .models import ConfigContext, ImageAttachment, JournalEntry, ObjectChange, JobResult, Tag, TaggedItem
 from .reports import get_report, get_reports, run_report
 from .reports import get_report, get_reports, run_report
@@ -28,7 +28,7 @@ class TagListView(generic.ObjectListView):
     queryset = Tag.objects.annotate(
     queryset = Tag.objects.annotate(
         items=count_related(TaggedItem, 'tag')
         items=count_related(TaggedItem, 'tag')
     )
     )
-    filterset = filters.TagFilterSet
+    filterset = filtersets.TagFilterSet
     filterset_form = forms.TagFilterForm
     filterset_form = forms.TagFilterForm
     table = tables.TagTable
     table = tables.TagTable
 
 
@@ -94,7 +94,7 @@ class TagBulkDeleteView(generic.BulkDeleteView):
 
 
 class ConfigContextListView(generic.ObjectListView):
 class ConfigContextListView(generic.ObjectListView):
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
-    filterset = filters.ConfigContextFilterSet
+    filterset = filtersets.ConfigContextFilterSet
     filterset_form = forms.ConfigContextFilterForm
     filterset_form = forms.ConfigContextFilterForm
     table = tables.ConfigContextTable
     table = tables.ConfigContextTable
     action_buttons = ('add',)
     action_buttons = ('add',)
@@ -127,7 +127,7 @@ class ConfigContextEditView(generic.ObjectEditView):
 
 
 class ConfigContextBulkEditView(generic.BulkEditView):
 class ConfigContextBulkEditView(generic.BulkEditView):
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
-    filterset = filters.ConfigContextFilterSet
+    filterset = filtersets.ConfigContextFilterSet
     table = tables.ConfigContextTable
     table = tables.ConfigContextTable
     form = forms.ConfigContextBulkEditForm
     form = forms.ConfigContextBulkEditForm
 
 
@@ -173,7 +173,7 @@ class ObjectConfigContextView(generic.ObjectView):
 
 
 class ObjectChangeListView(generic.ObjectListView):
 class ObjectChangeListView(generic.ObjectListView):
     queryset = ObjectChange.objects.all()
     queryset = ObjectChange.objects.all()
-    filterset = filters.ObjectChangeFilterSet
+    filterset = filtersets.ObjectChangeFilterSet
     filterset_form = forms.ObjectChangeFilterForm
     filterset_form = forms.ObjectChangeFilterForm
     table = tables.ObjectChangeTable
     table = tables.ObjectChangeTable
     template_name = 'extras/objectchange_list.html'
     template_name = 'extras/objectchange_list.html'
@@ -300,7 +300,7 @@ class ImageAttachmentDeleteView(generic.ObjectDeleteView):
 
 
 class JournalEntryListView(generic.ObjectListView):
 class JournalEntryListView(generic.ObjectListView):
     queryset = JournalEntry.objects.all()
     queryset = JournalEntry.objects.all()
-    filterset = filters.JournalEntryFilterSet
+    filterset = filtersets.JournalEntryFilterSet
     filterset_form = forms.JournalEntryFilterForm
     filterset_form = forms.JournalEntryFilterForm
     table = tables.JournalEntryTable
     table = tables.JournalEntryTable
     action_buttons = ('export',)
     action_buttons = ('export',)
@@ -338,14 +338,14 @@ class JournalEntryDeleteView(generic.ObjectDeleteView):
 
 
 class JournalEntryBulkEditView(generic.BulkEditView):
 class JournalEntryBulkEditView(generic.BulkEditView):
     queryset = JournalEntry.objects.prefetch_related('created_by')
     queryset = JournalEntry.objects.prefetch_related('created_by')
-    filterset = filters.JournalEntryFilterSet
+    filterset = filtersets.JournalEntryFilterSet
     table = tables.JournalEntryTable
     table = tables.JournalEntryTable
     form = forms.JournalEntryBulkEditForm
     form = forms.JournalEntryBulkEditForm
 
 
 
 
 class JournalEntryBulkDeleteView(generic.BulkDeleteView):
 class JournalEntryBulkDeleteView(generic.BulkDeleteView):
     queryset = JournalEntry.objects.prefetch_related('created_by')
     queryset = JournalEntry.objects.prefetch_related('created_by')
-    filterset = filters.JournalEntryFilterSet
+    filterset = filtersets.JournalEntryFilterSet
     table = tables.JournalEntryTable
     table = tables.JournalEntryTable
 
 
 
 

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

@@ -27,7 +27,7 @@ class NestedVRFSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.VRF
         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:
     class Meta:
         model = models.VLAN
         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
         model = VRF
         fields = [
         fields = [
             'id', 'url', 'display', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets',
             '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
         model = VLAN
         fields = [
         fields = [
             'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags',
             '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 = []
         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 rest_framework.routers import APIRootView
 
 
 from extras.api.views import CustomFieldModelViewSet
 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 ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
 from netbox.api.views import ModelViewSet
 from netbox.api.views import ModelViewSet
 from utilities.constants import ADVISORY_LOCK_KEYS
 from utilities.constants import ADVISORY_LOCK_KEYS
@@ -38,7 +38,7 @@ class VRFViewSet(CustomFieldModelViewSet):
         prefix_count=count_related(Prefix, 'vrf')
         prefix_count=count_related(Prefix, 'vrf')
     )
     )
     serializer_class = serializers.VRFSerializer
     serializer_class = serializers.VRFSerializer
-    filterset_class = filters.VRFFilterSet
+    filterset_class = filtersets.VRFFilterSet
 
 
 
 
 #
 #
@@ -48,7 +48,7 @@ class VRFViewSet(CustomFieldModelViewSet):
 class RouteTargetViewSet(CustomFieldModelViewSet):
 class RouteTargetViewSet(CustomFieldModelViewSet):
     queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags')
     queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags')
     serializer_class = serializers.RouteTargetSerializer
     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')
         aggregate_count=count_related(Aggregate, 'rir')
     )
     )
     serializer_class = serializers.RIRSerializer
     serializer_class = serializers.RIRSerializer
-    filterset_class = filters.RIRFilterSet
+    filterset_class = filtersets.RIRFilterSet
 
 
 
 
 #
 #
@@ -70,7 +70,7 @@ class RIRViewSet(CustomFieldModelViewSet):
 class AggregateViewSet(CustomFieldModelViewSet):
 class AggregateViewSet(CustomFieldModelViewSet):
     queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags')
     queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags')
     serializer_class = serializers.AggregateSerializer
     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')
         vlan_count=count_related(VLAN, 'role')
     )
     )
     serializer_class = serializers.RoleSerializer
     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'
         'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags'
     )
     )
     serializer_class = serializers.PrefixSerializer
     serializer_class = serializers.PrefixSerializer
-    filterset_class = filters.PrefixFilterSet
+    filterset_class = filtersets.PrefixFilterSet
 
 
     def get_serializer_class(self):
     def get_serializer_class(self):
         if self.action == "available_prefixes" and self.request.method == "POST":
         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'
         'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
     )
     )
     serializer_class = serializers.IPAddressSerializer
     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')
         vlan_count=count_related(VLAN, 'group')
     )
     )
     serializer_class = serializers.VLANGroupSerializer
     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')
         prefix_count=count_related(Prefix, 'vlan')
     )
     )
     serializer_class = serializers.VLANSerializer
     serializer_class = serializers.VLANSerializer
-    filterset_class = filters.VLANFilterSet
+    filterset_class = filtersets.VLANFilterSet
 
 
 
 
 #
 #
@@ -313,4 +313,4 @@ class ServiceViewSet(ModelViewSet):
         'device', 'virtual_machine', 'tags', 'ipaddresses'
         'device', 'virtual_machine', 'tags', 'ipaddresses'
     )
     )
     serializer_class = serializers.ServiceSerializer
     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 netaddr.core import AddrFormatError
 
 
 from dcim.models import Device, Interface, Region, Site, SiteGroup
 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 (
 from utilities.filters import (
-    BaseFilterSet, ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet,
-    NumericArrayFilter, TagFilter, TreeNodeMultipleChoiceFilter,
+    ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
 )
 )
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
 from .choices import *
 from .choices import *
@@ -31,7 +31,7 @@ __all__ = (
 )
 )
 
 
 
 
-class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -74,7 +74,7 @@ class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, C
         fields = ['id', 'name', 'rd', 'enforce_unique']
         fields = ['id', 'name', 'rd', 'enforce_unique']
 
 
 
 
-class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -116,14 +116,14 @@ class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilt
         fields = ['id', 'name']
         fields = ['id', 'name']
 
 
 
 
-class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class RIRFilterSet(OrganizationalModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = RIR
         model = RIR
         fields = ['id', 'name', 'slug', 'is_private', 'description']
         fields = ['id', 'name', 'slug', 'is_private', 'description']
 
 
 
 
-class AggregateFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -173,7 +173,7 @@ class AggregateFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter
             return queryset.none()
             return queryset.none()
 
 
 
 
-class RoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class RoleFilterSet(OrganizationalModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -184,7 +184,7 @@ class RoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilter
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='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(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -535,7 +535,7 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter
         return queryset.exclude(assigned_object_id__isnull=value)
         return queryset.exclude(assigned_object_id__isnull=value)
 
 
 
 
-class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class VLANGroupFilterSet(OrganizationalModelFilterSet):
     scope_type = ContentTypeFilter()
     scope_type = ContentTypeFilter()
     region = django_filters.NumberFilter(
     region = django_filters.NumberFilter(
         method='filter_scope'
         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(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -666,7 +666,7 @@ class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
         return queryset.get_for_virtualmachine(value)
         return queryset.get_for_virtualmachine(value)
 
 
 
 
-class ServiceFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
+class ServiceFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='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):
 class RIR(OrganizationalModel):
     """
     """
     A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
     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)
         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):
 class Role(OrganizationalModel):
     """
     """
     A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
     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()])
         child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
         available_ips = prefix - 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
             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([
         available_ips -= netaddr.IPSet([
             netaddr.IPAddress(self.prefix.first),
             netaddr.IPAddress(self.prefix.first),
             netaddr.IPAddress(self.prefix.last),
             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):
 class VLANGroup(OrganizationalModel):
     """
     """
     A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
     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'
         verbose_name_plural = 'VLANs'
 
 
     def __str__(self):
     def __str__(self):
-        return self.display_name or super().__str__()
+        return f'{self.name} ({self.vid})'
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('ipam:vlan', args=[self.pk])
         return reverse('ipam:vlan', args=[self.pk])
@@ -199,10 +199,6 @@ class VLAN(PrimaryModel):
             self.description,
             self.description,
         )
         )
 
 
-    @property
-    def display_name(self):
-        return f'{self.name} ({self.vid})'
-
     def get_status_class(self):
     def get_status_class(self):
         return VLANStatusChoices.CSS_CLASSES.get(self.status)
         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'
         verbose_name_plural = 'VRFs'
 
 
     def __str__(self):
     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):
     def get_absolute_url(self):
         return reverse('ipam:vrf', args=[self.pk])
         return reverse('ipam:vrf', args=[self.pk])
@@ -85,12 +87,6 @@ class VRF(PrimaryModel):
             self.description,
             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')
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class RouteTarget(PrimaryModel):
 class RouteTarget(PrimaryModel):

+ 2 - 0
netbox/ipam/querysets.py

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

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

@@ -22,7 +22,7 @@ class AppTest(APITestCase):
 
 
 class VRFTest(APIViewTestCases.APIViewTestCase):
 class VRFTest(APIViewTestCases.APIViewTestCase):
     model = VRF
     model = VRF
-    brief_fields = ['display', 'display_name', 'id', 'name', 'prefix_count', 'rd', 'url']
+    brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url']
     create_data = [
     create_data = [
         {
         {
             'name': 'VRF 4',
             'name': 'VRF 4',
@@ -421,7 +421,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
 
 
 class VLANTest(APIViewTestCases.APIViewTestCase):
 class VLANTest(APIViewTestCases.APIViewTestCase):
     model = VLAN
     model = VLAN
-    brief_fields = ['display', 'display_name', 'id', 'name', 'url', 'vid']
+    brief_fields = ['display', 'id', 'name', 'url', 'vid']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         '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 dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup
 from ipam.choices import *
 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 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 virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 
 
 
 
-class VRFTestCase(TestCase):
+class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VRF.objects.all()
     queryset = VRF.objects.all()
     filterset = VRFFilterSet
     filterset = VRFFilterSet
 
 
@@ -53,10 +54,6 @@ class VRFTestCase(TestCase):
         vrfs[2].import_targets.add(route_targets[2])
         vrfs[2].import_targets.add(route_targets[2])
         vrfs[2].export_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):
     def test_name(self):
         params = {'name': ['VRF 1', 'VRF 2']}
         params = {'name': ['VRF 1', 'VRF 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
 
 
-class RouteTargetTestCase(TestCase):
+class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RouteTarget.objects.all()
     queryset = RouteTarget.objects.all()
     filterset = RouteTargetFilterSet
     filterset = RouteTargetFilterSet
 
 
@@ -149,10 +146,6 @@ class RouteTargetTestCase(TestCase):
         vrfs[1].import_targets.add(route_targets[4], route_targets[5])
         vrfs[1].import_targets.add(route_targets[4], route_targets[5])
         vrfs[1].export_targets.add(route_targets[6], route_targets[7])
         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):
     def test_name(self):
         params = {'name': ['65000:1001', '65000:1002', '65000:1003']}
         params = {'name': ['65000:1001', '65000:1002', '65000:1003']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
 
 
 
 
-class RIRTestCase(TestCase):
+class RIRTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RIR.objects.all()
     queryset = RIR.objects.all()
     filterset = RIRFilterSet
     filterset = RIRFilterSet
 
 
@@ -203,10 +196,6 @@ class RIRTestCase(TestCase):
         )
         )
         RIR.objects.bulk_create(rirs)
         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):
     def test_name(self):
         params = {'name': ['RIR 1', 'RIR 2']}
         params = {'name': ['RIR 1', 'RIR 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
 
 
-class AggregateTestCase(TestCase):
+class AggregateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Aggregate.objects.all()
     queryset = Aggregate.objects.all()
     filterset = AggregateFilterSet
     filterset = AggregateFilterSet
 
 
@@ -265,10 +254,6 @@ class AggregateTestCase(TestCase):
         )
         )
         Aggregate.objects.bulk_create(aggregates)
         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):
     def test_family(self):
         params = {'family': '4'}
         params = {'family': '4'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
 
 
-class RoleTestCase(TestCase):
+class RoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Role.objects.all()
     queryset = Role.objects.all()
     filterset = RoleFilterSet
     filterset = RoleFilterSet
 
 
@@ -318,10 +303,6 @@ class RoleTestCase(TestCase):
         )
         )
         Role.objects.bulk_create(roles)
         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):
     def test_name(self):
         params = {'name': ['Role 1', 'Role 2']}
         params = {'name': ['Role 1', 'Role 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class PrefixTestCase(TestCase):
+class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Prefix.objects.all()
     queryset = Prefix.objects.all()
     filterset = PrefixFilterSet
     filterset = PrefixFilterSet
 
 
@@ -421,10 +402,6 @@ class PrefixTestCase(TestCase):
         )
         )
         Prefix.objects.bulk_create(prefixes)
         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):
     def test_family(self):
         params = {'family': '6'}
         params = {'family': '6'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
 
 
-class IPAddressTestCase(TestCase):
+class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = IPAddress.objects.all()
     queryset = IPAddress.objects.all()
     filterset = IPAddressFilterSet
     filterset = IPAddressFilterSet
 
 
@@ -607,10 +584,6 @@ class IPAddressTestCase(TestCase):
         )
         )
         IPAddress.objects.bulk_create(ipaddresses)
         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):
     def test_family(self):
         params = {'family': '6'}
         params = {'family': '6'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
 
 
-class VLANGroupTestCase(TestCase):
+class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VLANGroup.objects.all()
     queryset = VLANGroup.objects.all()
     filterset = VLANGroupFilterSet
     filterset = VLANGroupFilterSet
 
 
@@ -751,10 +724,6 @@ class VLANGroupTestCase(TestCase):
         )
         )
         VLANGroup.objects.bulk_create(vlan_groups)
         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):
     def test_name(self):
         params = {'name': ['VLAN Group 1', 'VLAN Group 2']}
         params = {'name': ['VLAN Group 1', 'VLAN Group 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class VLANTestCase(TestCase):
+class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VLAN.objects.all()
     queryset = VLAN.objects.all()
     filterset = VLANFilterSet
     filterset = VLANFilterSet
 
 
@@ -965,10 +934,6 @@ class VLANTestCase(TestCase):
         )
         )
         VLAN.objects.bulk_create(vlans)
         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):
     def test_name(self):
         params = {'name': ['VLAN 101', 'VLAN 102']}
         params = {'name': ['VLAN 101', 'VLAN 102']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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
         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()
     queryset = Service.objects.all()
     filterset = ServiceFilterSet
     filterset = ServiceFilterSet
 
 
@@ -1080,10 +1045,6 @@ class ServiceTestCase(TestCase):
         )
         )
         Service.objects.bulk_create(services)
         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):
     def test_name(self):
         params = {'name': ['Service 1', 'Service 2']}
         params = {'name': ['Service 1', 'Service 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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.tables import paginate_table
 from utilities.utils import count_related
 from utilities.utils import count_related
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
-from . import filters, forms, tables
+from . import filtersets, forms, tables
 from .constants import *
 from .constants import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
 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
 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):
 class VRFListView(generic.ObjectListView):
     queryset = VRF.objects.all()
     queryset = VRF.objects.all()
-    filterset = filters.VRFFilterSet
+    filterset = filtersets.VRFFilterSet
     filterset_form = forms.VRFFilterForm
     filterset_form = forms.VRFFilterForm
     table = tables.VRFTable
     table = tables.VRFTable
 
 
@@ -65,14 +65,14 @@ class VRFBulkImportView(generic.BulkImportView):
 
 
 class VRFBulkEditView(generic.BulkEditView):
 class VRFBulkEditView(generic.BulkEditView):
     queryset = VRF.objects.prefetch_related('tenant')
     queryset = VRF.objects.prefetch_related('tenant')
-    filterset = filters.VRFFilterSet
+    filterset = filtersets.VRFFilterSet
     table = tables.VRFTable
     table = tables.VRFTable
     form = forms.VRFBulkEditForm
     form = forms.VRFBulkEditForm
 
 
 
 
 class VRFBulkDeleteView(generic.BulkDeleteView):
 class VRFBulkDeleteView(generic.BulkDeleteView):
     queryset = VRF.objects.prefetch_related('tenant')
     queryset = VRF.objects.prefetch_related('tenant')
-    filterset = filters.VRFFilterSet
+    filterset = filtersets.VRFFilterSet
     table = tables.VRFTable
     table = tables.VRFTable
 
 
 
 
@@ -82,7 +82,7 @@ class VRFBulkDeleteView(generic.BulkDeleteView):
 
 
 class RouteTargetListView(generic.ObjectListView):
 class RouteTargetListView(generic.ObjectListView):
     queryset = RouteTarget.objects.all()
     queryset = RouteTarget.objects.all()
-    filterset = filters.RouteTargetFilterSet
+    filterset = filtersets.RouteTargetFilterSet
     filterset_form = forms.RouteTargetFilterForm
     filterset_form = forms.RouteTargetFilterForm
     table = tables.RouteTargetTable
     table = tables.RouteTargetTable
 
 
@@ -123,14 +123,14 @@ class RouteTargetBulkImportView(generic.BulkImportView):
 
 
 class RouteTargetBulkEditView(generic.BulkEditView):
 class RouteTargetBulkEditView(generic.BulkEditView):
     queryset = RouteTarget.objects.prefetch_related('tenant')
     queryset = RouteTarget.objects.prefetch_related('tenant')
-    filterset = filters.RouteTargetFilterSet
+    filterset = filtersets.RouteTargetFilterSet
     table = tables.RouteTargetTable
     table = tables.RouteTargetTable
     form = forms.RouteTargetBulkEditForm
     form = forms.RouteTargetBulkEditForm
 
 
 
 
 class RouteTargetBulkDeleteView(generic.BulkDeleteView):
 class RouteTargetBulkDeleteView(generic.BulkDeleteView):
     queryset = RouteTarget.objects.prefetch_related('tenant')
     queryset = RouteTarget.objects.prefetch_related('tenant')
-    filterset = filters.RouteTargetFilterSet
+    filterset = filtersets.RouteTargetFilterSet
     table = tables.RouteTargetTable
     table = tables.RouteTargetTable
 
 
 
 
@@ -142,7 +142,7 @@ class RIRListView(generic.ObjectListView):
     queryset = RIR.objects.annotate(
     queryset = RIR.objects.annotate(
         aggregate_count=count_related(Aggregate, 'rir')
         aggregate_count=count_related(Aggregate, 'rir')
     )
     )
-    filterset = filters.RIRFilterSet
+    filterset = filtersets.RIRFilterSet
     filterset_form = forms.RIRFilterForm
     filterset_form = forms.RIRFilterForm
     table = tables.RIRTable
     table = tables.RIRTable
     template_name = 'ipam/rir_list.html'
     template_name = 'ipam/rir_list.html'
@@ -184,7 +184,7 @@ class RIRBulkEditView(generic.BulkEditView):
     queryset = RIR.objects.annotate(
     queryset = RIR.objects.annotate(
         aggregate_count=count_related(Aggregate, 'rir')
         aggregate_count=count_related(Aggregate, 'rir')
     )
     )
-    filterset = filters.RIRFilterSet
+    filterset = filtersets.RIRFilterSet
     table = tables.RIRTable
     table = tables.RIRTable
     form = forms.RIRBulkEditForm
     form = forms.RIRBulkEditForm
 
 
@@ -193,7 +193,7 @@ class RIRBulkDeleteView(generic.BulkDeleteView):
     queryset = RIR.objects.annotate(
     queryset = RIR.objects.annotate(
         aggregate_count=count_related(Aggregate, 'rir')
         aggregate_count=count_related(Aggregate, 'rir')
     )
     )
-    filterset = filters.RIRFilterSet
+    filterset = filtersets.RIRFilterSet
     table = tables.RIRTable
     table = tables.RIRTable
 
 
 
 
@@ -205,7 +205,7 @@ class AggregateListView(generic.ObjectListView):
     queryset = Aggregate.objects.annotate(
     queryset = Aggregate.objects.annotate(
         child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
         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
     filterset_form = forms.AggregateFilterForm
     table = tables.AggregateDetailTable
     table = tables.AggregateDetailTable
     template_name = 'ipam/aggregate_list.html'
     template_name = 'ipam/aggregate_list.html'
@@ -280,14 +280,14 @@ class AggregateBulkImportView(generic.BulkImportView):
 
 
 class AggregateBulkEditView(generic.BulkEditView):
 class AggregateBulkEditView(generic.BulkEditView):
     queryset = Aggregate.objects.prefetch_related('rir')
     queryset = Aggregate.objects.prefetch_related('rir')
-    filterset = filters.AggregateFilterSet
+    filterset = filtersets.AggregateFilterSet
     table = tables.AggregateTable
     table = tables.AggregateTable
     form = forms.AggregateBulkEditForm
     form = forms.AggregateBulkEditForm
 
 
 
 
 class AggregateBulkDeleteView(generic.BulkDeleteView):
 class AggregateBulkDeleteView(generic.BulkDeleteView):
     queryset = Aggregate.objects.prefetch_related('rir')
     queryset = Aggregate.objects.prefetch_related('rir')
-    filterset = filters.AggregateFilterSet
+    filterset = filtersets.AggregateFilterSet
     table = tables.AggregateTable
     table = tables.AggregateTable
 
 
 
 
@@ -337,7 +337,7 @@ class RoleBulkImportView(generic.BulkImportView):
 
 
 class RoleBulkEditView(generic.BulkEditView):
 class RoleBulkEditView(generic.BulkEditView):
     queryset = Role.objects.all()
     queryset = Role.objects.all()
-    filterset = filters.RoleFilterSet
+    filterset = filtersets.RoleFilterSet
     table = tables.RoleTable
     table = tables.RoleTable
     form = forms.RoleBulkEditForm
     form = forms.RoleBulkEditForm
 
 
@@ -353,7 +353,7 @@ class RoleBulkDeleteView(generic.BulkDeleteView):
 
 
 class PrefixListView(generic.ObjectListView):
 class PrefixListView(generic.ObjectListView):
     queryset = Prefix.objects.annotate_tree()
     queryset = Prefix.objects.annotate_tree()
-    filterset = filters.PrefixFilterSet
+    filterset = filtersets.PrefixFilterSet
     filterset_form = forms.PrefixFilterForm
     filterset_form = forms.PrefixFilterForm
     table = tables.PrefixDetailTable
     table = tables.PrefixDetailTable
     template_name = 'ipam/prefix_list.html'
     template_name = 'ipam/prefix_list.html'
@@ -493,14 +493,14 @@ class PrefixBulkImportView(generic.BulkImportView):
 
 
 class PrefixBulkEditView(generic.BulkEditView):
 class PrefixBulkEditView(generic.BulkEditView):
     queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
-    filterset = filters.PrefixFilterSet
+    filterset = filtersets.PrefixFilterSet
     table = tables.PrefixTable
     table = tables.PrefixTable
     form = forms.PrefixBulkEditForm
     form = forms.PrefixBulkEditForm
 
 
 
 
 class PrefixBulkDeleteView(generic.BulkDeleteView):
 class PrefixBulkDeleteView(generic.BulkDeleteView):
     queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
-    filterset = filters.PrefixFilterSet
+    filterset = filtersets.PrefixFilterSet
     table = tables.PrefixTable
     table = tables.PrefixTable
 
 
 
 
@@ -510,7 +510,7 @@ class PrefixBulkDeleteView(generic.BulkDeleteView):
 
 
 class IPAddressListView(generic.ObjectListView):
 class IPAddressListView(generic.ObjectListView):
     queryset = IPAddress.objects.all()
     queryset = IPAddress.objects.all()
-    filterset = filters.IPAddressFilterSet
+    filterset = filtersets.IPAddressFilterSet
     filterset_form = forms.IPAddressFilterForm
     filterset_form = forms.IPAddressFilterForm
     table = tables.IPAddressDetailTable
     table = tables.IPAddressDetailTable
 
 
@@ -613,7 +613,7 @@ class IPAddressAssignView(generic.ObjectView):
 
 
             addresses = self.queryset.prefetch_related('vrf', 'tenant')
             addresses = self.queryset.prefetch_related('vrf', 'tenant')
             # Limit to 100 results
             # Limit to 100 results
-            addresses = filters.IPAddressFilterSet(request.POST, addresses).qs[:100]
+            addresses = filtersets.IPAddressFilterSet(request.POST, addresses).qs[:100]
             table = tables.IPAddressAssignTable(addresses)
             table = tables.IPAddressAssignTable(addresses)
 
 
         return render(request, 'ipam/ipaddress_assign.html', {
         return render(request, 'ipam/ipaddress_assign.html', {
@@ -643,14 +643,14 @@ class IPAddressBulkImportView(generic.BulkImportView):
 
 
 class IPAddressBulkEditView(generic.BulkEditView):
 class IPAddressBulkEditView(generic.BulkEditView):
     queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
     queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
-    filterset = filters.IPAddressFilterSet
+    filterset = filtersets.IPAddressFilterSet
     table = tables.IPAddressTable
     table = tables.IPAddressTable
     form = forms.IPAddressBulkEditForm
     form = forms.IPAddressBulkEditForm
 
 
 
 
 class IPAddressBulkDeleteView(generic.BulkDeleteView):
 class IPAddressBulkDeleteView(generic.BulkDeleteView):
     queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
     queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
-    filterset = filters.IPAddressFilterSet
+    filterset = filtersets.IPAddressFilterSet
     table = tables.IPAddressTable
     table = tables.IPAddressTable
 
 
 
 
@@ -662,7 +662,7 @@ class VLANGroupListView(generic.ObjectListView):
     queryset = VLANGroup.objects.annotate(
     queryset = VLANGroup.objects.annotate(
         vlan_count=count_related(VLAN, 'group')
         vlan_count=count_related(VLAN, 'group')
     )
     )
-    filterset = filters.VLANGroupFilterSet
+    filterset = filtersets.VLANGroupFilterSet
     filterset_form = forms.VLANGroupFilterForm
     filterset_form = forms.VLANGroupFilterForm
     table = tables.VLANGroupTable
     table = tables.VLANGroupTable
 
 
@@ -673,7 +673,7 @@ class VLANGroupView(generic.ObjectView):
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related(
         vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related(
             Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user))
             Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user))
-        )
+        ).order_by('vid')
         vlans_count = vlans.count()
         vlans_count = vlans.count()
         vlans = add_available_vlans(instance, vlans)
         vlans = add_available_vlans(instance, vlans)
 
 
@@ -684,9 +684,17 @@ class VLANGroupView(generic.ObjectView):
         vlans_table.columns.hide('group')
         vlans_table.columns.hide('group')
         paginate_table(vlans_table, request)
         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 {
         return {
             'vlans_count': vlans_count,
             'vlans_count': vlans_count,
             'vlans_table': vlans_table,
             'vlans_table': vlans_table,
+            'permissions': permissions,
         }
         }
 
 
 
 
@@ -710,7 +718,7 @@ class VLANGroupBulkEditView(generic.BulkEditView):
     queryset = VLANGroup.objects.annotate(
     queryset = VLANGroup.objects.annotate(
         vlan_count=count_related(VLAN, 'group')
         vlan_count=count_related(VLAN, 'group')
     )
     )
-    filterset = filters.VLANGroupFilterSet
+    filterset = filtersets.VLANGroupFilterSet
     table = tables.VLANGroupTable
     table = tables.VLANGroupTable
     form = forms.VLANGroupBulkEditForm
     form = forms.VLANGroupBulkEditForm
 
 
@@ -719,7 +727,7 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = VLANGroup.objects.annotate(
     queryset = VLANGroup.objects.annotate(
         vlan_count=count_related(VLAN, 'group')
         vlan_count=count_related(VLAN, 'group')
     )
     )
-    filterset = filters.VLANGroupFilterSet
+    filterset = filtersets.VLANGroupFilterSet
     table = tables.VLANGroupTable
     table = tables.VLANGroupTable
 
 
 
 
@@ -729,7 +737,7 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView):
 
 
 class VLANListView(generic.ObjectListView):
 class VLANListView(generic.ObjectListView):
     queryset = VLAN.objects.all()
     queryset = VLAN.objects.all()
-    filterset = filters.VLANFilterSet
+    filterset = filtersets.VLANFilterSet
     filterset_form = forms.VLANFilterForm
     filterset_form = forms.VLANFilterForm
     table = tables.VLANDetailTable
     table = tables.VLANDetailTable
 
 
@@ -797,14 +805,14 @@ class VLANBulkImportView(generic.BulkImportView):
 
 
 class VLANBulkEditView(generic.BulkEditView):
 class VLANBulkEditView(generic.BulkEditView):
     queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
     queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
-    filterset = filters.VLANFilterSet
+    filterset = filtersets.VLANFilterSet
     table = tables.VLANTable
     table = tables.VLANTable
     form = forms.VLANBulkEditForm
     form = forms.VLANBulkEditForm
 
 
 
 
 class VLANBulkDeleteView(generic.BulkDeleteView):
 class VLANBulkDeleteView(generic.BulkDeleteView):
     queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
     queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
-    filterset = filters.VLANFilterSet
+    filterset = filtersets.VLANFilterSet
     table = tables.VLANTable
     table = tables.VLANTable
 
 
 
 
@@ -814,7 +822,7 @@ class VLANBulkDeleteView(generic.BulkDeleteView):
 
 
 class ServiceListView(generic.ObjectListView):
 class ServiceListView(generic.ObjectListView):
     queryset = Service.objects.all()
     queryset = Service.objects.all()
-    filterset = filters.ServiceFilterSet
+    filterset = filtersets.ServiceFilterSet
     filterset_form = forms.ServiceFilterForm
     filterset_form = forms.ServiceFilterForm
     table = tables.ServiceTable
     table = tables.ServiceTable
     action_buttons = ('import', 'export')
     action_buttons = ('import', 'export')
@@ -855,12 +863,12 @@ class ServiceDeleteView(generic.ObjectDeleteView):
 
 
 class ServiceBulkEditView(generic.BulkEditView):
 class ServiceBulkEditView(generic.BulkEditView):
     queryset = Service.objects.prefetch_related('device', 'virtual_machine')
     queryset = Service.objects.prefetch_related('device', 'virtual_machine')
-    filterset = filters.ServiceFilterSet
+    filterset = filtersets.ServiceFilterSet
     table = tables.ServiceTable
     table = tables.ServiceTable
     form = forms.ServiceBulkEditForm
     form = forms.ServiceBulkEditForm
 
 
 
 
 class ServiceBulkDeleteView(generic.BulkDeleteView):
 class ServiceBulkDeleteView(generic.BulkDeleteView):
     queryset = Service.objects.prefetch_related('device', 'virtual_machine')
     queryset = Service.objects.prefetch_related('device', 'virtual_machine')
-    filterset = filters.ServiceFilterSet
+    filterset = filtersets.ServiceFilterSet
     table = tables.ServiceTable
     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.
 # this setting is derived from the installed location.
 # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
 # 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
 # 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
 # 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.
 # 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 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.models import Circuit, ProviderNetwork, Provider
 from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable
 from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable
-from dcim.filters import (
+from dcim.filtersets import (
     CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, LocationFilterSet,
     CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, LocationFilterSet,
     SiteFilterSet, VirtualChassisFilterSet,
     SiteFilterSet, VirtualChassisFilterSet,
 )
 )
@@ -12,17 +12,17 @@ from dcim.tables import (
     CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, LocationTable, SiteTable,
     CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, LocationTable, SiteTable,
     VirtualChassisTable,
     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.models import Aggregate, IPAddress, Prefix, VLAN, VRF
 from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
 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.models import Secret
 from secrets.tables import SecretTable
 from secrets.tables import SecretTable
-from tenancy.filters import TenantFilterSet
+from tenancy.filtersets import TenantFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from tenancy.tables import TenantTable
 from tenancy.tables import TenantTable
 from utilities.utils import count_related
 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.models import Cluster, VirtualMachine
 from virtualization.tables import ClusterTable, VirtualMachineDetailTable
 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)
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
 SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
 SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
 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_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
 SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
 SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
 SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
 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:
             for section_label, section_items in sections:
                 stat = {"label": section_label, "items": []}
                 stat = {"label": section_label, "items": []}
                 for perm, item_label, description, get_count in section_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:
                     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)
                 stats.append(stat)
             return stats
             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';
 @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
 // Style (SCSS) bundle jobs. Generally, everything should be bundled into netbox.css from main.scss
 // unless there is a specific reason to do otherwise.
 // unless there is a specific reason to do otherwise.
 const styles = [
 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
 // Script (JavaScript) bundle jobs. Generally, everything should be bundled into netbox.js from

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-dark.css.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-external.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-external.css.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-light.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-light.css.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.css.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


Разница между файлами не показана из-за своего большого размера
+ 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-sidebar-title-color: #{$text-muted};
   --nbx-breadcrumb-bg: #{$light};
   --nbx-breadcrumb-bg: #{$light};
   --nbx-body-bg: #{$white};
   --nbx-body-bg: #{$white};
-  --nbx-body-color: #{$black};
+  --nbx-body-color: #{$gray-800};
   --nbx-pre-bg: #{$gray-100};
   --nbx-pre-bg: #{$gray-100};
   --nbx-pre-border-color: #{$gray-600};
   --nbx-pre-border-color: #{$gray-600};
   --nbx-change-added: #{rgba($green, 0.4)};
   --nbx-change-added: #{rgba($green, 0.4)};
@@ -20,6 +20,9 @@
   --nbx-cable-node-border-color: #{$gray-200};
   --nbx-cable-node-border-color: #{$gray-200};
   --nbx-cable-termination-bg: #{$gray-200};
   --nbx-cable-termination-bg: #{$gray-200};
   --nbx-cable-termination-border-color: #{$gray-300};
   --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'] {
   body[data-netbox-color-mode='dark'] {
     --nbx-logo-color-1: #{$white};
     --nbx-logo-color-1: #{$white};
@@ -30,7 +33,7 @@
     --nbx-sidebar-title-color: #{$gray-300};
     --nbx-sidebar-title-color: #{$gray-300};
     --nbx-breadcrumb-bg: #{$gray-800};
     --nbx-breadcrumb-bg: #{$gray-800};
     --nbx-body-bg: #{$gray-900};
     --nbx-body-bg: #{$gray-900};
-    --nbx-body-color: #{$white};
+    --nbx-body-color: #{$gray-50};
     --nbx-pre-bg: #{$gray-700};
     --nbx-pre-bg: #{$gray-700};
     --nbx-pre-border-color: #{$gray-600};
     --nbx-pre-border-color: #{$gray-600};
     --nbx-change-added: #{rgba($green-300, 0.4)};
     --nbx-change-added: #{rgba($green-300, 0.4)};
@@ -39,6 +42,9 @@
     --nbx-cable-node-border-color: #{$gray-600};
     --nbx-cable-node-border-color: #{$gray-600};
     --nbx-cable-termination-bg: #{$gray-800};
     --nbx-cable-termination-bg: #{$gray-800};
     --nbx-cable-termination-border-color: #{$gray-700};
     --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;
   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 {
 body {
   background-color: var(--nbx-body-bg);
   background-color: var(--nbx-body-bg);
   color: var(--nbx-body-color);
   color: var(--nbx-body-color);
@@ -58,9 +89,16 @@ body {
     fill: #1685fc;
     fill: #1685fc;
     stroke: #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'] {
   &[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'] {
   &[data-netbox-color-mode='dark'] {
@@ -92,6 +130,32 @@ body {
       stroke: $gray-200;
       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 {
 div.title-container {
@@ -113,17 +177,35 @@ div.title-container {
 
 
 nav.search {
 nav.search {
   background-color: var(--nbx-body-bg);
   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 {
 main.login-container {
   display: flex;
   display: flex;
   height: calc(100vh - 4rem);
   height: calc(100vh - 4rem);
-  width: 100vw;
+  width: 100%;
+  max-width: 100vw;
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
   flex-direction: column;
   flex-direction: column;
   padding-top: 40px;
   padding-top: 40px;
   padding-bottom: 40px;
   padding-bottom: 40px;
+
+  & + footer.footer button.color-mode-toggle {
+    color: var(--nbx-color-mode-toggle-color);
+  }
 }
 }
 
 
 footer.login-footer {
 footer.login-footer {
@@ -161,7 +243,8 @@ h3.accordion-item-title,
 h4.accordion-item-title,
 h4.accordion-item-title,
 h5.accordion-item-title,
 h5.accordion-item-title,
 h6.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;
   font-weight: $font-weight-bold;
   text-transform: uppercase;
   text-transform: uppercase;
   color: var(--nbx-sidebar-title-color);
   color: var(--nbx-sidebar-title-color);
@@ -201,12 +284,6 @@ li.dropdown-item.dropdown-item-btns {
   align-items: center;
   align-items: center;
 }
 }
 
 
-@media (max-width: 767.98px) {
-  .sidebar {
-    top: 5rem;
-  }
-}
-
 .sidebar-sticky {
 .sidebar-sticky {
   position: relative;
   position: relative;
   top: 0;
   top: 0;
@@ -225,15 +302,26 @@ li.dropdown-item.dropdown-item-btns {
 nav.nav.nav-pills {
 nav.nav.nav-pills {
   .nav-item.nav-link {
   .nav-item.nav-link {
     padding: 0.25rem 0.5rem;
     padding: 0.25rem 0.5rem;
-    font-size: $font-size-base;
+    // font-size: $font-size-base;
+    font-size: $font-size-sm;
     border-radius: $border-radius;
     border-radius: $border-radius;
     &:hover {
     &: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 {
 .sidebar {
   position: fixed;
   position: fixed;
   top: 0;
   top: 0;
@@ -242,26 +330,51 @@ nav.nav.nav-pills {
   z-index: 100; /* Behind the navbar */
   z-index: 100; /* Behind the navbar */
   box-shadow: inset -1px 0 0 rgba(0, 0, 0, 0.1);
   box-shadow: inset -1px 0 0 rgba(0, 0, 0, 0.1);
   background-color: var(--nbx-sidebar-bg);
   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 {
   .accordion-body {
     max-height: calc(100vh - 24rem);
     max-height: calc(100vh - 24rem);
     overflow-y: auto;
     overflow-y: auto;
     .nav-item {
     .nav-item {
       .nav-link {
       .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;
         border-radius: $border-radius;
         &:hover {
         &: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 {
   div.position-sticky {
-    height: calc(100% - 8rem);
+    height: calc(100vh - 8rem);
   }
   }
   div.sidebar-bottom {
   div.sidebar-bottom {
     padding-left: 0.5rem;
     padding-left: 0.5rem;
@@ -499,10 +612,6 @@ table tbody {
     }
     }
   }
   }
 }
 }
-table td,
-table th {
-  font-size: $font-size-sm;
-}
 
 
 // Cable Tracing
 // Cable Tracing
 .cable-trace {
 .cable-trace {

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

@@ -54,7 +54,6 @@
     "plugins": [
     "plugins": [
       "@babel/plugin-proposal-optional-chaining",
       "@babel/plugin-proposal-optional-chaining",
       "@babel/proposal-class-properties",
       "@babel/proposal-class-properties",
-      "@babel/proposal-object-rest-spread",
       "@babel/plugin-proposal-nullish-coalescing-operator"
       "@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-multi-selected .ss-values .ss-disabled,
   div.ss-single-selected span.placeholder .ss-disabled {
   div.ss-single-selected span.placeholder .ss-disabled {
     color: var(--nbx-select-placeholder-color);
     color: var(--nbx-select-placeholder-color);
+    font-size: $font-size-xs;
   }
   }
 
 
   .ss-single-selected {
   .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-left-radius: $form-select-border-radius;
         border-bottom-right-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-left-radius: $form-select-border-radius;
     border-bottom-right-radius: $form-select-border-radius;
     border-bottom-right-radius: $form-select-border-radius;
     .ss-search {
     .ss-search {
       input[type='search'] {
       input[type='search'] {
         background-color: $form-select-bg;
         background-color: $form-select-bg;
+        color: $input-color;
         border: $form-select-border-width solid $form-select-border-color;
         border: $form-select-border-width solid $form-select-border-color;
         &:focus {
         &:focus {
           border-color: $form-select-focus-border-color;
           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() {
 export function initButtons() {
   for (const func of [
   for (const func of [
     initRackElevation,
     initRackElevation,
@@ -272,6 +285,7 @@ export function initButtons() {
     initReslug,
     initReslug,
     initSelectAll,
     initSelectAll,
     initPreferenceUpdate,
     initPreferenceUpdate,
+    initPerPage,
   ]) {
   ]) {
     func();
     func();
   }
   }

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

@@ -1,40 +1,44 @@
 import debounce from 'just-debounce-it';
 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() {
 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.
     // element's value.
     const event = new Event(`netbox.select.onload.${select.name}`);
     const event = new Event(`netbox.select.onload.${select.name}`);
     // Query Parameters - will have attributes added below.
     // 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)) {
     if (hasUrl(select)) {
       // Store the original URL, so it can be referred back to as filter-by elements change.
       // 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';
 @import 'bootstrap/scss/functions';
 
 
 $alt: #13293d;
 $alt: #13293d;
@@ -11,6 +12,7 @@ $green: #10b981;
 $blue: #3b82f6;
 $blue: #3b82f6;
 $purple: #8b5cf6;
 $purple: #8b5cf6;
 $pink: #ec4899;
 $pink: #ec4899;
+$cyan: #06b6d4;
 
 
 $gray-50: #f9fafb;
 $gray-50: #f9fafb;
 $gray-100: #f3f4f6;
 $gray-100: #f3f4f6;
@@ -67,6 +69,17 @@ $blue-700: #1d4ed8;
 $blue-800: #1e40af;
 $blue-800: #1e40af;
 $blue-900: #1e3a8a;
 $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-50: #eef2ff;
 $indigo-100: #e0e7ff;
 $indigo-100: #e0e7ff;
 $indigo-200: #c7d2fe;
 $indigo-200: #c7d2fe;
@@ -119,6 +132,13 @@ $font-weight-lighter: 200;
 $font-weight-medium: 600;
 $font-weight-medium: 600;
 $font-weight-bolder: 800;
 $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: (
 $theme-color-addons: (
   'alt': $alt,
   'alt': $alt,
   'gray': $gray-400,
   'gray': $gray-400,
@@ -173,6 +193,16 @@ $theme-color-addons: (
   'blue-700': $blue-700,
   'blue-700': $blue-700,
   'blue-800': $blue-800,
   'blue-800': $blue-800,
   'blue-900': $blue-900,
   '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-50': $indigo-50,
   'indigo-100': $indigo-100,
   'indigo-100': $indigo-100,
   'indigo-200': $indigo-200,
   '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';
   'Segoe UI Symbol', 'Noto Color Emoji';
 $font-family-monospace: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
 $font-family-monospace: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
   'Courier New', monospace;
   '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-sm: 0 0.125rem 0.25rem rgba($black, 0.075);
 $box-shadow-lg: 0 1rem 3rem rgba($black, 0.175);
 $box-shadow-lg: 0 1rem 3rem rgba($black, 0.175);
 $box-shadow-inset: inset 0 1px 2px rgba($black, 0.075);
 $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;
 $blockquote-footer-color: $gray-600;
 $mark-bg: #fcf8e3;
 $mark-bg: #fcf8e3;
+$link-color: $primary;
+$link-hover-color: $blue-200;
 
 
 // Tables
 // Tables
 $table-color: $gray-100;
 $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-color: $component-active-color;
 $nav-pills-link-active-bg: $component-active-bg;
 $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
 // Dropdowns
 $dropdown-color: $body-color;
 $dropdown-color: $body-color;
 $dropdown-bg: $gray-900;
 $dropdown-bg: $gray-900;
@@ -170,8 +174,8 @@ $accordion-bg: transparent;
 $accordion-border-color: rgba($white, 0.125);
 $accordion-border-color: rgba($white, 0.125);
 $accordion-button-color: $accordion-color;
 $accordion-button-color: $accordion-color;
 $accordion-button-bg: $accordion-bg;
 $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-button-focus-border-color: $input-focus-border-color;
 $accordion-icon-color: $accordion-color;
 $accordion-icon-color: $accordion-color;
 $accordion-icon-active-color: $accordion-button-active-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%);
 $btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);
 
 
 // Code
 // Code
-$code-color: $pink-300;
+$code-color: $gray-200;
 $kbd-color: $white;
 $kbd-color: $white;
 $kbd-bg: $gray-300;
 $kbd-bg: $gray-300;
 $pre-color: null;
 $pre-color: null;

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

@@ -17,5 +17,12 @@ $card-cap-color: $gray-800;
 
 
 $accordion-bg: transparent;
 $accordion-bg: transparent;
 $accordion-button-bg: $accordion-bg;
 $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");
 $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 extras.api.views import CustomFieldModelViewSet
 from netbox.api.views import ModelViewSet
 from netbox.api.views import ModelViewSet
-from secrets import filters
+from secrets import filtersets
 from secrets.exceptions import InvalidKey
 from secrets.exceptions import InvalidKey
 from secrets.models import Secret, SecretRole, SessionKey, UserKey
 from secrets.models import Secret, SecretRole, SessionKey, UserKey
 from utilities.utils import count_related
 from utilities.utils import count_related
@@ -39,7 +39,7 @@ class SecretRoleViewSet(CustomFieldModelViewSet):
         secret_count=count_related(Secret, 'role')
         secret_count=count_related(Secret, 'role')
     )
     )
     serializer_class = serializers.SecretRoleSerializer
     serializer_class = serializers.SecretRoleSerializer
-    filterset_class = filters.SecretRoleFilterSet
+    filterset_class = filtersets.SecretRoleFilterSet
 
 
 
 
 #
 #
@@ -49,7 +49,7 @@ class SecretRoleViewSet(CustomFieldModelViewSet):
 class SecretViewSet(ModelViewSet):
 class SecretViewSet(ModelViewSet):
     queryset = Secret.objects.prefetch_related('role', 'tags')
     queryset = Secret.objects.prefetch_related('role', 'tags')
     serializer_class = serializers.SecretSerializer
     serializer_class = serializers.SecretSerializer
-    filterset_class = filters.SecretFilterSet
+    filterset_class = filtersets.SecretFilterSet
 
 
     master_key = None
     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 django.db.models import Q
 
 
 from dcim.models import Device
 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 virtualization.models import VirtualMachine
 from .models import Secret, SecretRole
 from .models import Secret, SecretRole
 
 
@@ -14,14 +14,14 @@ __all__ = (
 )
 )
 
 
 
 
-class SecretRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet, CreatedUpdatedFilterSet):
+class SecretRoleFilterSet(OrganizationalModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = SecretRole
         model = SecretRole
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class SecretFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+class SecretFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',

+ 1 - 1
netbox/secrets/models.py

@@ -233,7 +233,7 @@ class SessionKey(BigIDModel):
         return session_key
         return session_key
 
 
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class SecretRole(OrganizationalModel):
 class SecretRole(OrganizationalModel):
     """
     """
     A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles
     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 django.test import TestCase
 
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
-from secrets.filters import *
+from secrets.filtersets import *
 from secrets.models import Secret, SecretRole
 from secrets.models import Secret, SecretRole
+from utilities.testing import ChangeLoggedFilterSetTests
 from virtualization.models import Cluster, ClusterType, VirtualMachine
 from virtualization.models import Cluster, ClusterType, VirtualMachine
 
 
 
 
-class SecretRoleTestCase(TestCase):
+class SecretRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = SecretRole.objects.all()
     queryset = SecretRole.objects.all()
     filterset = SecretRoleFilterSet
     filterset = SecretRoleFilterSet
 
 
@@ -20,10 +21,6 @@ class SecretRoleTestCase(TestCase):
         )
         )
         SecretRole.objects.bulk_create(roles)
         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):
     def test_name(self):
         params = {'name': ['Secret Role 1', 'Secret Role 2']}
         params = {'name': ['Secret Role 1', 'Secret Role 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class SecretTestCase(TestCase):
+class SecretTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Secret.objects.all()
     queryset = Secret.objects.all()
     filterset = SecretFilterSet
     filterset = SecretFilterSet
 
 
@@ -80,10 +77,6 @@ class SecretTestCase(TestCase):
         for s in secrets:
         for s in secrets:
             s.save()
             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):
     def test_name(self):
         params = {'name': ['Secret 1', 'Secret 2']}
         params = {'name': ['Secret 1', 'Secret 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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
 import logging
 
 
 from django.contrib import messages
 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.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 
 
 from netbox.views import generic
 from netbox.views import generic
 from utilities.tables import paginate_table
 from utilities.tables import paginate_table
 from utilities.utils import count_related
 from utilities.utils import count_related
-from . import filters, forms, tables
+from . import filtersets, forms, tables
 from .models import SecretRole, Secret, SessionKey, UserKey
 from .models import SecretRole, Secret, SessionKey, UserKey
 
 
 
 
@@ -70,7 +70,7 @@ class SecretRoleBulkEditView(generic.BulkEditView):
     queryset = SecretRole.objects.annotate(
     queryset = SecretRole.objects.annotate(
         secret_count=count_related(Secret, 'role')
         secret_count=count_related(Secret, 'role')
     )
     )
-    filterset = filters.SecretRoleFilterSet
+    filterset = filtersets.SecretRoleFilterSet
     table = tables.SecretRoleTable
     table = tables.SecretRoleTable
     form = forms.SecretRoleBulkEditForm
     form = forms.SecretRoleBulkEditForm
 
 
@@ -88,7 +88,7 @@ class SecretRoleBulkDeleteView(generic.BulkDeleteView):
 
 
 class SecretListView(generic.ObjectListView):
 class SecretListView(generic.ObjectListView):
     queryset = Secret.objects.all()
     queryset = Secret.objects.all()
-    filterset = filters.SecretFilterSet
+    filterset = filtersets.SecretFilterSet
     filterset_form = forms.SecretFilterForm
     filterset_form = forms.SecretFilterForm
     table = tables.SecretTable
     table = tables.SecretTable
     action_buttons = ('add', 'import', 'export')
     action_buttons = ('add', 'import', 'export')
@@ -220,12 +220,12 @@ class SecretBulkImportView(generic.BulkImportView):
 
 
 class SecretBulkEditView(generic.BulkEditView):
 class SecretBulkEditView(generic.BulkEditView):
     queryset = Secret.objects.prefetch_related('role')
     queryset = Secret.objects.prefetch_related('role')
-    filterset = filters.SecretFilterSet
+    filterset = filtersets.SecretFilterSet
     table = tables.SecretTable
     table = tables.SecretTable
     form = forms.SecretBulkEditForm
     form = forms.SecretBulkEditForm
 
 
 
 
 class SecretBulkDeleteView(generic.BulkDeleteView):
 class SecretBulkDeleteView(generic.BulkDeleteView):
     queryset = Secret.objects.prefetch_related('role')
     queryset = Secret.objects.prefetch_related('role')
-    filterset = filters.SecretFilterSet
+    filterset = filtersets.SecretFilterSet
     table = tables.SecretTable
     table = tables.SecretTable

+ 1 - 1
netbox/templates/500.html

@@ -11,7 +11,7 @@
 <body>
 <body>
     <div class="container-fluid">
     <div class="container-fluid">
         <div class="row">
         <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">
                 <div class="card bg-danger mt-5">
                     <h5 class="card-header">
                     <h5 class="card-header">
                         <i class="mdi mdi-alert"></i> Server Error
                         <i class="mdi mdi-alert"></i> Server Error

+ 13 - 3
netbox/templates/base.html

@@ -2,11 +2,21 @@
 <!DOCTYPE html>
 <!DOCTYPE html>
 <html lang="en">
 <html lang="en">
   <head>
   <head>
-    <title>{% block title %}Home{% endblock %} - NetBox</title>
+    <title>{% block title %}Home{% endblock %} | NetBox</title>
     <link
     <link
       rel="stylesheet"
       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' %}" />
     <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 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">
   <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/">
     <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>
       <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 %}
 {% block content %}
 <div class="row">
 <div class="row">
-	<div class="col-md-6">
+	<div class="col col-md-6">
         <div class="card">
         <div class="card">
             <h5 class="card-header">
             <h5 class="card-header">
                 Circuit
                 Circuit
             </h5>
             </h5>
             <div class="card-body">
             <div class="card-body">
                 <table class="table table-hover attr-table">
                 <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>
                     <tr>
                         <th scope="row">Provider</th>
                         <th scope="row">Provider</th>
                         <td>
                         <td>
@@ -50,6 +44,12 @@
                             {% endif %}
                             {% endif %}
                         </td>
                         </td>
                     </tr>
                     </tr>
+                    <tr>
+                        <th scope="row">Status</th>
+                        <td>
+                            <span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span>
+                        </td>
+                    </tr>
                     <tr>
                     <tr>
                         <th scope="row">Install Date</th>
                         <th scope="row">Install Date</th>
                         <td>{{ object.install_date|placeholder }}</td>
                         <td>{{ object.install_date|placeholder }}</td>
@@ -81,14 +81,14 @@
         </div>
         </div>
         {% plugin_left_page object %}
         {% plugin_left_page object %}
 	</div>
 	</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 %}
         {% plugin_right_page object %}
     </div>
     </div>
 </div>
 </div>
 <div class="row">
 <div class="row">
-    <div class="col-md-12">
+    <div class="col col-md-12">
         {% plugin_full_width_page object %}
         {% plugin_full_width_page object %}
     </div>
     </div>
 </div>
 </div>

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -7,7 +7,7 @@
 
 
 {% block content %}
 {% block content %}
 <div class="row mb-3">
 <div class="row mb-3">
-    <div class="col-md-9">
+    <div class="col col-md-9">
         <div class="card">
         <div class="card">
             <div class="card-body">
             <div class="card-body">
                 {% include 'responsive_table.html' %}
                 {% include 'responsive_table.html' %}
@@ -15,7 +15,7 @@
         </div>
         </div>
         {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
         {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
     </div>
     </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' %}
         {% include 'inc/search_panel.html' %}
     </div>
     </div>
 </div>
 </div>

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

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

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

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

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

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

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

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

Некоторые файлы не были показаны из-за большого количества измененных файлов