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

Merge branch 'feature' of https://github.com/netbox-community/netbox into feature

# Conflicts:
#	netbox/dcim/tables/template_code.py
#	netbox/netbox/forms.py
#	netbox/templates/dcim/cable_connect.html
#	netbox/templates/dcim/consoleport.html
#	netbox/templates/dcim/consoleserverport.html
#	netbox/templates/dcim/device.html
#	netbox/templates/dcim/device/base.html
#	netbox/templates/dcim/device_edit.html
#	netbox/templates/dcim/interface.html
#	netbox/templates/dcim/rack.html
#	netbox/templates/dcim/rack_edit.html
#	netbox/templates/dcim/site.html
#	netbox/templates/extras/configcontext.html
#	netbox/templates/extras/objectchange.html
#	netbox/templates/generic/object.html
#	netbox/templates/inc/nav_menu.html
#	netbox/templates/ipam/ipaddress_edit.html
#	netbox/templates/ipam/vrf.html
#	netbox/utilities/templates/buttons/export.html
checktheroads 5 лет назад
Родитель
Сommit
7a3af8de83
100 измененных файлов с 3969 добавлено и 1520 удалено
  1. 1 0
      .github/FUNDING.yml
  2. 0 40
      .github/ISSUE_TEMPLATE/bug_report.md
  3. 63 0
      .github/ISSUE_TEMPLATE/bug_report.yaml
  4. 0 28
      .github/ISSUE_TEMPLATE/documentation_change.md
  5. 38 0
      .github/ISSUE_TEMPLATE/documentation_change.yaml
  6. 0 54
      .github/ISSUE_TEMPLATE/feature_request.md
  7. 58 0
      .github/ISSUE_TEMPLATE/feature_request.yaml
  8. 0 16
      .github/ISSUE_TEMPLATE/housekeeping.md
  9. 27 0
      .github/ISSUE_TEMPLATE/housekeeping.yaml
  10. 1 7
      CONTRIBUTING.md
  11. 5 1
      README.md
  12. 4 0
      base_requirements.txt
  13. 1 1
      docs/additional-features/change-logging.md
  14. 4 9
      docs/additional-features/custom-scripts.md
  15. 15 3
      docs/additional-features/webhooks.md
  16. 3 2
      docs/core-functionality/sites-and-racks.md
  17. 4 4
      docs/development/index.md
  18. 23 17
      docs/development/release-checklist.md
  19. 1 1
      docs/installation/1-postgresql.md
  20. 3 3
      docs/installation/index.md
  21. 1 1
      docs/models/dcim/device.md
  22. 5 0
      docs/models/dcim/location.md
  23. 1 1
      docs/models/dcim/powerpanel.md
  24. 1 1
      docs/models/dcim/rack.md
  25. 0 7
      docs/models/dcim/rackgroup.md
  26. 3 0
      docs/models/dcim/sitegroup.md
  27. 5 0
      docs/models/extras/journalentry.md
  28. 3 1
      docs/models/virtualization/virtualmachine.md
  29. 19 0
      docs/release-notes/version-2.10.md
  30. 97 1
      docs/release-notes/version-2.11.md
  31. 4 4
      netbox/circuits/api/nested_serializers.py
  32. 16 14
      netbox/circuits/api/serializers.py
  33. 38 12
      netbox/circuits/filters.py
  34. 26 4
      netbox/circuits/forms.py
  35. 10 0
      netbox/circuits/migrations/0025_standardize_models.py
  36. 8 18
      netbox/circuits/models.py
  37. 2 4
      netbox/circuits/tables.py
  38. 4 4
      netbox/circuits/tests/test_api.py
  39. 43 15
      netbox/circuits/tests/test_filters.py
  40. 4 0
      netbox/circuits/tests/test_views.py
  41. 4 1
      netbox/circuits/urls.py
  42. 9 0
      netbox/circuits/views.py
  43. 48 37
      netbox/dcim/api/nested_serializers.py
  44. 139 110
      netbox/dcim/api/serializers.py
  45. 2 1
      netbox/dcim/api/urls.py
  46. 27 17
      netbox/dcim/api/views.py
  47. 25 0
      netbox/dcim/choices.py
  48. 155 36
      netbox/dcim/filters.py
  49. 375 104
      netbox/dcim/forms.py
  50. 21 0
      netbox/dcim/migrations/0125_console_port_speed.py
  51. 39 0
      netbox/dcim/migrations/0126_rename_rackgroup_location.py
  52. 17 0
      netbox/dcim/migrations/0127_device_location.py
  53. 24 0
      netbox/dcim/migrations/0128_device_location_populate.py
  54. 17 0
      netbox/dcim/migrations/0129_interface_parent.py
  55. 39 0
      netbox/dcim/migrations/0130_sitegroup.py
  56. 2 1
      netbox/dcim/models/__init__.py
  57. 0 3
      netbox/dcim/models/cables.py
  58. 11 19
      netbox/dcim/models/device_component_templates.py
  59. 72 47
      netbox/dcim/models/device_components.py
  60. 18 9
      netbox/dcim/models/devices.py
  61. 10 14
      netbox/dcim/models/power.py
  62. 22 90
      netbox/dcim/models/racks.py
  63. 133 9
      netbox/dcim/models/sites.py
  64. 12 18
      netbox/dcim/signals.py
  65. 41 30
      netbox/dcim/tables/devices.py
  66. 2 2
      netbox/dcim/tables/power.py
  67. 15 26
      netbox/dcim/tables/racks.py
  68. 28 13
      netbox/dcim/tables/sites.py
  69. 4 16
      netbox/dcim/tables/template_code.py
  70. 90 87
      netbox/dcim/tests/test_api.py
  71. 446 126
      netbox/dcim/tests/test_filters.py
  72. 53 26
      netbox/dcim/tests/test_models.py
  73. 125 55
      netbox/dcim/tests/test_views.py
  74. 35 14
      netbox/dcim/urls.py
  75. 164 43
      netbox/dcim/views.py
  76. 6 6
      netbox/extras/admin.py
  77. 29 9
      netbox/extras/api/nested_serializers.py
  78. 132 52
      netbox/extras/api/serializers.py
  79. 9 0
      netbox/extras/api/urls.py
  80. 35 4
      netbox/extras/api/views.py
  81. 26 0
      netbox/extras/choices.py
  82. 68 2
      netbox/extras/filters.py
  83. 88 9
      netbox/extras/forms.py
  84. 14 10
      netbox/extras/management/commands/renaturalize.py
  85. 5 2
      netbox/extras/management/commands/webhook_receiver.py
  86. 28 0
      netbox/extras/migrations/0055_objectchange_data.py
  87. 17 0
      netbox/extras/migrations/0056_sitegroup.py
  88. 28 0
      netbox/extras/migrations/0057_customlink_rename_fields.py
  89. 31 0
      netbox/extras/migrations/0058_journalentry.py
  90. 3 4
      netbox/extras/models/__init__.py
  91. 12 4
      netbox/extras/models/change_logging.py
  92. 161 0
      netbox/extras/models/configcontexts.py
  93. 52 129
      netbox/extras/models/models.py
  94. 4 2
      netbox/extras/models/tags.py
  95. 10 12
      netbox/extras/scripts.py
  96. 6 0
      netbox/extras/signals.py
  97. 45 1
      netbox/extras/tables.py
  98. 4 4
      netbox/extras/templatetags/custom_links.py
  99. 169 10
      netbox/extras/tests/test_api.py
  100. 222 33
      netbox/extras/tests/test_changelog.py

+ 1 - 0
.github/FUNDING.yml

@@ -0,0 +1 @@
+github: [jeremystretch]

+ 0 - 40
.github/ISSUE_TEMPLATE/bug_report.md

@@ -1,40 +0,0 @@
----
-name: 🐛 Bug Report
-about: Report a reproducible bug in the current release of NetBox
-
----
-
-<!--
-    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
-
-    This form is only for reporting reproducible bugs. If you need assistance
-    with NetBox installation, or if you have a general question, please start a
-    discussion instead: https://github.com/netbox-community/netbox/discussions
-
-    Please describe the environment in which you are running NetBox. Be sure
-    that you are running an unmodified instance of the latest stable release
-    before submitting a bug report, and that any plugins have been disabled.
--->
-### Environment
-* Python version: 
-* NetBox version: 
-
-<!--
-    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.
--->
-### Steps to Reproduce
-1. 
-2. 
-3. 
-
-<!-- What did you expect to happen? -->
-### Expected Behavior
-
-
-<!-- What happened instead? -->
-### Observed Behavior

+ 63 - 0
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -0,0 +1,63 @@
+---
+name: 🐛 Bug Report
+about: Report a reproducible bug in the current release of NetBox
+labels: ["type: bug"]
+body:
+  - type: markdown
+    attributes:
+      value: "**NOTE:** This form is only for reporting _reproducible bugs_ in a
+      current NetBox installation. If you're having trouble with installation or just
+      looking for assistance with using NetBox, please visit our
+      [discussion forum](https://github.com/netbox-community/netbox/discussions) instead."
+  - type: input
+    attributes:
+      label: NetBox version
+      description: "What version of NetBox are you currently running?"
+      placeholder: v2.10.4
+    validations:
+      required: true
+  - type: dropdown
+    attributes:
+      label: Python version
+      description: "What version of Python are you currently running?"
+      options:
+        - 3.6
+        - 3.7
+        - 3.8
+        - 3.9
+    validations:
+      required: true
+  - type: textarea
+    attributes:
+      label: Steps to Reproduce
+      description: "Describe in detail the exact steps that someone else can take to
+      reproduce this bug using the current stable release of NetBox. Begin with the
+      creation of any necessary database objects and call out every operation being
+      performed explicitly. If reporting a bug in the REST API, be sure to reconstruct
+      the raw HTTP request(s) being made: Don't rely on a client  library such as
+      pynetbox."
+      placeholder: |
+        1. Click on "create widget"
+        2. Set foo to 12 and bar to G
+        3. Click the "create" button
+    validations:
+      required: true
+  - type: textarea
+    attributes:
+      label: Expected Behavior
+      description: "What did you expect to happen?"
+      placeholder: "A new widget should have been created with the specified attributes"
+    validations:
+      required: true
+  - type: textarea
+    attributes:
+      label: Observed Behavior
+      description: "What happened instead?"
+      placeholder: "A TypeError exception was raised"
+    validations:
+      required: true
+  - type: markdown
+    attributes:
+      value: |
+        ### Additional information
+        You can use the space below to provide any additional information or to attach files.

+ 0 - 28
.github/ISSUE_TEMPLATE/documentation_change.md

@@ -1,28 +0,0 @@
----
-name: 📖 Documentation Change
-about: Suggest an addition or modification to the NetBox documentation
-
----
-
-<!--
-    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
-
-    Please indicate the nature of the change by placing an X in one of the
-    boxes below.
--->
-### Change Type
-[ ] Addition
-[ ] Correction
-[ ] Deprecation
-[ ] Cleanup (formatting, typos, etc.)
-
-### Area
-[ ] Installation instructions
-[ ] Configuration parameters
-[ ] Functionality/features
-[ ] REST API
-[ ] Administration/development
-[ ] Other
-
-<!-- Describe the proposed change(s). -->
-### Proposed Changes

+ 38 - 0
.github/ISSUE_TEMPLATE/documentation_change.yaml

@@ -0,0 +1,38 @@
+---
+name: 📖 Documentation Change
+about: Suggest an addition or modification to the NetBox documentation
+labels: ["type: documentation"]
+body:
+  - type: dropdown
+    attributes:
+      label: Change Type
+      description: What type of change are you proposing?
+      options:
+        - Addition
+        - Correction
+        - Removal
+        - Cleanup (formatting, typos, etc.)
+    validations:
+      required: true
+  - type: checkboxes
+    attributes:
+      label: Area
+      description: To what section(s) of the documentation does this change pertain?
+      options:
+        - label: Installation instructions
+        - label: Configuration parameters
+        - label: Functionality/features
+        - label: REST API
+        - label: Administration/development
+        - label: Other
+  - type: textarea
+    attributes:
+      label: Proposed Changes
+      description: "Describe the proposed changes and why they are necessary"
+    validations:
+      required: true
+  - type: markdown
+    attributes:
+      value: |
+        ### Additional information
+        You can use the space below to provide any additional information or to attach files.

+ 0 - 54
.github/ISSUE_TEMPLATE/feature_request.md

@@ -1,54 +0,0 @@
----
-name: ✨ Feature Request
-about: Propose a new NetBox feature or enhancement
-
----
-
-<!--
-    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
-
-    This form is only for proposing specific new features or enhancements.
-    If you have a general idea or question, please start a discussion instead:
-    https://github.com/netbox-community/netbox/discussions
-
-    NOTE: Due to an excessive backlog of feature requests, we are not currently
-    accepting any proposals which significantly extend NetBox's feature scope.
-
-    Please describe the environment in which you are running NetBox. Be sure
-    that you are running an unmodified instance of the latest stable release
-    before submitting a bug report.
--->
-### Environment
-* Python version: 
-* NetBox version: 
-
-<!--
-    Describe in detail the new functionality you are proposing. Include any
-    specific changes to work flows, data models, or the user interface.
--->
-### Proposed Functionality
-
-
-<!--
-    Convey an example use case for your proposed feature. Write from the
-    perspective of a NetBox user who would benefit from the proposed
-    functionality and describe how.
---->
-### Use Case
-
-
-<!--
-    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.)
---->
-### Database Changes
-
-
-<!--
-    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.)
--->
-### External Dependencies

+ 58 - 0
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -0,0 +1,58 @@
+---
+name: ✨ Feature Request
+about: Propose a new NetBox feature or enhancement
+labels: ["type: feature"]
+body:
+  - type: markdown
+    attributes:
+      value: "**NOTE:** This form is only for submitting well-formed proposals to extend or
+      modify NetBox in some way. If you're trying to solve a problem but can't figure out how,
+      or if you still need time to work on the details of a proposed new feature, please start
+      a [discussion](https://github.com/netbox-community/netbox/discussions) instead."
+  - type: input
+    attributes:
+      label: NetBox version
+      description: "What version of NetBox are you currently running?"
+      placeholder: v2.10.4
+    validations:
+      required: true
+  - type: dropdown
+    attributes:
+      label: Feature type
+      options:
+        - Data model extension
+        - New functionality
+        - Change to existing functionality
+    validations:
+      required: true
+  - type: textarea
+    attributes:
+      label: Proposed functionality
+      description: "Describe in detail the new feature or behavior you'd like to propose.
+        Include any specific changes to work flows, data models, or the user interface."
+    validations:
+      required: true
+  - type: textarea
+    attributes:
+      label: Use case
+      description: "Explain how adding this functionality would benefit NetBox users. What
+        need does it address?"
+    validations:
+      required: true
+  - type: textarea
+    attributes:
+      label: Database changes
+      description: "Note any changes to the database schema necessary to support the new
+        feature. For example, does the proposal require adding a new model or field? (Not
+        all new features require database changes.)"
+  - type: textarea
+    attributes:
+      label: External dependencies
+      description: "List any new dependencies on external libraries or services that this
+        new feature would introduce. For example, does the proposal require the installation
+        of a new Python package? (Not all new features introduce new dependencies.)"
+  - type: markdown
+    attributes:
+      value: |
+        ### Additional information
+        You can use the space below to provide any additional information or to attach files.

+ 0 - 16
.github/ISSUE_TEMPLATE/housekeeping.md

@@ -1,16 +0,0 @@
----
-name: 🏡 Housekeeping
-about: A change pertaining to the codebase itself (developers only)
-
----
-
-<!--
-    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.
--->
-### Proposed Changes
-
-
-<!-- Provide justification for the proposed change(s). -->
-### Justification

+ 27 - 0
.github/ISSUE_TEMPLATE/housekeeping.yaml

@@ -0,0 +1,27 @@
+---
+name: 🏡 Housekeeping
+about: A change pertaining to the codebase itself (developers only)
+labels: ["type: housekeeping"]
+body:
+  - type: markdown
+    attributes:
+      value: "**NOTE:** This template is for use by maintainers only. Please do not submit
+      an issue using this template unless you have been specifically asked to do so."
+  - type: textarea
+    attributes:
+      label: Proposed Changes
+      description: "Describe in detail the new feature or behavior you'd like to propose.
+        Include any specific changes to work flows, data models, or the user interface."
+    validations:
+      required: true
+  - type: textarea
+    attributes:
+      label: Justification
+      description: "Please provide justification for the proposed change(s)."
+    validations:
+      required: true
+  - type: markdown
+    attributes:
+      value: |
+        ### Additional information
+        You can use the space below to provide any additional information or to attach files.

+ 1 - 7
CONTRIBUTING.md

@@ -25,7 +25,7 @@ discussions.
 
 
 ### Slack
 ### Slack
 
 
-For real-time chat, you can join the **#netbox** Slack channel on [NetworkToCode](https://slack.networktocode.com/).
+For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://join.slack.com/t/netdev-community/shared_invite/zt-mtts8g0n-Sm6Wutn62q_M4OdsaIycrQ).
 Unfortunately, the Slack channel does not provide long-term retention of chat
 Unfortunately, the Slack channel does not provide long-term retention of chat
 history, so try to avoid it for any discussions would benefit from being
 history, so try to avoid it for any discussions would benefit from being
 preserved for future reference.
 preserved for future reference.
@@ -185,11 +185,5 @@ overlooked.
   sync to review agenda items. This meeting provides opportunity to present and
   sync to review agenda items. This meeting provides opportunity to present and
   discuss pressing topics. Meetings are held as virtual audio/video conferences.
   discuss pressing topics. Meetings are held as virtual audio/video conferences.
 
 
-* Official channels for communication include:
-
-    * GitHub issues, pull requests, and discussions
-    * The [netbox-discuss](https://groups.google.com/g/netbox-discuss) mailing list
-    * The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/)
-
 * Maintainers with no substantial recorded activity in a 60-day period will be
 * Maintainers with no substantial recorded activity in a 60-day period will be
   removed from the project.
   removed from the project.

+ 5 - 1
README.md

@@ -12,7 +12,11 @@ complete list of requirements, see `requirements.txt`. The code is available [on
 
 
 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/).
 
 
-Questions? Comments? Start by perusing our [GitHub discussions](https://github.com/netbox-community/netbox/discussions) for the topic you have in mind.
+### 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://join.slack.com/t/netdev-community/shared_invite/zt-mtts8g0n-Sm6Wutn62q_M4OdsaIycrQ) - 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
 ### Build Status
 
 

+ 4 - 0
base_requirements.txt

@@ -93,3 +93,7 @@ redis
 # SVG image rendering (used for rack elevations)
 # SVG image rendering (used for rack elevations)
 # https://github.com/mozman/svgwrite
 # https://github.com/mozman/svgwrite
 svgwrite
 svgwrite
+
+# Tabular dataset library (for table-based exports)
+# https://github.com/jazzband/tablib
+tablib

+ 1 - 1
docs/additional-features/change-logging.md

@@ -1,6 +1,6 @@
 # Change Logging
 # Change Logging
 
 
-Every time an object in NetBox is created, updated, or deleted, a serialized copy of that object is saved to the database, along with meta data including the current time and the user associated with the change. These records form a persistent record of changes both for each individual object as well as NetBox as a whole. The global change log can be viewed by navigating to Other > Change Log.
+Every time an object in NetBox is created, updated, or deleted, a serialized copy of that object taken both before and after the change is saved to the database, along with meta data including the current time and the user associated with the change. These records form a persistent record of changes both for each individual object as well as NetBox as a whole. The global change log can be viewed by navigating to Other > Change Log.
 
 
 A serialized representation of the instance being modified is included in JSON format. This is similar to how objects are conveyed within the REST API, but does not include any nested representations. For instance, the `tenant` field of a site will record only the tenant's ID, not a representation of the tenant.
 A serialized representation of the instance being modified is included in JSON format. This is similar to how objects are conveyed within the REST API, but does not include any nested representations. For instance, the `tenant` field of a site will record only the tenant's ID, not a representation of the tenant.
 
 

+ 4 - 9
docs/additional-features/custom-scripts.md

@@ -170,18 +170,13 @@ Similar to `ChoiceVar`, but allows for the selection of multiple choices.
 A particular object within NetBox. Each ObjectVar must specify a particular model, and allows the user to select one of the available instances. ObjectVar accepts several arguments, listed below.
 A particular object within NetBox. Each ObjectVar must specify a particular model, and allows the user to select one of the available instances. ObjectVar accepts several arguments, listed below.
 
 
 * `model` - The model class
 * `model` - The model class
-* `display_field` - The name of the REST API object field to display in the selection list (default: `'name'`)
+* `display_field` - The name of the REST API object field to display in the selection list (default: `'display'`)
 * `query_params` - A dictionary of query parameters to use when retrieving available options (optional)
 * `query_params` - A dictionary of query parameters to use when retrieving available options (optional)
 * `null_option` - A label representing a "null" or empty choice (optional)
 * `null_option` - A label representing a "null" or empty choice (optional)
 
 
-The `display_field` argument is useful when referencing a model which does not have a `name` field. For example, when displaying a list of device types, you would likely use the `model` field:
-
-```python
-device_type = ObjectVar(
-    model=DeviceType,
-    display_field='model'
-)
-```
+!!! warning
+    The `display_field` parameter is now deprecated, and will be removed in NetBox v2.12. All ObjectVar instances will
+    instead use the new standard `display` field for all serializers (introduced in NetBox v2.11).
 
 
 To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status:
 To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status:
 
 

+ 15 - 3
docs/additional-features/webhooks.md

@@ -38,7 +38,8 @@ The following data is available as context for Jinja2 templates:
 * `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format).
 * `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format).
 * `username` - The name of the user account associated with the change.
 * `username` - The name of the user account associated with the change.
 * `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
 * `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
-* `data` - A serialized representation of the object _after_ the change was made. This is typically equivalent to the model's representation in NetBox's REST API.
+* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
+* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided ass a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
 
 
 ### Default Request Body
 ### Default Request Body
 
 
@@ -47,7 +48,7 @@ If no body template is specified, the request body will be populated with a JSON
 ```no-highlight
 ```no-highlight
 {
 {
     "event": "created",
     "event": "created",
-    "timestamp": "2020-02-25 15:10:26.010582+00:00",
+    "timestamp": "2021-03-09 17:55:33.968016+00:00",
     "model": "site",
     "model": "site",
     "username": "jstretch",
     "username": "jstretch",
     "request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a",
     "request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a",
@@ -62,13 +63,24 @@ If no body template is specified, the request body will be populated with a JSON
         },
         },
         "region": null,
         "region": null,
         ...
         ...
+    },
+    "snapshots": {
+        "prechange": null,
+        "postchange": {
+            "created": "2021-03-09",
+            "last_updated": "2021-03-09T17:55:33.851Z",
+            "name": "Site 1",
+            "slug": "site-1",
+            "status": "active",
+            ...
+        }
     }
     }
 }
 }
 ```
 ```
 
 
 ## Webhook Processing
 ## Webhook Processing
 
 
-When a change is detected, any resulting webhooks are placed into a Redis queue for processing. This allows the user's request to complete without needing to wait for the outgoing webhook(s) to be processed. The webhooks are then extracted from the queue by the `rqworker` process and HTTP requests are sent to their respective destinations. The current webhook queue and any failed webhooks can be inspected in the admin UI under Django RQ > Queues.
+When a change is detected, any resulting webhooks are placed into a Redis queue for processing. This allows the user's request to complete without needing to wait for the outgoing webhook(s) to be processed. The webhooks are then extracted from the queue by the `rqworker` process and HTTP requests are sent to their respective destinations. The current webhook queue and any failed webhooks can be inspected in the admin UI under System > Background Tasks.
 
 
 A request is considered successful if the response has a 2XX status code; otherwise, the request is marked as having failed. Failed requests may be retried manually via the admin UI.
 A request is considered successful if the response has a 2XX status code; otherwise, the request is marked as having failed. Failed requests may be retried manually via the admin UI.
 
 

+ 3 - 2
docs/core-functionality/sites-and-racks.md

@@ -1,11 +1,12 @@
 # Sites and Racks
 # Sites and Racks
 
 
-{!docs/models/dcim/site.md!}
 {!docs/models/dcim/region.md!}
 {!docs/models/dcim/region.md!}
+{!docs/models/dcim/sitegroup.md!}
+{!docs/models/dcim/site.md!}
+{!docs/models/dcim/location.md!}
 
 
 ---
 ---
 
 
 {!docs/models/dcim/rack.md!}
 {!docs/models/dcim/rack.md!}
-{!docs/models/dcim/rackgroup.md!}
 {!docs/models/dcim/rackrole.md!}
 {!docs/models/dcim/rackrole.md!}
 {!docs/models/dcim/rackreservation.md!}
 {!docs/models/dcim/rackreservation.md!}

+ 4 - 4
docs/development/index.md

@@ -4,12 +4,12 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
 
 
 ## Communication
 ## Communication
 
 
-Communication among developers should always occur via public channels:
+There are several official forums for communication among the developers and community members:
 
 
 * [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
 * [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
-* [GitHub discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
-* [The mailing list](https://groups.google.com/g/netbox-discuss) - An alternative forum for general discussion (GitHub is preferred).
-* [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
+* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
+* [#netbox on NetDev Community Slack](https://join.slack.com/t/netdev-community/shared_invite/zt-mtts8g0n-Sm6Wutn62q_M4OdsaIycrQ) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
+* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions.
 
 
 ## Governance
 ## Governance
 
 

+ 23 - 17
docs/development/release-checklist.md

@@ -2,24 +2,9 @@
 
 
 ## Minor Version Bumps
 ## Minor Version Bumps
 
 
-### Update Requirements
-
-Required Python packages are maintained in two files. `base_requirements.txt` contains a list of all the packages required by NetBox. Some of them may be pinned to a specific version of the package due to a known issue. For example:
-
-```
-# https://github.com/encode/django-rest-framework/issues/6053
-djangorestframework==3.8.1
-```
-
-The other file is `requirements.txt`, which lists each of the required packages pinned to its current stable version. When NetBox is installed, the Python environment is configured to match this file. This helps ensure that a new release of a dependency doesn't break NetBox.
+### Address Pinned Dependencies
 
 
-Every minor version release should refresh `requirements.txt` so that it lists the most recent stable release of each package. To do this:
-
-1. Create a new virtual environment.
-2. Install the latest version of all required packages `pip install -U -r base_requirements.txt`).
-3. Run all tests and check that the UI and API function as expected.
-4. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
-5. Update the package versions in `requirements.txt` as appropriate.
+Check `base_requirements.txt` for any dependencies pinned to a specific version, and upgrade them to their most stable release (where possible).
 
 
 ### Update Static Libraries
 ### Update Static Libraries
 
 
@@ -58,6 +43,27 @@ Submit a pull request to merge the `feature` branch into the `develop` branch in
 
 
 ## All Releases
 ## All Releases
 
 
+### Update Requirements
+
+Required Python packages are maintained in two files. `base_requirements.txt` contains a list of all the packages required by NetBox. Some of them may be pinned to a specific version of the package due to a known issue. For example:
+
+```
+# https://github.com/encode/django-rest-framework/issues/6053
+djangorestframework==3.8.1
+```
+
+The other file is `requirements.txt`, which lists each of the required packages pinned to its current stable version. When NetBox is installed, the Python environment is configured to match this file. This helps ensure that a new release of a dependency doesn't break NetBox.
+
+Every release should refresh `requirements.txt` so that it lists the most recent stable release of each package. To do this:
+
+1. Create a new virtual environment.
+2. Install the latest version of all required packages `pip install -U -r base_requirements.txt`).
+3. Run all tests and check that the UI and API function as expected.
+4. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
+5. Update the package versions in `requirements.txt` as appropriate.
+
+In cases where upgrading a dependency to its most recent release is breaking, it should be pinned to its current minor version in `base_requirements.txt` (with an explanatory comment) and revisited for the next major NetBox release.
+
 ### Verify CI Build Status
 ### Verify CI Build Status
 
 
 Ensure that continuous integration testing on the `develop` branch is completing successfully.
 Ensure that continuous integration testing on the `develop` branch is completing successfully.

+ 1 - 1
docs/installation/1-postgresql.md

@@ -52,7 +52,7 @@ $ sudo -u postgres psql
 psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
 psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
 Type "help" for help.
 Type "help" for help.
 
 
-postgres=# CREATE DATABASE netbox ENCODING 'UTF8' LC_COLLATE='C.UTF-8' LC_CTYPE='C.UTF-8';
+postgres=# CREATE DATABASE netbox;
 CREATE DATABASE
 CREATE DATABASE
 postgres=# CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
 postgres=# CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
 CREATE ROLE
 CREATE ROLE

+ 3 - 3
docs/installation/index.md

@@ -23,6 +23,9 @@ The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04
 | PostgreSQL | 9.6             |
 | PostgreSQL | 9.6             |
 | Redis      | 4.0             |
 | Redis      | 4.0             |
 
 
+!!! note
+    Python 3.7 or later will be required in NetBox v2.12. Users are strongly encouraged to install NetBox using Python 3.7 or later for new deployments.
+
 Below is a simplified overview of the NetBox application stack for reference:
 Below is a simplified overview of the NetBox application stack for reference:
 
 
 ![NetBox UI as seen by a non-authenticated user](../media/installation/netbox_application_stack.png)
 ![NetBox UI as seen by a non-authenticated user](../media/installation/netbox_application_stack.png)
@@ -30,6 +33,3 @@ Below is a simplified overview of the NetBox application stack for reference:
 ## Upgrading
 ## Upgrading
 
 
 If you are upgrading from an existing installation, please consult the [upgrading guide](upgrading.md).
 If you are upgrading from an existing installation, please consult the [upgrading guide](upgrading.md).
-
-!!! note
-    Beginning with v2.5.9, the official documentation calls for systemd to be used for managing the WSGI workers in place of supervisord.  Please see the instructions for [migrating to systemd](migrating-to-systemd.md) if you are still using supervisord.

+ 1 - 1
docs/models/dcim/device.md

@@ -8,7 +8,7 @@ A device is said to be full-depth if its installation on one rack face prevents
 
 
 Each device must be instantiated from a pre-created device type, and its default components (console ports, power ports, interfaces, etc.) will be created automatically. (The device type associated with a device may be changed after its creation, however its components will not be updated retroactively.)
 Each device must be instantiated from a pre-created device type, and its default components (console ports, power ports, interfaces, etc.) will be created automatically. (The device type associated with a device may be changed after its creation, however its components will not be updated retroactively.)
 
 
-Each device must be assigned a site, device role, and operational status, and may optionally be assigned to a specific rack within a site. A platform, serial number, and asset tag may optionally be assigned to each device.
+Each device must be assigned a site, device role, and operational status, and may optionally be assigned to a specific location and/or within a site. A platform, serial number, and asset tag may optionally be assigned to each device.
 
 
 Device names must be unique within a site, unless the device has been assigned to a tenant. Devices may also be unnamed.
 Device names must be unique within a site, unless the device has been assigned to a tenant. Devices may also be unnamed.
 
 

+ 5 - 0
docs/models/dcim/location.md

@@ -0,0 +1,5 @@
+# Locations
+
+Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar concept. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor.
+
+The name and facility ID of each rack within a location must be unique. (Racks not assigned to the same location may have identical names and/or facility IDs.)

+ 1 - 1
docs/models/dcim/powerpanel.md

@@ -2,7 +2,7 @@
 
 
 A power panel represents the origin point in NetBox for electrical power being disseminated by one or more power feeds. In a data center environment, one power panel often serves a group of racks, with an individual power feed extending to each rack, though this is not always the case. It is common to have two sets of panels and feeds arranged in parallel to provide redundant power to each rack.
 A power panel represents the origin point in NetBox for electrical power being disseminated by one or more power feeds. In a data center environment, one power panel often serves a group of racks, with an individual power feed extending to each rack, though this is not always the case. It is common to have two sets of panels and feeds arranged in parallel to provide redundant power to each rack.
 
 
-Each power panel must be assigned to a site, and may optionally be assigned to a particular rack group.
+Each power panel must be assigned to a site, and may optionally be assigned to a particular location within that site.
 
 
 !!! note
 !!! note
     NetBox does not model the mechanism by which power is delivered to a power panel. Power panels define the root level of the power distribution hierarchy in NetBox.
     NetBox does not model the mechanism by which power is delivered to a power panel. Power panels define the root level of the power distribution hierarchy in NetBox.

+ 1 - 1
docs/models/dcim/rack.md

@@ -1,6 +1,6 @@
 # Racks
 # Racks
 
 
-The rack model represents a physical two- or four-post equipment rack in which devices can be installed. Each rack must be assigned to a site, and may optionally be assigned to a rack group and/or tenant. Racks can also be organized by user-defined functional roles.
+The rack model represents a physical two- or four-post equipment rack in which devices can be installed. Each rack must be assigned to a site, and may optionally be assigned to a location and/or tenant. Racks can also be organized by user-defined functional roles.
 
 
 Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending (from the ground up) or descending order.
 Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending (from the ground up) or descending order.
 
 

+ 0 - 7
docs/models/dcim/rackgroup.md

@@ -1,7 +0,0 @@
-# Rack Groups
-
-Racks can be organized into groups, which can be nested into themselves similar to regions. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room.
-
-Each rack group must be assigned to a parent site, and rack groups may optionally be nested within a site to model a multi-level hierarchy. For example, you might have a tier of rooms beneath a tier of floors, all belonging to the same parent building (site).
-
-The name and facility ID of each rack within a group must be unique. (Racks not assigned to the same rack group may have identical names and/or facility IDs.)

+ 3 - 0
docs/models/dcim/sitegroup.md

@@ -0,0 +1,3 @@
+# Site Groups
+
+Like regions, site groups can be used to organize sites. Whereas regions are intended to provide geographic organization, site groups can be used to classify sites by role or function. Also like regions, site groups can be nested to form a hierarchy. Sites which belong to a child group are also considered to be members of any of its parent groups.

+ 5 - 0
docs/models/extras/journalentry.md

@@ -0,0 +1,5 @@
+# Journal Entries
+
+All primary objects in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside of NetBox. Unlike the change log, which is typically limited in the amount of history it retains, journal entries never expire.
+
+Each journal entry has a user-populated `commnets` field. Each entry records the date and time, associated user, and object automatically upon being created.

+ 3 - 1
docs/models/virtualization/virtualmachine.md

@@ -11,4 +11,6 @@ Like devices, each VM can be assigned a platform and/or functional role, and mus
 * Failed
 * Failed
 * Decommissioning
 * Decommissioning
 
 
-Additional fields are available for annotating the vCPU count, memory (GB), and disk (GB) allocated to each VM. Each VM may optionally be assigned to a tenant. Virtual machines may have virtual interfaces assigned to them, but do not support any physical component.
+Additional fields are available for annotating the vCPU count, memory (GB), and disk (GB) allocated to each VM. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU).
+
+Each VM may optionally be assigned to a tenant. Virtual machines may have virtual interfaces assigned to them, but do not support any physical component.

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

@@ -1,5 +1,24 @@
 # NetBox v2.10
 # NetBox v2.10
 
 
+## v2.10.6 (2021-03-09)
+
+### Enhancements
+
+* [#5592](https://github.com/netbox-community/netbox/issues/5592) - Add IP addresses count to VRF view
+* [#5630](https://github.com/netbox-community/netbox/issues/5630) - Add QSFP+ (64GFC) FibreChannel Interface option
+* [#5884](https://github.com/netbox-community/netbox/issues/5884) - Enable custom links for device components
+* [#5914](https://github.com/netbox-community/netbox/issues/5914) - Add edit/delete buttons for IP addresses on interface view
+* [#5942](https://github.com/netbox-community/netbox/issues/5942) - Add button to add a new IP address on interface view
+
+### Bug Fixes
+
+* [#5703](https://github.com/netbox-community/netbox/issues/5703) - Fix VRF and Tenant field population when adding IP addresses from prefix
+* [#5819](https://github.com/netbox-community/netbox/issues/5819) - Enable ordering of virtual machines by primary IP address
+* [#5872](https://github.com/netbox-community/netbox/issues/5872) - Ordering of devices by primary IP should respect `PREFER_IPV4` configuration parameter
+* [#5922](https://github.com/netbox-community/netbox/issues/5922) - Fix options for filtering object permissions in admin UI
+* [#5935](https://github.com/netbox-community/netbox/issues/5935) - Fix filtering prefixes list by multiple prefix values
+* [#5948](https://github.com/netbox-community/netbox/issues/5948) - Invalidate cached queries when running `renaturalize`
+
 ## v2.10.5 (2021-02-24)
 ## v2.10.5 (2021-02-24)
 
 
 ### Bug Fixes
 ### Bug Fixes

+ 97 - 1
docs/release-notes/version-2.11.md

@@ -4,30 +4,98 @@
 
 
 **WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.11 release will be provided from this beta, and users should assume that all data entered into the application will be lost.
 **WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.11 release will be provided from this beta, and users should assume that all data entered into the application will be lost.
 
 
+**Note:** NetBox v2.11 is the last major release that will support Python 3.6. Beginning with NetBox v2.12, Python 3.7 or
+later will be required.
+
 ### New Features
 ### New Features
 
 
+#### Journaling Support ([#151](https://github.com/netbox-community/netbox/issues/151))
+
+NetBox now supports journaling for all primary objects. The journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside of NetBox. Unlike the change log, which is typically limited in the amount of history it retains, journal entries never expire.
+
+#### Parent Interface Assignments ([#1519](https://github.com/netbox-community/netbox/issues/1519))
+
+Virtual interfaces can now be assigned to a "parent" physical interface, by setting the `parent` field on the Interface model. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 to the physical interface Gi0/0.
+
+#### Pre- and Post-Change Snapshots in Webhooks ([#3451](https://github.com/netbox-community/netbox/issues/3451))
+
+In conjunction with the newly improved change logging functionality ([#5913](https://github.com/netbox-community/netbox/issues/5913)), outgoing webhooks now include a pre- and post-change representation of the modified object. These are available in the rendering context as a dictionary named `snapshots` with keys `prechange` and `postchange`. For example, here are the abridged snapshots resulting from renaming a site and changing its status:
+
+```json
+"snapshots": {
+    "prechange": {
+        "name": "Site 1",
+        "slug": "site-1",
+        "status": "active",
+        ...
+    },
+    "postchange": {
+        "name": "Site 2",
+        "slug": "site-2",
+        "status": "planned",
+        ...
+    }
+}
+```
+
+Note: The pre-change snapshot for an object creation will always be null, as will the post-change snapshot for an object deletion.
+
 #### Mark as Connected Without a Cable ([#3648](https://github.com/netbox-community/netbox/issues/3648))
 #### Mark as Connected Without a Cable ([#3648](https://github.com/netbox-community/netbox/issues/3648))
 
 
 Cable termination objects (circuit terminations, power feeds, and most device components) can now be marked as "connected" without actually attaching a cable. This helps simplify the process of modeling an infrastructure boundary where you don't necessarily know or care what is connected to the far end of a cable, but still need to designate the near end termination.
 Cable termination objects (circuit terminations, power feeds, and most device components) can now be marked as "connected" without actually attaching a cable. This helps simplify the process of modeling an infrastructure boundary where you don't necessarily know or care what is connected to the far end of a cable, but still need to designate the near end termination.
 
 
-In addition to the new `mark_connected` boolean field, the REST API representation of these objects now also includes a read-only boolean field named `_occupied`. This conveniently returns true if either a cable is attached or `mark_connected` is true. 
+In addition to the new `mark_connected` boolean field, the REST API representation of these objects now also includes a read-only boolean field named `_occupied`. This conveniently returns true if either a cable is attached or `mark_connected` is true.
+
+#### Allow Assigning Devices to Locations ([#4971](https://github.com/netbox-community/netbox/issues/4971))
+
+Devices can now be assigned to locations (formerly known as rack groups) within a site without needing to be assigned to a particular rack. This is handy for assigning devices to rooms or floors within a building where racks are not used. The `location` foreign key field has been added to the Device model to support this.
+
+#### Dynamic Object Exports ([#4999](https://github.com/netbox-community/netbox/issues/4999))
+
+When exporting a list of objects in NetBox, users now have the option of selecting the "current view". This will render CSV output matching the configuration of the current table. For example, if you modify the sites list to display on the site name, tenant, and status, the rendered CSV will include only these columns.
+
+The legacy static export behavior has been retained to ensure backward compatibility for dependent integrations. However, users are strongly encouraged to adapt custom export templates where needed as this functionality will be removed in v2.12.
+
+#### Variable Scope Support for VLAN Groups ([#5284](https://github.com/netbox-community/netbox/issues/5284))
+
+In previous releases, VLAN groups could be assigned only to a site. To afford more flexibility in conveying the true scope of an L2 domain, a VLAN group can now be assigned to a region, site group (new in v2.11), site, location, or rack. VLANs assigned to a group will be available only to devices and virtual machines which exist within its scope.
+
+For example, a VLAN within a group assigned to a location will be available only to devices assigned to that location (or one of its child locations), or to a rack within that location.
+
+#### New Site Group Model ([#5892](https://github.com/netbox-community/netbox/issues/5892))
+
+This release introduces the new Site Group model, which can be used to organize sites similar to the existing Region model. Whereas regions are intended for geographically arranging sites into countries, states, and so on, the new site group model can be used to organize sites by role or other arbitrary classification. Using regions and site groups in conjunction provides two dimensions along which sites can be organized, offering greater flexibility to the user.
+
+#### Improved Change Logging ([#5913](https://github.com/netbox-community/netbox/issues/5913))
+
+The ObjectChange model (which is used to record the creation, modification, and deletion of NetBox objects) now explicitly records the pre-change and post-change state of each object, rather than only the post-change state. This was done to present a more clear depiction of each change being made, and to prevent the erroneous association of a previous unlogged change with its successor.
 
 
 ### Enhancements
 ### Enhancements
 
 
 * [#5370](https://github.com/netbox-community/netbox/issues/5370) - Extend custom field support to organizational models
 * [#5370](https://github.com/netbox-community/netbox/issues/5370) - Extend custom field support to organizational models
+* [#5375](https://github.com/netbox-community/netbox/issues/5375) - Add `speed` attribute to console port models
 * [#5401](https://github.com/netbox-community/netbox/issues/5401) - Extend custom field support to device component models
 * [#5401](https://github.com/netbox-community/netbox/issues/5401) - Extend custom field support to device component models
 * [#5451](https://github.com/netbox-community/netbox/issues/5451) - Add support for multiple-selection custom fields
 * [#5451](https://github.com/netbox-community/netbox/issues/5451) - Add support for multiple-selection custom fields
+* [#5608](https://github.com/netbox-community/netbox/issues/5608) - Add REST API endpoint for custom links
+* [#5610](https://github.com/netbox-community/netbox/issues/5610) - Add REST API endpoint for webhooks
+* [#5891](https://github.com/netbox-community/netbox/issues/5891) - Add `display` field to all REST API serializers
 * [#5894](https://github.com/netbox-community/netbox/issues/5894) - Use primary keys when filtering object lists by related objects in the UI
 * [#5894](https://github.com/netbox-community/netbox/issues/5894) - Use primary keys when filtering object lists by related objects in the UI
+* [#5895](https://github.com/netbox-community/netbox/issues/5895) - Rename RackGroup to Location
 * [#5901](https://github.com/netbox-community/netbox/issues/5901) - Add `created` and `last_updated` fields to device component models
 * [#5901](https://github.com/netbox-community/netbox/issues/5901) - Add `created` and `last_updated` fields to device component models
+* [#5972](https://github.com/netbox-community/netbox/issues/5972) - Enable bulk editing for organizational models
+* [#5975](https://github.com/netbox-community/netbox/issues/5975) - Allow partial vCPU allocations for virtual machines
 
 
 ### Other Changes
 ### Other Changes
 
 
 * [#1638](https://github.com/netbox-community/netbox/issues/1638) - Migrate all primary keys to 64-bit integers
 * [#1638](https://github.com/netbox-community/netbox/issues/1638) - Migrate all primary keys to 64-bit integers
 * [#5873](https://github.com/netbox-community/netbox/issues/5873) - Use numeric IDs in all object URLs
 * [#5873](https://github.com/netbox-community/netbox/issues/5873) - Use numeric IDs in all object URLs
+* [#5990](https://github.com/netbox-community/netbox/issues/5990) - Deprecated `display_field` parameter for custom script ObjectVar and MultiObjectVar fields
+* [#5995](https://github.com/netbox-community/netbox/issues/5995) - Dropped backward compatibility for `queryset` parameter on ObjectVar and MultiObjectVar (use `model` instead)
 
 
 ### REST API Changes
 ### REST API Changes
 
 
 * All primary keys are now 64-bit integers
 * All primary keys are now 64-bit integers
+* All model serializers now include a `display` field to be used for the presentation of an object to a human user
 * All device components
 * All device components
   * Added support for custom fields
   * Added support for custom fields
   * Added `created` and `last_updated` fields to track object creation and modification
   * Added `created` and `last_updated` fields to track object creation and modification
@@ -38,5 +106,33 @@ In addition to the new `mark_connected` boolean field, the REST API representati
 * All cable termination models (cabled device components, power feeds, and circuit terminations)
 * All cable termination models (cabled device components, power feeds, and circuit terminations)
   * Added `mark_connected` boolean field to force connection status
   * Added `mark_connected` boolean field to force connection status
   * Added `_occupied` read-only boolean field as common attribute for determining whether an object is occupied
   * Added `_occupied` read-only boolean field as common attribute for determining whether an object is occupied
+* Renamed RackGroup to Location
+  * The `/dcim/rack-groups/` endpoint is now `/dcim/locations/`
+* dcim.Device
+  * Added the `location` field
+* dcim.Interface
+  * Added the `parent` field
+* dcim.PowerPanel
+  * Renamed `rack_group` field to `location`
+* dcim.Rack
+  * Renamed `group` field to `location`
+* dcim.Site
+  * Added the `group` foreign key field to SiteGroup
+* dcim.SiteGroup
+  * Added the `/api/dcim/site-groups/` endpoint
+* extras.ConfigContext
+  * Added the `site_groups` many-to-many field to track the assignment of ConfigContexts to SiteGroups
 * extras.CustomField
 * extras.CustomField
   * Added new custom field type: `multi-select`
   * Added new custom field type: `multi-select`
+* extras.CustomLink
+  * Added the `/api/extras/custom-links/` endpoint
+* extras.ObjectChange
+  * Added the `prechange_data` field
+  * Renamed `object_data` to `postchange_data`
+* extras.Webhook
+  * Added the `/api/extras/webhooks/` endpoint
+* ipam.VLANGroup
+  * Added the `scope_type`, `scope_id`, and `scope` fields (`scope` is a generic foreign key)
+  * Dropped the `site` foreign key field
+* virtualization.VirtualMachine
+  * `vcpus` has been changed from an integer to a decimal value

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

@@ -21,7 +21,7 @@ class NestedProviderSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = Provider
         model = Provider
-        fields = ['id', 'url', 'name', 'slug', 'circuit_count']
+        fields = ['id', 'url', 'display', 'name', 'slug', 'circuit_count']
 
 
 
 
 #
 #
@@ -34,7 +34,7 @@ class NestedCircuitTypeSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
-        fields = ['id', 'url', 'name', 'slug', 'circuit_count']
+        fields = ['id', 'url', 'display', 'name', 'slug', 'circuit_count']
 
 
 
 
 class NestedCircuitSerializer(WritableNestedSerializer):
 class NestedCircuitSerializer(WritableNestedSerializer):
@@ -42,7 +42,7 @@ class NestedCircuitSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
-        fields = ['id', 'url', 'cid']
+        fields = ['id', 'url', 'display', 'cid']
 
 
 
 
 class NestedCircuitTerminationSerializer(WritableNestedSerializer):
 class NestedCircuitTerminationSerializer(WritableNestedSerializer):
@@ -51,4 +51,4 @@ class NestedCircuitTerminationSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
-        fields = ['id', 'url', 'circuit', 'term_side', 'cable', '_occupied']
+        fields = ['id', 'url', 'display', 'circuit', 'term_side', 'cable', '_occupied']

+ 16 - 14
netbox/circuits/api/serializers.py

@@ -4,10 +4,10 @@ from circuits.choices import CircuitStatusChoices
 from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
 from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
 from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer
 from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer
-from netbox.api.serializers import CustomFieldModelSerializer
-from extras.api.serializers import TaggedObjectSerializer
 from netbox.api import ChoiceField
 from netbox.api import ChoiceField
-from netbox.api.serializers import OrganizationalModelSerializer, WritableNestedSerializer
+from netbox.api.serializers import (
+    BaseModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, WritableNestedSerializer
+)
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from .nested_serializers import *
 from .nested_serializers import *
 
 
@@ -16,15 +16,15 @@ from .nested_serializers import *
 # Providers
 # Providers
 #
 #
 
 
-class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class ProviderSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
     circuit_count = serializers.IntegerField(read_only=True)
     circuit_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = Provider
         model = Provider
         fields = [
         fields = [
-            'id', 'url', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
-            'custom_fields', 'created', 'last_updated', 'circuit_count',
+            'id', 'url', 'display', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact',
+            'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
         ]
         ]
 
 
 
 
@@ -39,7 +39,8 @@ class CircuitTypeSerializer(OrganizationalModelSerializer):
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
         fields = [
         fields = [
-            'id', 'url', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', 'circuit_count',
+            'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
+            'circuit_count',
         ]
         ]
 
 
 
 
@@ -50,12 +51,12 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEnd
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
         fields = [
         fields = [
-            'id', 'url', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'connected_endpoint',
+            'id', 'url', 'display', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'connected_endpoint',
             'connected_endpoint_type', 'connected_endpoint_reachable',
             'connected_endpoint_type', 'connected_endpoint_reachable',
         ]
         ]
 
 
 
 
-class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class CircuitSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
     provider = NestedProviderSerializer()
     provider = NestedProviderSerializer()
     status = ChoiceField(choices=CircuitStatusChoices, required=False)
     status = ChoiceField(choices=CircuitStatusChoices, required=False)
@@ -67,12 +68,13 @@ class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
         fields = [
         fields = [
-            'id', 'url', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
-            'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate',
+            'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created',
+            'last_updated',
         ]
         ]
 
 
 
 
-class CircuitTerminationSerializer(CableTerminationSerializer, ConnectedEndpointSerializer):
+class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
     circuit = NestedCircuitSerializer()
     circuit = NestedCircuitSerializer()
     site = NestedSiteSerializer()
     site = NestedSiteSerializer()
@@ -81,7 +83,7 @@ class CircuitTerminationSerializer(CableTerminationSerializer, ConnectedEndpoint
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
         fields = [
         fields = [
-            'id', 'url', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
-            'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint',
+            'id', 'url', 'display', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id',
+            'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint',
             'connected_endpoint_type', 'connected_endpoint_reachable', '_occupied',
             'connected_endpoint_type', 'connected_endpoint_reachable', '_occupied',
         ]
         ]

+ 38 - 12
netbox/circuits/filters.py

@@ -2,7 +2,7 @@ import django_filters
 from django.db.models import Q
 from django.db.models import Q
 
 
 from dcim.filters import CableTerminationFilterSet, PathEndpointFilterSet
 from dcim.filters import CableTerminationFilterSet, PathEndpointFilterSet
-from dcim.models import Region, Site
+from dcim.models import Region, Site, SiteGroup
 from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
 from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
 from tenancy.filters import TenancyFilterSet
 from tenancy.filters import TenancyFilterSet
 from utilities.filters import (
 from utilities.filters import (
@@ -37,6 +37,19 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdated
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
+    site_group_id = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='circuits__terminations__site__group',
+        lookup_expr='in',
+        label='Site group (ID)',
+    )
+    site_group = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='circuits__terminations__site__group',
+        lookup_expr='in',
+        to_field_name='slug',
+        label='Site group (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         field_name='circuits__terminations__site',
         field_name='circuits__terminations__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
@@ -102,17 +115,6 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe
         choices=CircuitStatusChoices,
         choices=CircuitStatusChoices,
         null_value=None
         null_value=None
     )
     )
-    site_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='terminations__site',
-        queryset=Site.objects.all(),
-        label='Site (ID)',
-    )
-    site = django_filters.ModelMultipleChoiceFilter(
-        field_name='terminations__site__slug',
-        queryset=Site.objects.all(),
-        to_field_name='slug',
-        label='Site (slug)',
-    )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         field_name='terminations__site__region',
         field_name='terminations__site__region',
@@ -126,6 +128,30 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
+    site_group_id = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='terminations__site__group',
+        lookup_expr='in',
+        label='Site group (ID)',
+    )
+    site_group = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='terminations__site__group',
+        lookup_expr='in',
+        to_field_name='slug',
+        label='Site group (slug)',
+    )
+    site_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='terminations__site',
+        queryset=Site.objects.all(),
+        label='Site (ID)',
+    )
+    site = django_filters.ModelMultipleChoiceFilter(
+        field_name='terminations__site__slug',
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        label='Site (slug)',
+    )
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:

+ 26 - 4
netbox/circuits/forms.py

@@ -1,7 +1,7 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from dcim.models import Region, Site
+from dcim.models import Region, Site, SiteGroup
 from extras.forms import (
 from extras.forms import (
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
 )
 )
@@ -142,6 +142,20 @@ class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
         ]
         ]
 
 
 
 
+class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=CircuitType.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['description']
+
+
 class CircuitTypeCSVForm(CustomFieldModelCSVForm):
 class CircuitTypeCSVForm(CustomFieldModelCSVForm):
     slug = SlugField()
     slug = SlugField()
 
 
@@ -320,18 +334,26 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
             'sites': '$site'
             'sites': '$site'
         }
         }
     )
     )
+    site_group = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         query_params={
         query_params={
-            'region_id': '$region'
+            'region_id': '$region',
+            'group_id': '$site_group',
         }
         }
     )
     )
 
 
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
         fields = [
         fields = [
-            'term_side', 'region', 'site', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
-            'description',
+            'term_side', 'region', 'site_group', 'site', 'mark_connected', 'port_speed', 'upstream_speed',
+            'xconnect_id', 'pp_info', 'description',
         ]
         ]
         help_texts = {
         help_texts = {
             'port_speed': "Physical circuit speed",
             'port_speed': "Physical circuit speed",

+ 10 - 0
netbox/circuits/migrations/0025_standardize_models.py

@@ -34,4 +34,14 @@ class Migration(migrations.Migration):
             name='id',
             name='id',
             field=models.BigAutoField(primary_key=True, serialize=False),
             field=models.BigAutoField(primary_key=True, serialize=False),
         ),
         ),
+        migrations.AddField(
+            model_name='circuittermination',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='circuittermination',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
     ]
     ]

+ 8 - 18
netbox/circuits/models.py

@@ -1,14 +1,12 @@
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
-from taggit.managers import TaggableManager
 
 
 from dcim.fields import ASNField
 from dcim.fields import ASNField
 from dcim.models import CableTermination, PathEndpoint
 from dcim.models import CableTermination, PathEndpoint
-from extras.models import ObjectChange, TaggedItem
+from extras.models import ObjectChange
 from extras.utils import extras_features
 from extras.utils import extras_features
-from netbox.models import BigIDModel, OrganizationalModel, PrimaryModel
+from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
-from utilities.utils import serialize_object
 from .choices import *
 from .choices import *
 from .querysets import CircuitQuerySet
 from .querysets import CircuitQuerySet
 
 
@@ -61,7 +59,6 @@ class Provider(PrimaryModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
-    tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
@@ -185,7 +182,6 @@ class Circuit(PrimaryModel):
     )
     )
 
 
     objects = CircuitQuerySet.as_manager()
     objects = CircuitQuerySet.as_manager()
-    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
         'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
@@ -235,7 +231,8 @@ class Circuit(PrimaryModel):
         return self._get_termination('Z')
         return self._get_termination('Z')
 
 
 
 
-class CircuitTermination(BigIDModel, PathEndpoint, CableTermination):
+@extras_features('webhooks')
+class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination):
     circuit = models.ForeignKey(
     circuit = models.ForeignKey(
         to='circuits.Circuit',
         to='circuits.Circuit',
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,
@@ -289,21 +286,14 @@ class CircuitTermination(BigIDModel, PathEndpoint, CableTermination):
     def to_objectchange(self, action):
     def to_objectchange(self, action):
         # Annotate the parent Circuit
         # Annotate the parent Circuit
         try:
         try:
-            related_object = self.circuit
+            circuit = self.circuit
         except Circuit.DoesNotExist:
         except Circuit.DoesNotExist:
             # Parent circuit has been deleted
             # Parent circuit has been deleted
-            related_object = None
-
-        return ObjectChange(
-            changed_object=self,
-            object_repr=str(self),
-            action=action,
-            related_object=related_object,
-            object_data=serialize_object(self)
-        )
+            circuit = None
+        return super().to_objectchange(action, related_object=circuit)
 
 
     @property
     @property
-    def parent(self):
+    def parent_object(self):
         return self.circuit
         return self.circuit
 
 
     def get_peer_termination(self):
     def get_peer_termination(self):

+ 2 - 4
netbox/circuits/tables.py

@@ -1,7 +1,7 @@
 import django_tables2 as tables
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
-from tenancy.tables import COL_TENANT
+from tenancy.tables import TenantColumn
 from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
 from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
 from .models import Circuit, CircuitType, Provider
 from .models import Circuit, CircuitType, Provider
 
 
@@ -60,9 +60,7 @@ class CircuitTable(BaseTable):
         linkify=True
         linkify=True
     )
     )
     status = ChoiceFieldColumn()
     status = ChoiceFieldColumn()
-    tenant = tables.TemplateColumn(
-        template_code=COL_TENANT
-    )
+    tenant = TenantColumn()
     a_side = tables.Column(
     a_side = tables.Column(
         verbose_name='A Side'
         verbose_name='A Side'
     )
     )

+ 4 - 4
netbox/circuits/tests/test_api.py

@@ -17,7 +17,7 @@ class AppTest(APITestCase):
 
 
 class ProviderTest(APIViewTestCases.APIViewTestCase):
 class ProviderTest(APIViewTestCases.APIViewTestCase):
     model = Provider
     model = Provider
-    brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url']
+    brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
     create_data = [
     create_data = [
         {
         {
             'name': 'Provider 4',
             'name': 'Provider 4',
@@ -49,7 +49,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
 
 
 class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
 class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
     model = CircuitType
     model = CircuitType
-    brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url']
+    brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
     create_data = (
     create_data = (
         {
         {
             'name': 'Circuit Type 4',
             'name': 'Circuit Type 4',
@@ -81,7 +81,7 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
 
 
 class CircuitTest(APIViewTestCases.APIViewTestCase):
 class CircuitTest(APIViewTestCases.APIViewTestCase):
     model = Circuit
     model = Circuit
-    brief_fields = ['cid', 'id', 'url']
+    brief_fields = ['cid', 'display', 'id', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'status': 'planned',
         'status': 'planned',
     }
     }
@@ -129,7 +129,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
 
 
 class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
 class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
     model = CircuitTermination
     model = CircuitTermination
-    brief_fields = ['_occupied', 'cable', 'circuit', 'id', 'term_side', 'url']
+    brief_fields = ['_occupied', 'cable', 'circuit', 'display', 'id', 'term_side', 'url']
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):

+ 43 - 15
netbox/circuits/tests/test_filters.py

@@ -3,7 +3,7 @@ from django.test import TestCase
 from circuits.choices import *
 from circuits.choices import *
 from circuits.filters import *
 from circuits.filters import *
 from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
 from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
-from dcim.models import Cable, Region, Site
+from dcim.models import Cable, Region, Site, SiteGroup
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 
 
 
 
@@ -27,13 +27,20 @@ class ProviderTestCase(TestCase):
             Region(name='Test Region 1', slug='test-region-1'),
             Region(name='Test Region 1', slug='test-region-1'),
             Region(name='Test Region 2', slug='test-region-2'),
             Region(name='Test Region 2', slug='test-region-2'),
         )
         )
-        # Can't use bulk_create for models with MPTT fields
         for r in regions:
         for r in regions:
             r.save()
             r.save()
 
 
+        site_groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for site_group in site_groups:
+            site_group.save()
+
         sites = (
         sites = (
-            Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
-            Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
+            Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
+            Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
@@ -74,13 +81,6 @@ class ProviderTestCase(TestCase):
         params = {'account': ['1234', '2345']}
         params = {'account': ['1234', '2345']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-    def test_site(self):
-        sites = Site.objects.all()[:2]
-        params = {'site_id': [sites[0].pk, sites[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'site': [sites[0].slug, sites[1].slug]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
     def test_region(self):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -88,6 +88,20 @@ class ProviderTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_site(self):
+        sites = Site.objects.all()[:2]
+        params = {'site_id': [sites[0].pk, sites[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site': [sites[0].slug, sites[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 
 class CircuitTypeTestCase(TestCase):
 class CircuitTypeTestCase(TestCase):
     queryset = CircuitType.objects.all()
     queryset = CircuitType.objects.all()
@@ -127,14 +141,21 @@ class CircuitTestCase(TestCase):
             Region(name='Test Region 2', slug='test-region-2'),
             Region(name='Test Region 2', slug='test-region-2'),
             Region(name='Test Region 3', slug='test-region-3'),
             Region(name='Test Region 3', slug='test-region-3'),
         )
         )
-        # Can't use bulk_create for models with MPTT fields
         for r in regions:
         for r in regions:
             r.save()
             r.save()
 
 
+        site_groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for site_group in site_groups:
+            site_group.save()
+
         sites = (
         sites = (
-            Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
-            Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
-            Site(name='Test Site 3', slug='test-site-3', region=regions[2]),
+            Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
+            Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
+            Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
@@ -223,6 +244,13 @@ class CircuitTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        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]}

+ 4 - 0
netbox/circuits/tests/test_views.py

@@ -73,6 +73,10 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Circuit Type 6,circuit-type-6",
             "Circuit Type 6,circuit-type-6",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'description': 'Foo',
+        }
+
 
 
 class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Circuit
     model = Circuit

+ 4 - 1
netbox/circuits/urls.py

@@ -1,7 +1,7 @@
 from django.urls import path
 from django.urls import path
 
 
 from dcim.views import CableCreateView, PathTraceView
 from dcim.views import CableCreateView, PathTraceView
-from extras.views import ObjectChangeLogView
+from extras.views import ObjectChangeLogView, ObjectJournalView
 from . import views
 from . import views
 from .models import Circuit, CircuitTermination, CircuitType, Provider
 from .models import Circuit, CircuitTermination, CircuitType, Provider
 
 
@@ -18,11 +18,13 @@ urlpatterns = [
     path('providers/<int:pk>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
     path('providers/<int:pk>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
     path('providers/<int:pk>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
     path('providers/<int:pk>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
     path('providers/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
     path('providers/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
+    path('providers/<int:pk>/journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}),
 
 
     # Circuit types
     # Circuit types
     path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
     path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
     path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'),
     path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'),
     path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
     path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
+    path('circuit-types/edit/', views.CircuitTypeBulkEditView.as_view(), name='circuittype_bulk_edit'),
     path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
     path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
     path('circuit-types/<int:pk>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
     path('circuit-types/<int:pk>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
     path('circuit-types/<int:pk>/delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'),
     path('circuit-types/<int:pk>/delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'),
@@ -38,6 +40,7 @@ urlpatterns = [
     path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
     path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
     path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
     path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
     path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
     path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
+    path('circuits/<int:pk>/journal/', ObjectJournalView.as_view(), name='circuit_journal', kwargs={'model': Circuit}),
     path('circuits/<int:pk>/terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
     path('circuits/<int:pk>/terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
 
 
     # Circuit terminations
     # Circuit terminations

+ 9 - 0
netbox/circuits/views.py

@@ -107,6 +107,15 @@ class CircuitTypeBulkImportView(generic.BulkImportView):
     table = tables.CircuitTypeTable
     table = tables.CircuitTypeTable
 
 
 
 
+class CircuitTypeBulkEditView(generic.BulkEditView):
+    queryset = CircuitType.objects.annotate(
+        circuit_count=count_related(Circuit, 'type')
+    )
+    filterset = filters.CircuitTypeFilterSet
+    table = tables.CircuitTypeTable
+    form = forms.CircuitTypeBulkEditForm
+
+
 class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
 class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = CircuitType.objects.annotate(
     queryset = CircuitType.objects.annotate(
         circuit_count=count_related(Circuit, 'type')
         circuit_count=count_related(Circuit, 'type')

+ 48 - 37
netbox/dcim/api/nested_serializers.py

@@ -1,7 +1,7 @@
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from dcim import models
 from dcim import models
-from netbox.api import WritableNestedSerializer
+from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer
 
 
 __all__ = [
 __all__ = [
     'NestedCableSerializer',
     'NestedCableSerializer',
@@ -27,7 +27,7 @@ __all__ = [
     'NestedPowerPanelSerializer',
     'NestedPowerPanelSerializer',
     'NestedPowerPortSerializer',
     'NestedPowerPortSerializer',
     'NestedPowerPortTemplateSerializer',
     'NestedPowerPortTemplateSerializer',
-    'NestedRackGroupSerializer',
+    'NestedLocationSerializer',
     'NestedRackReservationSerializer',
     'NestedRackReservationSerializer',
     'NestedRackRoleSerializer',
     'NestedRackRoleSerializer',
     'NestedRackSerializer',
     'NestedRackSerializer',
@@ -35,6 +35,7 @@ __all__ = [
     'NestedRearPortTemplateSerializer',
     'NestedRearPortTemplateSerializer',
     'NestedRegionSerializer',
     'NestedRegionSerializer',
     'NestedSiteSerializer',
     'NestedSiteSerializer',
+    'NestedSiteGroupSerializer',
     'NestedVirtualChassisSerializer',
     'NestedVirtualChassisSerializer',
 ]
 ]
 
 
@@ -50,7 +51,17 @@ class NestedRegionSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.Region
         model = models.Region
-        fields = ['id', 'url', 'name', 'slug', 'site_count', '_depth']
+        fields = ['id', 'url', 'display', 'name', 'slug', 'site_count', '_depth']
+
+
+class NestedSiteGroupSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
+    site_count = serializers.IntegerField(read_only=True)
+    _depth = serializers.IntegerField(source='level', read_only=True)
+
+    class Meta:
+        model = models.SiteGroup
+        fields = ['id', 'url', 'display', 'name', 'slug', 'site_count', '_depth']
 
 
 
 
 class NestedSiteSerializer(WritableNestedSerializer):
 class NestedSiteSerializer(WritableNestedSerializer):
@@ -58,21 +69,21 @@ class NestedSiteSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.Site
         model = models.Site
-        fields = ['id', 'url', 'name', 'slug']
+        fields = ['id', 'url', 'display', 'name', 'slug']
 
 
 
 
 #
 #
 # Racks
 # Racks
 #
 #
 
 
-class NestedRackGroupSerializer(WritableNestedSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
+class NestedLocationSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
     rack_count = serializers.IntegerField(read_only=True)
     rack_count = serializers.IntegerField(read_only=True)
     _depth = serializers.IntegerField(source='level', read_only=True)
     _depth = serializers.IntegerField(source='level', read_only=True)
 
 
     class Meta:
     class Meta:
-        model = models.RackGroup
-        fields = ['id', 'url', 'name', 'slug', 'rack_count', '_depth']
+        model = models.Location
+        fields = ['id', 'url', 'display', 'name', 'slug', 'rack_count', '_depth']
 
 
 
 
 class NestedRackRoleSerializer(WritableNestedSerializer):
 class NestedRackRoleSerializer(WritableNestedSerializer):
@@ -81,7 +92,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.RackRole
         model = models.RackRole
-        fields = ['id', 'url', 'name', 'slug', 'rack_count']
+        fields = ['id', 'url', 'display', 'name', 'slug', 'rack_count']
 
 
 
 
 class NestedRackSerializer(WritableNestedSerializer):
 class NestedRackSerializer(WritableNestedSerializer):
@@ -90,7 +101,7 @@ class NestedRackSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.Rack
         model = models.Rack
-        fields = ['id', 'url', 'name', 'display_name', 'device_count']
+        fields = ['id', 'url', 'display', 'name', 'display_name', 'device_count']
 
 
 
 
 class NestedRackReservationSerializer(WritableNestedSerializer):
 class NestedRackReservationSerializer(WritableNestedSerializer):
@@ -99,7 +110,7 @@ class NestedRackReservationSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.RackReservation
         model = models.RackReservation
-        fields = ['id', 'url', 'user', 'units']
+        fields = ['id', 'url', 'display', 'user', 'units']
 
 
     def get_user(self, obj):
     def get_user(self, obj):
         return obj.user.username
         return obj.user.username
@@ -115,7 +126,7 @@ class NestedManufacturerSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.Manufacturer
         model = models.Manufacturer
-        fields = ['id', 'url', 'name', 'slug', 'devicetype_count']
+        fields = ['id', 'url', 'display', 'name', 'slug', 'devicetype_count']
 
 
 
 
 class NestedDeviceTypeSerializer(WritableNestedSerializer):
 class NestedDeviceTypeSerializer(WritableNestedSerializer):
@@ -125,7 +136,7 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.DeviceType
         model = models.DeviceType
-        fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count']
+        fields = ['id', 'url', 'display', 'manufacturer', 'model', 'slug', 'display_name', 'device_count']
 
 
 
 
 class NestedConsolePortTemplateSerializer(WritableNestedSerializer):
 class NestedConsolePortTemplateSerializer(WritableNestedSerializer):
@@ -133,7 +144,7 @@ class NestedConsolePortTemplateSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.ConsolePortTemplate
         model = models.ConsolePortTemplate
-        fields = ['id', 'url', 'name']
+        fields = ['id', 'url', 'display', 'name']
 
 
 
 
 class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer):
 class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer):
@@ -141,7 +152,7 @@ class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.ConsoleServerPortTemplate
         model = models.ConsoleServerPortTemplate
-        fields = ['id', 'url', 'name']
+        fields = ['id', 'url', 'display', 'name']
 
 
 
 
 class NestedPowerPortTemplateSerializer(WritableNestedSerializer):
 class NestedPowerPortTemplateSerializer(WritableNestedSerializer):
@@ -149,7 +160,7 @@ class NestedPowerPortTemplateSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.PowerPortTemplate
         model = models.PowerPortTemplate
-        fields = ['id', 'url', 'name']
+        fields = ['id', 'url', 'display', 'name']
 
 
 
 
 class NestedPowerOutletTemplateSerializer(WritableNestedSerializer):
 class NestedPowerOutletTemplateSerializer(WritableNestedSerializer):
@@ -157,7 +168,7 @@ class NestedPowerOutletTemplateSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.PowerOutletTemplate
         model = models.PowerOutletTemplate
-        fields = ['id', 'url', 'name']
+        fields = ['id', 'url', 'display', 'name']
 
 
 
 
 class NestedInterfaceTemplateSerializer(WritableNestedSerializer):
 class NestedInterfaceTemplateSerializer(WritableNestedSerializer):
@@ -165,7 +176,7 @@ class NestedInterfaceTemplateSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.InterfaceTemplate
         model = models.InterfaceTemplate
-        fields = ['id', 'url', 'name']
+        fields = ['id', 'url', 'display', 'name']
 
 
 
 
 class NestedRearPortTemplateSerializer(WritableNestedSerializer):
 class NestedRearPortTemplateSerializer(WritableNestedSerializer):
@@ -173,7 +184,7 @@ class NestedRearPortTemplateSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.RearPortTemplate
         model = models.RearPortTemplate
-        fields = ['id', 'url', 'name']
+        fields = ['id', 'url', 'display', 'name']
 
 
 
 
 class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
 class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
@@ -181,7 +192,7 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.FrontPortTemplate
         model = models.FrontPortTemplate
-        fields = ['id', 'url', 'name']
+        fields = ['id', 'url', 'display', 'name']
 
 
 
 
 class NestedDeviceBayTemplateSerializer(WritableNestedSerializer):
 class NestedDeviceBayTemplateSerializer(WritableNestedSerializer):
@@ -189,7 +200,7 @@ class NestedDeviceBayTemplateSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.DeviceBayTemplate
         model = models.DeviceBayTemplate
-        fields = ['id', 'url', 'name']
+        fields = ['id', 'url', 'display', 'name']
 
 
 
 
 #
 #
@@ -203,7 +214,7 @@ class NestedDeviceRoleSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.DeviceRole
         model = models.DeviceRole
-        fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
+        fields = ['id', 'url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count']
 
 
 
 
 class NestedPlatformSerializer(WritableNestedSerializer):
 class NestedPlatformSerializer(WritableNestedSerializer):
@@ -213,7 +224,7 @@ class NestedPlatformSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.Platform
         model = models.Platform
-        fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
+        fields = ['id', 'url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count']
 
 
 
 
 class NestedDeviceSerializer(WritableNestedSerializer):
 class NestedDeviceSerializer(WritableNestedSerializer):
@@ -221,7 +232,7 @@ class NestedDeviceSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.Device
         model = models.Device
-        fields = ['id', 'url', 'name', 'display_name']
+        fields = ['id', 'url', 'display', 'name', 'display_name']
 
 
 
 
 class NestedConsoleServerPortSerializer(WritableNestedSerializer):
 class NestedConsoleServerPortSerializer(WritableNestedSerializer):
@@ -230,7 +241,7 @@ class NestedConsoleServerPortSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.ConsoleServerPort
         model = models.ConsoleServerPort
-        fields = ['id', 'url', 'device', 'name', 'cable', '_occupied']
+        fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
 
 
 
 
 class NestedConsolePortSerializer(WritableNestedSerializer):
 class NestedConsolePortSerializer(WritableNestedSerializer):
@@ -239,7 +250,7 @@ class NestedConsolePortSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.ConsolePort
         model = models.ConsolePort
-        fields = ['id', 'url', 'device', 'name', 'cable', '_occupied']
+        fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
 
 
 
 
 class NestedPowerOutletSerializer(WritableNestedSerializer):
 class NestedPowerOutletSerializer(WritableNestedSerializer):
@@ -248,7 +259,7 @@ class NestedPowerOutletSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.PowerOutlet
         model = models.PowerOutlet
-        fields = ['id', 'url', 'device', 'name', 'cable', '_occupied']
+        fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
 
 
 
 
 class NestedPowerPortSerializer(WritableNestedSerializer):
 class NestedPowerPortSerializer(WritableNestedSerializer):
@@ -257,7 +268,7 @@ class NestedPowerPortSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.PowerPort
         model = models.PowerPort
-        fields = ['id', 'url', 'device', 'name', 'cable', '_occupied']
+        fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
 
 
 
 
 class NestedInterfaceSerializer(WritableNestedSerializer):
 class NestedInterfaceSerializer(WritableNestedSerializer):
@@ -266,7 +277,7 @@ class NestedInterfaceSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.Interface
         model = models.Interface
-        fields = ['id', 'url', 'device', 'name', 'cable', '_occupied']
+        fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
 
 
 
 
 class NestedRearPortSerializer(WritableNestedSerializer):
 class NestedRearPortSerializer(WritableNestedSerializer):
@@ -275,7 +286,7 @@ class NestedRearPortSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.RearPort
         model = models.RearPort
-        fields = ['id', 'url', 'device', 'name', 'cable', '_occupied']
+        fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
 
 
 
 
 class NestedFrontPortSerializer(WritableNestedSerializer):
 class NestedFrontPortSerializer(WritableNestedSerializer):
@@ -284,7 +295,7 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.FrontPort
         model = models.FrontPort
-        fields = ['id', 'url', 'device', 'name', 'cable', '_occupied']
+        fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
 
 
 
 
 class NestedDeviceBaySerializer(WritableNestedSerializer):
 class NestedDeviceBaySerializer(WritableNestedSerializer):
@@ -293,7 +304,7 @@ class NestedDeviceBaySerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.DeviceBay
         model = models.DeviceBay
-        fields = ['id', 'url', 'device', 'name']
+        fields = ['id', 'url', 'display', 'device', 'name']
 
 
 
 
 class NestedInventoryItemSerializer(WritableNestedSerializer):
 class NestedInventoryItemSerializer(WritableNestedSerializer):
@@ -303,19 +314,19 @@ class NestedInventoryItemSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.InventoryItem
         model = models.InventoryItem
-        fields = ['id', 'url', 'device', 'name', '_depth']
+        fields = ['id', 'url', 'display', 'device', 'name', '_depth']
 
 
 
 
 #
 #
 # Cables
 # Cables
 #
 #
 
 
-class NestedCableSerializer(serializers.ModelSerializer):
+class NestedCableSerializer(BaseModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
 
 
     class Meta:
     class Meta:
         model = models.Cable
         model = models.Cable
-        fields = ['id', 'url', 'label']
+        fields = ['id', 'url', 'display', 'label']
 
 
 
 
 #
 #
@@ -342,7 +353,7 @@ class NestedPowerPanelSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.PowerPanel
         model = models.PowerPanel
-        fields = ['id', 'url', 'name', 'powerfeed_count']
+        fields = ['id', 'url', 'display', 'name', 'powerfeed_count']
 
 
 
 
 class NestedPowerFeedSerializer(WritableNestedSerializer):
 class NestedPowerFeedSerializer(WritableNestedSerializer):
@@ -350,4 +361,4 @@ class NestedPowerFeedSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.PowerFeed
         model = models.PowerFeed
-        fields = ['id', 'url', 'name', 'cable', '_occupied']
+        fields = ['id', 'url', 'display', 'name', 'cable', '_occupied']

+ 139 - 110
netbox/dcim/api/serializers.py

@@ -6,20 +6,13 @@ from rest_framework.validators import UniqueTogetherValidator
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
-from dcim.models import (
-    Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
-    Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
-    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
-    VirtualChassis,
-)
-from netbox.api.serializers import CustomFieldModelSerializer
-from extras.api.serializers import TaggedObjectSerializer
+from dcim.models import *
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField
 from netbox.api.serializers import (
 from netbox.api.serializers import (
-    NestedGroupModelSerializer, OrganizationalModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
+    NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer,
+    WritableNestedSerializer,
 )
 )
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from users.api.nested_serializers import NestedUserSerializer
 from users.api.nested_serializers import NestedUserSerializer
@@ -49,7 +42,7 @@ class CableTerminationSerializer(serializers.ModelSerializer):
         return None
         return None
 
 
 
 
-class ConnectedEndpointSerializer(CustomFieldModelSerializer):
+class ConnectedEndpointSerializer(serializers.ModelSerializer):
     connected_endpoint_type = serializers.SerializerMethodField(read_only=True)
     connected_endpoint_type = serializers.SerializerMethodField(read_only=True)
     connected_endpoint = serializers.SerializerMethodField(read_only=True)
     connected_endpoint = serializers.SerializerMethodField(read_only=True)
     connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
     connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
@@ -89,15 +82,29 @@ class RegionSerializer(NestedGroupModelSerializer):
     class Meta:
     class Meta:
         model = Region
         model = Region
         fields = [
         fields = [
-            'id', 'url', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
+            'site_count', '_depth',
+        ]
+
+
+class SiteGroupSerializer(NestedGroupModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
+    parent = NestedRegionSerializer(required=False, allow_null=True)
+    site_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = SiteGroup
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
             'site_count', '_depth',
             'site_count', '_depth',
         ]
         ]
 
 
 
 
-class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class SiteSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
     status = ChoiceField(choices=SiteStatusChoices, required=False)
     status = ChoiceField(choices=SiteStatusChoices, required=False)
     region = NestedRegionSerializer(required=False, allow_null=True)
     region = NestedRegionSerializer(required=False, allow_null=True)
+    group = NestedSiteGroupSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     time_zone = TimeZoneField(required=False)
     time_zone = TimeZoneField(required=False)
     circuit_count = serializers.IntegerField(read_only=True)
     circuit_count = serializers.IntegerField(read_only=True)
@@ -110,10 +117,10 @@ class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     class Meta:
     class Meta:
         model = Site
         model = Site
         fields = [
         fields = [
-            'id', 'url', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
-            'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
-            'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
-            'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
+            'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn',
+            'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
+            'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
         ]
         ]
 
 
 
 
@@ -121,17 +128,17 @@ class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
 # Racks
 # Racks
 #
 #
 
 
-class RackGroupSerializer(NestedGroupModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
+class LocationSerializer(NestedGroupModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
     site = NestedSiteSerializer()
     site = NestedSiteSerializer()
-    parent = NestedRackGroupSerializer(required=False, allow_null=True)
+    parent = NestedLocationSerializer(required=False, allow_null=True)
     rack_count = serializers.IntegerField(read_only=True)
     rack_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
-        model = RackGroup
+        model = Location
         fields = [
         fields = [
-            'id', 'url', 'name', 'slug', 'site', 'parent', 'description', 'custom_fields', 'created', 'last_updated',
-            'rack_count', '_depth',
+            'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'description', 'custom_fields', 'created',
+            'last_updated', 'rack_count', '_depth',
         ]
         ]
 
 
 
 
@@ -142,15 +149,15 @@ class RackRoleSerializer(OrganizationalModelSerializer):
     class Meta:
     class Meta:
         model = RackRole
         model = RackRole
         fields = [
         fields = [
-            'id', 'url', 'name', 'slug', 'color', 'description', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'custom_fields', 'created', 'last_updated',
             'rack_count',
             'rack_count',
         ]
         ]
 
 
 
 
-class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class RackSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
     site = NestedSiteSerializer()
     site = NestedSiteSerializer()
-    group = NestedRackGroupSerializer(required=False, allow_null=True, default=None)
+    location = NestedLocationSerializer(required=False, allow_null=True, default=None)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=RackStatusChoices, required=False)
     status = ChoiceField(choices=RackStatusChoices, required=False)
     role = NestedRackRoleSerializer(required=False, allow_null=True)
     role = NestedRackRoleSerializer(required=False, allow_null=True)
@@ -163,21 +170,22 @@ class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     class Meta:
     class Meta:
         model = Rack
         model = Rack
         fields = [
         fields = [
-            'id', 'url', 'name', 'facility_id', 'display_name', 'site', 'group', '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', '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',
         ]
         ]
-        # Omit the UniqueTogetherValidator that would be automatically added to validate (group, 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.
         validators = [
         validators = [
-            UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('group', 'name'))
+            UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('location', 'name'))
         ]
         ]
 
 
     def validate(self, data):
     def validate(self, data):
 
 
-        # Validate uniqueness of (group, facility_id) since we omitted the automatically-created validator from Meta.
+        # Validate uniqueness of (location, facility_id) since we omitted the automatically-created validator from Meta.
         if data.get('facility_id', None):
         if data.get('facility_id', None):
-            validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('group', 'facility_id'))
+            validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('location', 'facility_id'))
             validator(data, self)
             validator(data, self)
 
 
         # Enforce model validation
         # Enforce model validation
@@ -197,7 +205,7 @@ class RackUnitSerializer(serializers.Serializer):
     occupied = serializers.BooleanField(read_only=True)
     occupied = serializers.BooleanField(read_only=True)
 
 
 
 
-class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class RackReservationSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
     rack = NestedRackSerializer()
     rack = NestedRackSerializer()
     user = NestedUserSerializer()
     user = NestedUserSerializer()
@@ -205,7 +213,10 @@ class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializ
 
 
     class Meta:
     class Meta:
         model = RackReservation
         model = RackReservation
-        fields = ['id', 'url', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags', 'custom_fields']
+        fields = [
+            'id', 'url', 'display', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags',
+            'custom_fields',
+        ]
 
 
 
 
 class RackElevationDetailFilterSerializer(serializers.Serializer):
 class RackElevationDetailFilterSerializer(serializers.Serializer):
@@ -257,12 +268,12 @@ class ManufacturerSerializer(OrganizationalModelSerializer):
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
         fields = [
         fields = [
-            'id', 'url', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', 'devicetype_count',
-            'inventoryitem_count', 'platform_count',
+            'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated',
+            'devicetype_count', 'inventoryitem_count', 'platform_count',
         ]
         ]
 
 
 
 
-class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class DeviceTypeSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
     manufacturer = NestedManufacturerSerializer()
     manufacturer = NestedManufacturerSerializer()
     subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
     subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
@@ -271,9 +282,9 @@ class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
         fields = [
         fields = [
-            'id', 'url', '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', 'display_name', 'part_number', 'u_height',
+            'is_full_depth', 'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields',
+            'created', 'last_updated', 'device_count',
         ]
         ]
 
 
 
 
@@ -288,7 +299,9 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = ConsolePortTemplate
         model = ConsolePortTemplate
-        fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated']
+        fields = [
+            'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated',
+        ]
 
 
 
 
 class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
 class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
@@ -302,7 +315,9 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPortTemplate
         model = ConsoleServerPortTemplate
-        fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated']
+        fields = [
+            'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated',
+        ]
 
 
 
 
 class PowerPortTemplateSerializer(ValidatedModelSerializer):
 class PowerPortTemplateSerializer(ValidatedModelSerializer):
@@ -317,8 +332,8 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
     class Meta:
     class Meta:
         model = PowerPortTemplate
         model = PowerPortTemplate
         fields = [
         fields = [
-            'id', 'url', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
-            'created', 'last_updated',
+            'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
+            'description', 'created', 'last_updated',
         ]
         ]
 
 
 
 
@@ -342,8 +357,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
     class Meta:
     class Meta:
         model = PowerOutletTemplate
         model = PowerOutletTemplate
         fields = [
         fields = [
-            'id', 'url', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'created',
-            'last_updated',
+            'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
+            'created', 'last_updated',
         ]
         ]
 
 
 
 
@@ -355,7 +370,8 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
         fields = [
         fields = [
-            'id', 'url', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created', 'last_updated',
+            'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created',
+            'last_updated',
         ]
         ]
 
 
 
 
@@ -367,7 +383,8 @@ class RearPortTemplateSerializer(ValidatedModelSerializer):
     class Meta:
     class Meta:
         model = RearPortTemplate
         model = RearPortTemplate
         fields = [
         fields = [
-            'id', 'url', 'device_type', 'name', 'label', 'type', 'positions', 'description', 'created', 'last_updated',
+            'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'positions', 'description', 'created',
+            'last_updated',
         ]
         ]
 
 
 
 
@@ -380,8 +397,8 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
     class Meta:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
         fields = [
         fields = [
-            'id', 'url', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description',
-            'created', 'last_updated',
+            'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position',
+            'description', 'created', 'last_updated',
         ]
         ]
 
 
 
 
@@ -391,7 +408,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = DeviceBayTemplate
         model = DeviceBayTemplate
-        fields = ['id', 'url', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
+        fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
 
 
 
 
 #
 #
@@ -406,8 +423,8 @@ class DeviceRoleSerializer(OrganizationalModelSerializer):
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
         fields = [
         fields = [
-            'id', 'url', 'name', 'slug', 'color', 'vm_role', 'description', 'custom_fields', 'created', 'last_updated',
-            'device_count', 'virtualmachine_count',
+            'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'custom_fields', 'created',
+            'last_updated', 'device_count', 'virtualmachine_count',
         ]
         ]
 
 
 
 
@@ -420,18 +437,19 @@ class PlatformSerializer(OrganizationalModelSerializer):
     class Meta:
     class Meta:
         model = Platform
         model = Platform
         fields = [
         fields = [
-            'id', 'url', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'custom_fields',
-            'created', 'last_updated', 'device_count', 'virtualmachine_count',
+            'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description',
+            'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
         ]
         ]
 
 
 
 
-class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class DeviceSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()
     device_role = NestedDeviceRoleSerializer()
     device_role = NestedDeviceRoleSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     platform = NestedPlatformSerializer(required=False, allow_null=True)
     platform = NestedPlatformSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer()
     site = NestedSiteSerializer()
+    location = NestedLocationSerializer(required=False, allow_null=True, default=None)
     rack = NestedRackSerializer(required=False, allow_null=True)
     rack = NestedRackSerializer(required=False, allow_null=True)
     face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False)
     face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False)
     status = ChoiceField(choices=DeviceStatusChoices, required=False)
     status = ChoiceField(choices=DeviceStatusChoices, required=False)
@@ -445,10 +463,10 @@ class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     class Meta:
     class Meta:
         model = Device
         model = Device
         fields = [
         fields = [
-            'id', 'url', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial',
-            'asset_tag', 'site', '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', '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 = []
 
 
@@ -481,10 +499,10 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
 
 
     class Meta(DeviceSerializer.Meta):
     class Meta(DeviceSerializer.Meta):
         fields = [
         fields = [
-            'id', 'url', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial',
-            'asset_tag', 'site', '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', '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)
@@ -496,7 +514,11 @@ class DeviceNAPALMSerializer(serializers.Serializer):
     method = serializers.DictField()
     method = serializers.DictField()
 
 
 
 
-class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+#
+# Device components
+#
+
+class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(
     type = ChoiceField(
@@ -504,18 +526,23 @@ class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerial
         allow_blank=True,
         allow_blank=True,
         required=False
         required=False
     )
     )
+    speed = ChoiceField(
+        choices=ConsolePortSpeedChoices,
+        allow_blank=True,
+        required=False
+    )
     cable = NestedCableSerializer(read_only=True)
     cable = NestedCableSerializer(read_only=True)
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPort
         model = ConsoleServerPort
         fields = [
         fields = [
-            'id', 'url', 'device', 'name', 'label', 'type', 'description', 'mark_connected', 'cable', 'cable_peer',
-            'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
-            'custom_fields', 'created', 'last_updated', '_occupied',
+            'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected',
+            'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
+            'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
         ]
 
 
 
 
-class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(
     type = ChoiceField(
@@ -523,18 +550,23 @@ class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer,
         allow_blank=True,
         allow_blank=True,
         required=False
         required=False
     )
     )
+    speed = ChoiceField(
+        choices=ConsolePortSpeedChoices,
+        allow_blank=True,
+        required=False
+    )
     cable = NestedCableSerializer(read_only=True)
     cable = NestedCableSerializer(read_only=True)
 
 
     class Meta:
     class Meta:
         model = ConsolePort
         model = ConsolePort
         fields = [
         fields = [
-            'id', 'url', 'device', 'name', 'label', 'type', 'description', 'mark_connected', 'cable', 'cable_peer',
-            'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
-            'custom_fields', 'created', 'last_updated', '_occupied',
+            'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected',
+            'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
+            'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
         ]
 
 
 
 
-class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(
     type = ChoiceField(
@@ -557,13 +589,13 @@ class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer,
     class Meta:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
         fields = [
         fields = [
-            'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected',
-            'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
+            'id', 'url', 'display', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
+            'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
             'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
             'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
         ]
 
 
 
 
-class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(
     type = ChoiceField(
@@ -576,16 +608,17 @@ class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
     class Meta:
     class Meta:
         model = PowerPort
         model = PowerPort
         fields = [
         fields = [
-            'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
+            'id', 'url', 'display', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
             'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
             'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
             'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
             'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
         ]
 
 
 
 
-class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=InterfaceTypeChoices)
     type = ChoiceField(choices=InterfaceTypeChoices)
+    parent = NestedInterfaceSerializer(required=False, allow_null=True)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
     mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
     mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
@@ -601,10 +634,11 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = [
         fields = [
-            'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only',
-            'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_peer',
-            'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
-            'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied',
+            'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address',
+            'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable',
+            'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
+            'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
+            '_occupied',
         ]
         ]
 
 
     def validate(self, data):
     def validate(self, data):
@@ -621,7 +655,7 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
         return super().validate(data)
         return super().validate(data)
 
 
 
 
-class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, CustomFieldModelSerializer):
+class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=PortTypeChoices)
     type = ChoiceField(choices=PortTypeChoices)
@@ -630,8 +664,8 @@ class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Cus
     class Meta:
     class Meta:
         model = RearPort
         model = RearPort
         fields = [
         fields = [
-            'id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable',
-            'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
+            'id', 'url', 'display', 'device', 'name', 'label', 'type', 'positions', 'description', 'mark_connected',
+            'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
         ]
 
 
 
 
@@ -643,10 +677,10 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = RearPort
         model = RearPort
-        fields = ['id', 'url', 'name', 'label']
+        fields = ['id', 'url', 'display', 'name', 'label']
 
 
 
 
-class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, CustomFieldModelSerializer):
+class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=PortTypeChoices)
     type = ChoiceField(choices=PortTypeChoices)
@@ -656,13 +690,13 @@ class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Cu
     class Meta:
     class Meta:
         model = FrontPort
         model = FrontPort
         fields = [
         fields = [
-            'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description',
+            'id', 'url', 'display', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description',
             'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created',
             'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created',
             'last_updated', '_occupied',
             'last_updated', '_occupied',
         ]
         ]
 
 
 
 
-class DeviceBaySerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class DeviceBaySerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     installed_device = NestedDeviceSerializer(required=False, allow_null=True)
     installed_device = NestedDeviceSerializer(required=False, allow_null=True)
@@ -670,8 +704,8 @@ class DeviceBaySerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     class Meta:
     class Meta:
         model = DeviceBay
         model = DeviceBay
         fields = [
         fields = [
-            'id', 'url', 'device', 'name', 'label', 'description', 'installed_device', 'tags', 'custom_fields',
-            'created', 'last_updated',
+            'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags',
+            'custom_fields', 'created', 'last_updated',
         ]
         ]
 
 
 
 
@@ -679,7 +713,7 @@ class DeviceBaySerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
 # Inventory items
 # Inventory items
 #
 #
 
 
-class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class InventoryItemSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     # Provide a default value to satisfy UniqueTogetherValidator
     # Provide a default value to satisfy UniqueTogetherValidator
@@ -690,8 +724,8 @@ class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
         fields = [
         fields = [
-            'id', 'url', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag',
-            'discovered', 'description', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
+            'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial',
+            'asset_tag', 'discovered', 'description', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
         ]
         ]
 
 
 
 
@@ -699,7 +733,7 @@ class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer
 # Cables
 # Cables
 #
 #
 
 
-class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class CableSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
     termination_a_type = ContentTypeField(
     termination_a_type = ContentTypeField(
         queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
         queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
@@ -715,7 +749,7 @@ class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     class Meta:
     class Meta:
         model = Cable
         model = Cable
         fields = [
         fields = [
-            'id', 'url', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
+            'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
             'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
             'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
             'custom_fields',
             'custom_fields',
         ]
         ]
@@ -829,24 +863,24 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
 # Virtual chassis
 # Virtual chassis
 #
 #
 
 
-class VirtualChassisSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class VirtualChassisSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
     master = NestedDeviceSerializer(required=False)
     master = NestedDeviceSerializer(required=False)
     member_count = serializers.IntegerField(read_only=True)
     member_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = VirtualChassis
         model = VirtualChassis
-        fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count']
+        fields = ['id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count']
 
 
 
 
 #
 #
 # Power panels
 # Power panels
 #
 #
 
 
-class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+class PowerPanelSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
     site = NestedSiteSerializer()
     site = NestedSiteSerializer()
-    rack_group = NestedRackGroupSerializer(
+    location = NestedLocationSerializer(
         required=False,
         required=False,
         allow_null=True,
         allow_null=True,
         default=None
         default=None
@@ -855,15 +889,10 @@ class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
 
 
     class Meta:
     class Meta:
         model = PowerPanel
         model = PowerPanel
-        fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'custom_fields', 'powerfeed_count']
+        fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count']
 
 
 
 
-class PowerFeedSerializer(
-    TaggedObjectSerializer,
-    CableTerminationSerializer,
-    ConnectedEndpointSerializer,
-    CustomFieldModelSerializer
-):
+class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
     power_panel = NestedPowerPanelSerializer()
     power_panel = NestedPowerPanelSerializer()
     rack = NestedRackSerializer(
     rack = NestedRackSerializer(
@@ -892,8 +921,8 @@ class PowerFeedSerializer(
     class Meta:
     class Meta:
         model = PowerFeed
         model = PowerFeed
         fields = [
         fields = [
-            'id', 'url', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
-            'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type',
+            'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
+            'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type',
             'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields',
             'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields',
             'created', 'last_updated', '_occupied',
             'created', 'last_updated', '_occupied',
         ]
         ]

+ 2 - 1
netbox/dcim/api/urls.py

@@ -7,10 +7,11 @@ router.APIRootView = views.DCIMRootView
 
 
 # Sites
 # Sites
 router.register('regions', views.RegionViewSet)
 router.register('regions', views.RegionViewSet)
+router.register('site-groups', views.SiteGroupViewSet)
 router.register('sites', views.SiteViewSet)
 router.register('sites', views.SiteViewSet)
 
 
 # Racks
 # Racks
-router.register('rack-groups', views.RackGroupViewSet)
+router.register('locations', views.LocationViewSet)
 router.register('rack-roles', views.RackRoleViewSet)
 router.register('rack-roles', views.RackRoleViewSet)
 router.register('racks', views.RackViewSet)
 router.register('racks', views.RackViewSet)
 router.register('rack-reservations', views.RackReservationViewSet)
 router.register('rack-reservations', views.RackReservationViewSet)

+ 27 - 17
netbox/dcim/api/views.py

@@ -16,13 +16,7 @@ from rest_framework.viewsets import GenericViewSet, ViewSet
 
 
 from circuits.models import Circuit
 from circuits.models import Circuit
 from dcim import filters
 from dcim import filters
-from dcim.models import (
-    Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
-    Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
-    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
-    VirtualChassis,
-)
+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
 from netbox.api.views import ModelViewSet
 from netbox.api.views import ModelViewSet
@@ -111,6 +105,22 @@ class RegionViewSet(CustomFieldModelViewSet):
     filterset_class = filters.RegionFilterSet
     filterset_class = filters.RegionFilterSet
 
 
 
 
+#
+# Site groups
+#
+
+class SiteGroupViewSet(CustomFieldModelViewSet):
+    queryset = SiteGroup.objects.add_related_count(
+        SiteGroup.objects.all(),
+        Site,
+        'group',
+        'site_count',
+        cumulative=True
+    )
+    serializer_class = serializers.SiteGroupSerializer
+    filterset_class = filters.SiteGroupFilterSet
+
+
 #
 #
 # Sites
 # Sites
 #
 #
@@ -134,16 +144,16 @@ class SiteViewSet(CustomFieldModelViewSet):
 # Rack groups
 # Rack groups
 #
 #
 
 
-class RackGroupViewSet(CustomFieldModelViewSet):
-    queryset = RackGroup.objects.add_related_count(
-        RackGroup.objects.all(),
+class LocationViewSet(CustomFieldModelViewSet):
+    queryset = Location.objects.add_related_count(
+        Location.objects.all(),
         Rack,
         Rack,
-        'group',
+        'location',
         'rack_count',
         'rack_count',
         cumulative=True
         cumulative=True
     ).prefetch_related('site')
     ).prefetch_related('site')
-    serializer_class = serializers.RackGroupSerializer
-    filterset_class = filters.RackGroupFilterSet
+    serializer_class = serializers.LocationSerializer
+    filterset_class = filters.LocationFilterSet
 
 
 
 
 #
 #
@@ -164,7 +174,7 @@ class RackRoleViewSet(CustomFieldModelViewSet):
 
 
 class RackViewSet(CustomFieldModelViewSet):
 class RackViewSet(CustomFieldModelViewSet):
     queryset = Rack.objects.prefetch_related(
     queryset = Rack.objects.prefetch_related(
-        'site', 'group__site', 'role', 'tenant', 'tags'
+        'site', 'location__site', 'role', 'tenant', 'tags'
     ).annotate(
     ).annotate(
         device_count=count_related(Device, 'rack'),
         device_count=count_related(Device, 'rack'),
         powerfeed_count=count_related(PowerFeed, 'rack')
         powerfeed_count=count_related(PowerFeed, 'rack')
@@ -345,7 +355,7 @@ class PlatformViewSet(CustomFieldModelViewSet):
 
 
 class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
 class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
     queryset = Device.objects.prefetch_related(
     queryset = Device.objects.prefetch_related(
-        'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', '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 = filters.DeviceFilterSet
@@ -522,7 +532,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
 
 
 class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
 class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
     queryset = Interface.objects.prefetch_related(
     queryset = Interface.objects.prefetch_related(
-        'device', '_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 = filters.InterfaceFilterSet
@@ -619,7 +629,7 @@ class VirtualChassisViewSet(ModelViewSet):
 
 
 class PowerPanelViewSet(ModelViewSet):
 class PowerPanelViewSet(ModelViewSet):
     queryset = PowerPanel.objects.prefetch_related(
     queryset = PowerPanel.objects.prefetch_related(
-        'site', 'rack_group'
+        'site', 'location'
     ).annotate(
     ).annotate(
         powerfeed_count=count_related(PowerFeed, 'power_panel')
         powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
     )

+ 25 - 0
netbox/dcim/choices.py

@@ -217,6 +217,29 @@ class ConsolePortTypeChoices(ChoiceSet):
     )
     )
 
 
 
 
+class ConsolePortSpeedChoices(ChoiceSet):
+
+    SPEED_1200 = 1200
+    SPEED_2400 = 2400
+    SPEED_4800 = 4800
+    SPEED_9600 = 9600
+    SPEED_19200 = 19200
+    SPEED_38400 = 38400
+    SPEED_57600 = 57600
+    SPEED_115200 = 115200
+
+    CHOICES = (
+        (SPEED_1200, '1200 bps'),
+        (SPEED_2400, '2400 bps'),
+        (SPEED_4800, '4800 bps'),
+        (SPEED_9600, '9600 bps'),
+        (SPEED_19200, '19.2 kbps'),
+        (SPEED_38400, '38.4 kbps'),
+        (SPEED_57600, '57.6 kbps'),
+        (SPEED_115200, '115.2 kbps'),
+    )
+
+
 #
 #
 # PowerPorts
 # PowerPorts
 #
 #
@@ -686,6 +709,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_8GFC_SFP_PLUS = '8gfc-sfpp'
     TYPE_8GFC_SFP_PLUS = '8gfc-sfpp'
     TYPE_16GFC_SFP_PLUS = '16gfc-sfpp'
     TYPE_16GFC_SFP_PLUS = '16gfc-sfpp'
     TYPE_32GFC_SFP28 = '32gfc-sfp28'
     TYPE_32GFC_SFP28 = '32gfc-sfp28'
+    TYPE_64GFC_QSFP_PLUS = '64gfc-qsfpp'
     TYPE_128GFC_QSFP28 = '128gfc-sfp28'
     TYPE_128GFC_QSFP28 = '128gfc-sfp28'
 
 
     # InfiniBand
     # InfiniBand
@@ -801,6 +825,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'),
                 (TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'),
                 (TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'),
                 (TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'),
                 (TYPE_32GFC_SFP28, 'SFP28 (32GFC)'),
                 (TYPE_32GFC_SFP28, 'SFP28 (32GFC)'),
+                (TYPE_64GFC_QSFP_PLUS, 'QSFP+ (64GFC)'),
                 (TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'),
                 (TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'),
             )
             )
         ),
         ),

+ 155 - 36
netbox/dcim/filters.py

@@ -12,13 +12,7 @@ from utilities.filters import (
 from virtualization.models import Cluster
 from virtualization.models import Cluster
 from .choices import *
 from .choices import *
 from .constants import *
 from .constants import *
-from .models import (
-    Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
-    InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
-    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
-    VirtualChassis,
-)
+from .models import *
 
 
 
 
 __all__ = (
 __all__ = (
@@ -40,6 +34,7 @@ __all__ = (
     'InterfaceFilterSet',
     'InterfaceFilterSet',
     'InterfaceTemplateFilterSet',
     'InterfaceTemplateFilterSet',
     'InventoryItemFilterSet',
     'InventoryItemFilterSet',
+    'LocationFilterSet',
     'ManufacturerFilterSet',
     'ManufacturerFilterSet',
     'PathEndpointFilterSet',
     'PathEndpointFilterSet',
     'PlatformFilterSet',
     'PlatformFilterSet',
@@ -51,13 +46,13 @@ __all__ = (
     'PowerPortFilterSet',
     'PowerPortFilterSet',
     'PowerPortTemplateFilterSet',
     'PowerPortTemplateFilterSet',
     'RackFilterSet',
     'RackFilterSet',
-    'RackGroupFilterSet',
     'RackReservationFilterSet',
     'RackReservationFilterSet',
     'RackRoleFilterSet',
     'RackRoleFilterSet',
     'RearPortFilterSet',
     'RearPortFilterSet',
     'RearPortTemplateFilterSet',
     'RearPortTemplateFilterSet',
     'RegionFilterSet',
     'RegionFilterSet',
     'SiteFilterSet',
     'SiteFilterSet',
+    'SiteGroupFilterSet',
     'VirtualChassisFilterSet',
     'VirtualChassisFilterSet',
 )
 )
 
 
@@ -79,6 +74,23 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
+class SiteGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+    parent_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        label='Parent site group (ID)',
+    )
+    parent = django_filters.ModelMultipleChoiceFilter(
+        field_name='parent__slug',
+        queryset=SiteGroup.objects.all(),
+        to_field_name='slug',
+        label='Parent site group (slug)',
+    )
+
+    class Meta:
+        model = SiteGroup
+        fields = ['id', 'name', 'slug', 'description']
+
+
 class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
 class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
@@ -96,11 +108,22 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='region',
         lookup_expr='in',
         lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
+    group_id = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='group',
+        lookup_expr='in',
+        label='Group (ID)',
+    )
+    group = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        lookup_expr='in',
+        to_field_name='slug',
+        label='Group (slug)',
+    )
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:
@@ -131,7 +154,7 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
-class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         field_name='site__region',
         field_name='site__region',
@@ -145,6 +168,19 @@ class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
+    site_group_id = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='site__group',
+        lookup_expr='in',
+        label='Site group (ID)',
+    )
+    site_group = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='site__group',
+        lookup_expr='in',
+        to_field_name='slug',
+        label='Site group (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
@@ -156,18 +192,18 @@ class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
         label='Site (slug)',
         label='Site (slug)',
     )
     )
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=RackGroup.objects.all(),
+        queryset=Location.objects.all(),
         label='Rack group (ID)',
         label='Rack group (ID)',
     )
     )
     parent = django_filters.ModelMultipleChoiceFilter(
     parent = django_filters.ModelMultipleChoiceFilter(
         field_name='parent__slug',
         field_name='parent__slug',
-        queryset=RackGroup.objects.all(),
+        queryset=Location.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Rack group (slug)',
         label='Rack group (slug)',
     )
     )
 
 
     class Meta:
     class Meta:
-        model = RackGroup
+        model = Location
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
@@ -196,6 +232,19 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
+    site_group_id = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='site__group',
+        lookup_expr='in',
+        label='Site group (ID)',
+    )
+    site_group = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='site__group',
+        lookup_expr='in',
+        to_field_name='slug',
+        label='Site group (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
@@ -206,18 +255,18 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
     )
     )
-    group_id = TreeNodeMultipleChoiceFilter(
-        queryset=RackGroup.objects.all(),
-        field_name='group',
+    location_id = TreeNodeMultipleChoiceFilter(
+        queryset=Location.objects.all(),
+        field_name='location',
         lookup_expr='in',
         lookup_expr='in',
-        label='Rack group (ID)',
+        label='Location (ID)',
     )
     )
-    group = TreeNodeMultipleChoiceFilter(
-        queryset=RackGroup.objects.all(),
-        field_name='group',
+    location = TreeNodeMultipleChoiceFilter(
+        queryset=Location.objects.all(),
+        field_name='location',
         lookup_expr='in',
         lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
-        label='Rack group (slug)',
+        label='Location (slug)',
     )
     )
     status = django_filters.MultipleChoiceFilter(
     status = django_filters.MultipleChoiceFilter(
         choices=RackStatusChoices,
         choices=RackStatusChoices,
@@ -283,18 +332,18 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModel
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
     )
     )
-    group_id = TreeNodeMultipleChoiceFilter(
-        queryset=RackGroup.objects.all(),
-        field_name='rack__group',
+    location_id = TreeNodeMultipleChoiceFilter(
+        queryset=Location.objects.all(),
+        field_name='rack__location',
         lookup_expr='in',
         lookup_expr='in',
-        label='Rack group (ID)',
+        label='Location (ID)',
     )
     )
-    group = TreeNodeMultipleChoiceFilter(
-        queryset=RackGroup.objects.all(),
-        field_name='rack__group',
+    location = TreeNodeMultipleChoiceFilter(
+        queryset=Location.objects.all(),
+        field_name='rack__location',
         lookup_expr='in',
         lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
-        label='Rack group (slug)',
+        label='Location (slug)',
     )
     )
     user_id = django_filters.ModelMultipleChoiceFilter(
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
         queryset=User.objects.all(),
@@ -565,6 +614,19 @@ class DeviceFilterSet(
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
+    site_group_id = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='site__group',
+        lookup_expr='in',
+        label='Site group (ID)',
+    )
+    site_group = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='site__group',
+        lookup_expr='in',
+        to_field_name='slug',
+        label='Site group (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
@@ -575,11 +637,11 @@ class DeviceFilterSet(
         to_field_name='slug',
         to_field_name='slug',
         label='Site name (slug)',
         label='Site name (slug)',
     )
     )
-    rack_group_id = TreeNodeMultipleChoiceFilter(
-        queryset=RackGroup.objects.all(),
-        field_name='rack__group',
+    location_id = TreeNodeMultipleChoiceFilter(
+        queryset=Location.objects.all(),
+        field_name='location',
         lookup_expr='in',
         lookup_expr='in',
-        label='Rack group (ID)',
+        label='Location (ID)',
     )
     )
     rack_id = django_filters.ModelMultipleChoiceFilter(
     rack_id = django_filters.ModelMultipleChoiceFilter(
         field_name='rack',
         field_name='rack',
@@ -721,6 +783,19 @@ class DeviceComponentFilterSet(CustomFieldModelFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
+    site_group_id = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='device__site__group',
+        lookup_expr='in',
+        label='Site group (ID)',
+    )
+    site_group = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='device__site__group',
+        lookup_expr='in',
+        to_field_name='slug',
+        label='Site group (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         field_name='device__site',
         field_name='device__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
@@ -844,6 +919,11 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
         method='filter_kind',
         method='filter_kind',
         label='Kind of interface',
         label='Kind of interface',
     )
     )
+    parent_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='parent',
+        queryset=Interface.objects.all(),
+        label='Parent interface (ID)',
+    )
     lag_id = django_filters.ModelMultipleChoiceFilter(
     lag_id = django_filters.ModelMultipleChoiceFilter(
         field_name='lag',
         field_name='lag',
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
@@ -1038,6 +1118,19 @@ class VirtualChassisFilterSet(BaseFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
+    site_group_id = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='master__site__group',
+        lookup_expr='in',
+        label='Site group (ID)',
+    )
+    site_group = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='master__site__group',
+        lookup_expr='in',
+        to_field_name='slug',
+        label='Site group (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         field_name='master__site',
         field_name='master__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
@@ -1226,6 +1319,19 @@ class PowerPanelFilterSet(BaseFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
+    site_group_id = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='site__group',
+        lookup_expr='in',
+        label='Site group (ID)',
+    )
+    site_group = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='site__group',
+        lookup_expr='in',
+        to_field_name='slug',
+        label='Site group (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
@@ -1236,9 +1342,9 @@ class PowerPanelFilterSet(BaseFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Site name (slug)',
         label='Site name (slug)',
     )
     )
-    rack_group_id = TreeNodeMultipleChoiceFilter(
-        queryset=RackGroup.objects.all(),
-        field_name='rack_group',
+    location_id = TreeNodeMultipleChoiceFilter(
+        queryset=Location.objects.all(),
+        field_name='location',
         lookup_expr='in',
         lookup_expr='in',
         label='Rack group (ID)',
         label='Rack group (ID)',
     )
     )
@@ -1281,6 +1387,19 @@ class PowerFeedFilterSet(
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
+    site_group_id = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='power_panel__site__group',
+        lookup_expr='in',
+        label='Site group (ID)',
+    )
+    site_group = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='power_panel__site__group',
+        lookup_expr='in',
+        to_field_name='slug',
+        label='Site group (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         field_name='power_panel__site',
         field_name='power_panel__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),

Разница между файлами не показана из-за своего большого размера
+ 375 - 104
netbox/dcim/forms.py


+ 21 - 0
netbox/dcim/migrations/0125_console_port_speed.py

@@ -0,0 +1,21 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0124_mark_connected'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='consoleport',
+            name='speed',
+            field=models.PositiveSmallIntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='consoleserverport',
+            name='speed',
+            field=models.PositiveSmallIntegerField(blank=True, null=True),
+        ),
+    ]

+ 39 - 0
netbox/dcim/migrations/0126_rename_rackgroup_location.py

@@ -0,0 +1,39 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0125_console_port_speed'),
+    ]
+
+    operations = [
+        migrations.RenameModel(
+            old_name='RackGroup',
+            new_name='Location',
+        ),
+        migrations.AlterModelOptions(
+            name='rack',
+            options={'ordering': ('site', 'location', '_name', 'pk')},
+        ),
+        migrations.AlterField(
+            model_name='location',
+            name='site',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='dcim.site'),
+        ),
+        migrations.RenameField(
+            model_name='powerpanel',
+            old_name='rack_group',
+            new_name='location',
+        ),
+        migrations.RenameField(
+            model_name='rack',
+            old_name='group',
+            new_name='location',
+        ),
+        migrations.AlterUniqueTogether(
+            name='rack',
+            unique_together={('location', 'facility_id'), ('location', 'name')},
+        ),
+    ]

+ 17 - 0
netbox/dcim/migrations/0127_device_location.py

@@ -0,0 +1,17 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0126_rename_rackgroup_location'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='device',
+            name='location',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.location'),
+        ),
+    ]

+ 24 - 0
netbox/dcim/migrations/0128_device_location_populate.py

@@ -0,0 +1,24 @@
+from django.db import migrations
+from django.db.models import Subquery, OuterRef
+
+
+def populate_device_location(apps, schema_editor):
+    Device = apps.get_model('dcim', 'Device')
+    Device.objects.filter(rack__isnull=False).update(
+        location_id=Subquery(
+            Device.objects.filter(pk=OuterRef('pk')).values('rack__location_id')[:1]
+        )
+    )
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0127_device_location'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=populate_device_location
+        ),
+    ]

+ 17 - 0
netbox/dcim/migrations/0129_interface_parent.py

@@ -0,0 +1,17 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0128_device_location_populate'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interface',
+            name='parent',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_interfaces', to='dcim.interface'),
+        ),
+    ]

+ 39 - 0
netbox/dcim/migrations/0130_sitegroup.py

@@ -0,0 +1,39 @@
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+import mptt.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0129_interface_parent'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='SiteGroup',
+            fields=[
+                ('created', models.DateField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('id', models.BigAutoField(primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('slug', models.SlugField(max_length=100, unique=True)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('lft', models.PositiveIntegerField(editable=False)),
+                ('rght', models.PositiveIntegerField(editable=False)),
+                ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
+                ('level', models.PositiveIntegerField(editable=False)),
+                ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='dcim.sitegroup')),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+        migrations.AddField(
+            model_name='site',
+            name='group',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sites', to='dcim.sitegroup'),
+        ),
+    ]

+ 2 - 1
netbox/dcim/models/__init__.py

@@ -34,12 +34,13 @@ __all__ = (
     'PowerPort',
     'PowerPort',
     'PowerPortTemplate',
     'PowerPortTemplate',
     'Rack',
     'Rack',
-    'RackGroup',
+    'Location',
     'RackReservation',
     'RackReservation',
     'RackRole',
     'RackRole',
     'RearPort',
     'RearPort',
     'RearPortTemplate',
     'RearPortTemplate',
     'Region',
     'Region',
     'Site',
     'Site',
+    'SiteGroup',
     'VirtualChassis',
     'VirtualChassis',
 )
 )

+ 0 - 3
netbox/dcim/models/cables.py

@@ -6,13 +6,11 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.db import models
 from django.db import models
 from django.db.models import Sum
 from django.db.models import Sum
 from django.urls import reverse
 from django.urls import reverse
-from taggit.managers import TaggableManager
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import PathField
 from dcim.fields import PathField
 from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
 from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
-from extras.models import TaggedItem
 from extras.utils import extras_features
 from extras.utils import extras_features
 from netbox.models import BigIDModel, PrimaryModel
 from netbox.models import BigIDModel, PrimaryModel
 from utilities.fields import ColorField
 from utilities.fields import ColorField
@@ -108,7 +106,6 @@ class Cable(PrimaryModel):
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
-    tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 

+ 11 - 19
netbox/dcim/models/device_component_templates.py

@@ -4,13 +4,11 @@ from django.db import models
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
-from extras.models import ObjectChange
 from extras.utils import extras_features
 from extras.utils import extras_features
-from netbox.models import BigIDModel, ChangeLoggingMixin
+from netbox.models import ChangeLoggedModel
 from utilities.fields import NaturalOrderingField
 from utilities.fields import NaturalOrderingField
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from utilities.ordering import naturalize_interface
 from utilities.ordering import naturalize_interface
-from utilities.utils import serialize_object
 from .device_components import (
 from .device_components import (
     ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort,
     ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort,
 )
 )
@@ -28,7 +26,7 @@ __all__ = (
 )
 )
 
 
 
 
-class ComponentTemplateModel(ChangeLoggingMixin, BigIDModel):
+class ComponentTemplateModel(ChangeLoggedModel):
     device_type = models.ForeignKey(
     device_type = models.ForeignKey(
         to='dcim.DeviceType',
         to='dcim.DeviceType',
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,
@@ -75,16 +73,10 @@ class ComponentTemplateModel(ChangeLoggingMixin, BigIDModel):
         except ObjectDoesNotExist:
         except ObjectDoesNotExist:
             # The parent DeviceType has already been deleted
             # The parent DeviceType has already been deleted
             device_type = None
             device_type = None
-        return ObjectChange(
-            changed_object=self,
-            object_repr=str(self),
-            action=action,
-            related_object=device_type,
-            object_data=serialize_object(self)
-        )
+        return super().to_objectchange(action, related_object=device_type)
 
 
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('webhooks')
 class ConsolePortTemplate(ComponentTemplateModel):
 class ConsolePortTemplate(ComponentTemplateModel):
     """
     """
     A template for a ConsolePort to be created for a new Device.
     A template for a ConsolePort to be created for a new Device.
@@ -108,7 +100,7 @@ class ConsolePortTemplate(ComponentTemplateModel):
         )
         )
 
 
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('webhooks')
 class ConsoleServerPortTemplate(ComponentTemplateModel):
 class ConsoleServerPortTemplate(ComponentTemplateModel):
     """
     """
     A template for a ConsoleServerPort to be created for a new Device.
     A template for a ConsoleServerPort to be created for a new Device.
@@ -132,7 +124,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
         )
         )
 
 
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('webhooks')
 class PowerPortTemplate(ComponentTemplateModel):
 class PowerPortTemplate(ComponentTemplateModel):
     """
     """
     A template for a PowerPort to be created for a new Device.
     A template for a PowerPort to be created for a new Device.
@@ -179,7 +171,7 @@ class PowerPortTemplate(ComponentTemplateModel):
                 })
                 })
 
 
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('webhooks')
 class PowerOutletTemplate(ComponentTemplateModel):
 class PowerOutletTemplate(ComponentTemplateModel):
     """
     """
     A template for a PowerOutlet to be created for a new Device.
     A template for a PowerOutlet to be created for a new Device.
@@ -231,7 +223,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
         )
         )
 
 
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('webhooks')
 class InterfaceTemplate(ComponentTemplateModel):
 class InterfaceTemplate(ComponentTemplateModel):
     """
     """
     A template for a physical data interface on a new Device.
     A template for a physical data interface on a new Device.
@@ -266,7 +258,7 @@ class InterfaceTemplate(ComponentTemplateModel):
         )
         )
 
 
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('webhooks')
 class FrontPortTemplate(ComponentTemplateModel):
 class FrontPortTemplate(ComponentTemplateModel):
     """
     """
     Template for a pass-through port on the front of a new Device.
     Template for a pass-through port on the front of a new Device.
@@ -327,7 +319,7 @@ class FrontPortTemplate(ComponentTemplateModel):
         )
         )
 
 
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('webhooks')
 class RearPortTemplate(ComponentTemplateModel):
 class RearPortTemplate(ComponentTemplateModel):
     """
     """
     Template for a pass-through port on the rear of a new Device.
     Template for a pass-through port on the rear of a new Device.
@@ -358,7 +350,7 @@ class RearPortTemplate(ComponentTemplateModel):
         )
         )
 
 
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('webhooks')
 class DeviceBayTemplate(ComponentTemplateModel):
 class DeviceBayTemplate(ComponentTemplateModel):
     """
     """
     A template for a DeviceBay to be created for a new parent Device.
     A template for a DeviceBay to be created for a new parent Device.

+ 72 - 47
netbox/dcim/models/device_components.py

@@ -6,12 +6,10 @@ from django.db import models
 from django.db.models import Sum
 from django.db.models import Sum
 from django.urls import reverse
 from django.urls import reverse
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
-from taggit.managers import TaggableManager
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import MACAddressField
 from dcim.fields import MACAddressField
-from extras.models import ObjectChange, TaggedItem
 from extras.utils import extras_features
 from extras.utils import extras_features
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
 from utilities.fields import NaturalOrderingField
 from utilities.fields import NaturalOrderingField
@@ -19,7 +17,6 @@ from utilities.mptt import TreeManager
 from utilities.ordering import naturalize_interface
 from utilities.ordering import naturalize_interface
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from utilities.query_functions import CollateAsChar
 from utilities.query_functions import CollateAsChar
-from utilities.utils import serialize_object
 
 
 
 
 __all__ = (
 __all__ = (
@@ -82,17 +79,11 @@ class ComponentModel(PrimaryModel):
         except ObjectDoesNotExist:
         except ObjectDoesNotExist:
             # The parent Device has already been deleted
             # The parent Device has already been deleted
             device = None
             device = None
-        return ObjectChange(
-            changed_object=self,
-            object_repr=str(self),
-            action=action,
-            related_object=device,
-            object_data=serialize_object(self)
-        )
+        return super().to_objectchange(action, related_object=device)
 
 
     @property
     @property
-    def parent(self):
-        return getattr(self, 'device', None)
+    def parent_object(self):
+        return self.device
 
 
 
 
 class CableTermination(models.Model):
 class CableTermination(models.Model):
@@ -159,6 +150,10 @@ class CableTermination(models.Model):
     def _occupied(self):
     def _occupied(self):
         return bool(self.mark_connected or self.cable_id)
         return bool(self.mark_connected or self.cable_id)
 
 
+    @property
+    def parent_object(self):
+        raise NotImplementedError("CableTermination models must implement parent_object()")
+
 
 
 class PathEndpoint(models.Model):
 class PathEndpoint(models.Model):
     """
     """
@@ -213,8 +208,8 @@ class PathEndpoint(models.Model):
 # Console ports
 # Console ports
 #
 #
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
-class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
     """
     """
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
     """
     """
@@ -224,9 +219,14 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
         blank=True,
         blank=True,
         help_text='Physical port type'
         help_text='Physical port type'
     )
     )
-    tags = TaggableManager(through=TaggedItem)
+    speed = models.PositiveSmallIntegerField(
+        choices=ConsolePortSpeedChoices,
+        blank=True,
+        null=True,
+        help_text='Port speed in bits per second'
+    )
 
 
-    csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'description']
+    csv_headers = ['device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description']
 
 
     class Meta:
     class Meta:
         ordering = ('device', '_name')
         ordering = ('device', '_name')
@@ -241,6 +241,7 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
             self.name,
             self.name,
             self.label,
             self.label,
             self.type,
             self.type,
+            self.speed,
             self.mark_connected,
             self.mark_connected,
             self.description,
             self.description,
         )
         )
@@ -250,8 +251,8 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
 # Console server ports
 # Console server ports
 #
 #
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
-class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
     """
     """
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
     """
     """
@@ -261,9 +262,14 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
         blank=True,
         blank=True,
         help_text='Physical port type'
         help_text='Physical port type'
     )
     )
-    tags = TaggableManager(through=TaggedItem)
+    speed = models.PositiveSmallIntegerField(
+        choices=ConsolePortSpeedChoices,
+        blank=True,
+        null=True,
+        help_text='Port speed in bits per second'
+    )
 
 
-    csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'description']
+    csv_headers = ['device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description']
 
 
     class Meta:
     class Meta:
         ordering = ('device', '_name')
         ordering = ('device', '_name')
@@ -278,6 +284,7 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
             self.name,
             self.name,
             self.label,
             self.label,
             self.type,
             self.type,
+            self.speed,
             self.mark_connected,
             self.mark_connected,
             self.description,
             self.description,
         )
         )
@@ -287,8 +294,8 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
 # Power ports
 # Power ports
 #
 #
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
-class PowerPort(CableTermination, PathEndpoint, ComponentModel):
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class PowerPort(ComponentModel, CableTermination, PathEndpoint):
     """
     """
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
     """
     """
@@ -310,7 +317,6 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
         validators=[MinValueValidator(1)],
         validators=[MinValueValidator(1)],
         help_text="Allocated power draw (watts)"
         help_text="Allocated power draw (watts)"
     )
     )
-    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description',
         'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description',
@@ -399,8 +405,8 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
 # Power outlets
 # Power outlets
 #
 #
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
-class PowerOutlet(CableTermination, PathEndpoint, ComponentModel):
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class PowerOutlet(ComponentModel, CableTermination, PathEndpoint):
     """
     """
     A physical power outlet (output) within a Device which provides power to a PowerPort.
     A physical power outlet (output) within a Device which provides power to a PowerPort.
     """
     """
@@ -423,7 +429,6 @@ class PowerOutlet(CableTermination, PathEndpoint, ComponentModel):
         blank=True,
         blank=True,
         help_text="Phase (for three-phase feeds)"
         help_text="Phase (for three-phase feeds)"
     )
     )
-    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description']
     csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description']
 
 
@@ -500,8 +505,8 @@ class BaseInterface(models.Model):
         return super().save(*args, **kwargs)
         return super().save(*args, **kwargs)
 
 
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
-class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
     """
     """
     A network interface within a Device. A physical Interface can connect to exactly one other Interface.
     A network interface within a Device. A physical Interface can connect to exactly one other Interface.
     """
     """
@@ -512,6 +517,14 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
         max_length=100,
         max_length=100,
         blank=True
         blank=True
     )
     )
+    parent = models.ForeignKey(
+        to='self',
+        on_delete=models.SET_NULL,
+        related_name='child_interfaces',
+        null=True,
+        blank=True,
+        verbose_name='Parent interface'
+    )
     lag = models.ForeignKey(
     lag = models.ForeignKey(
         to='self',
         to='self',
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,
@@ -549,11 +562,10 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
         object_id_field='assigned_object_id',
         object_id_field='assigned_object_id',
         related_query_name='interface'
         related_query_name='interface'
     )
     )
-    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
-        'device', 'name', 'label', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu', 'mgmt_only',
-        'description', 'mode',
+        'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu',
+        'mgmt_only', 'description', 'mode',
     ]
     ]
 
 
     class Meta:
     class Meta:
@@ -568,6 +580,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
             self.device.identifier if self.device else None,
             self.device.identifier if self.device else None,
             self.name,
             self.name,
             self.label,
             self.label,
+            self.parent.name if self.parent else None,
             self.lag.name if self.lag else None,
             self.lag.name if self.lag else None,
             self.get_type_display(),
             self.get_type_display(),
             self.enabled,
             self.enabled,
@@ -591,6 +604,27 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
                         "Disconnect the interface or choose a suitable type."
                         "Disconnect the interface or choose a suitable type."
             })
             })
 
 
+        # An interface's parent must belong to the same device or virtual chassis
+        if self.parent and self.parent.device != self.device:
+            if self.device.virtual_chassis is None:
+                raise ValidationError({
+                    'parent': f"The selected parent interface ({self.parent}) belongs to a different device "
+                              f"({self.parent.device})."
+                })
+            elif self.parent.device.virtual_chassis != self.parent.virtual_chassis:
+                raise ValidationError({
+                    'parent': f"The selected parent interface ({self.parent}) belongs to {self.parent.device}, which "
+                              f"is not part of virtual chassis {self.device.virtual_chassis}."
+                })
+
+        # A physical interface cannot have a parent interface
+        if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
+            raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."})
+
+        # A virtual interface cannot be a parent interface
+        if self.parent is not None and self.parent.type == InterfaceTypeChoices.TYPE_VIRTUAL:
+            raise ValidationError({'parent': "Virtual interfaces may not be parents of other interfaces."})
+
         # An interface's LAG must belong to the same device or virtual chassis
         # An interface's LAG must belong to the same device or virtual chassis
         if self.lag and self.lag.device != self.device:
         if self.lag and self.lag.device != self.device:
             if self.device.virtual_chassis is None:
             if self.device.virtual_chassis is None:
@@ -612,16 +646,12 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
             raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
             raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
 
 
         # Validate untagged VLAN
         # Validate untagged VLAN
-        if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
+        if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
             raise ValidationError({
             raise ValidationError({
                 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
                 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
                                  "device, or it must be global".format(self.untagged_vlan)
                                  "device, or it must be global".format(self.untagged_vlan)
             })
             })
 
 
-    @property
-    def parent(self):
-        return self.device
-
     @property
     @property
     def is_connectable(self):
     def is_connectable(self):
         return self.type not in NONCONNECTABLE_IFACE_TYPES
         return self.type not in NONCONNECTABLE_IFACE_TYPES
@@ -647,8 +677,8 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
 # Pass-through ports
 # Pass-through ports
 #
 #
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
-class FrontPort(CableTermination, ComponentModel):
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class FrontPort(ComponentModel, CableTermination):
     """
     """
     A pass-through port on the front of a Device.
     A pass-through port on the front of a Device.
     """
     """
@@ -668,7 +698,6 @@ class FrontPort(CableTermination, ComponentModel):
             MaxValueValidator(REARPORT_POSITIONS_MAX)
             MaxValueValidator(REARPORT_POSITIONS_MAX)
         ]
         ]
     )
     )
-    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'device', 'name', 'label', 'type', 'mark_connected', 'rear_port', 'rear_port_position', 'description',
         'device', 'name', 'label', 'type', 'mark_connected', 'rear_port', 'rear_port_position', 'description',
@@ -713,8 +742,8 @@ class FrontPort(CableTermination, ComponentModel):
             })
             })
 
 
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
-class RearPort(CableTermination, ComponentModel):
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class RearPort(ComponentModel, CableTermination):
     """
     """
     A pass-through port on the rear of a Device.
     A pass-through port on the rear of a Device.
     """
     """
@@ -729,7 +758,6 @@ class RearPort(CableTermination, ComponentModel):
             MaxValueValidator(REARPORT_POSITIONS_MAX)
             MaxValueValidator(REARPORT_POSITIONS_MAX)
         ]
         ]
     )
     )
-    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'positions', 'description']
     csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'positions', 'description']
 
 
@@ -767,7 +795,7 @@ class RearPort(CableTermination, ComponentModel):
 # Device bays
 # Device bays
 #
 #
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class DeviceBay(ComponentModel):
 class DeviceBay(ComponentModel):
     """
     """
     An empty space within a Device which can house a child device
     An empty space within a Device which can house a child device
@@ -779,7 +807,6 @@ class DeviceBay(ComponentModel):
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
-    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'name', 'label', 'installed_device', 'description']
     csv_headers = ['device', 'name', 'label', 'installed_device', 'description']
 
 
@@ -827,7 +854,7 @@ class DeviceBay(ComponentModel):
 # Inventory items
 # Inventory items
 #
 #
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class InventoryItem(MPTTModel, ComponentModel):
 class InventoryItem(MPTTModel, ComponentModel):
     """
     """
     An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
     An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
@@ -872,8 +899,6 @@ class InventoryItem(MPTTModel, ComponentModel):
         help_text='This item was automatically discovered'
         help_text='This item was automatically discovered'
     )
     )
 
 
-    tags = TaggableManager(through=TaggedItem)
-
     objects = TreeManager()
     objects = TreeManager()
 
 
     csv_headers = [
     csv_headers = [

+ 18 - 9
netbox/dcim/models/devices.py

@@ -9,11 +9,10 @@ from django.db import models
 from django.db.models import F, ProtectedError
 from django.db.models import F, ProtectedError
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
-from taggit.managers import TaggableManager
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
-from extras.models import ConfigContextModel, TaggedItem
+from extras.models import ConfigContextModel
 from extras.querysets import ConfigContextModelQuerySet
 from extras.querysets import ConfigContextModelQuerySet
 from extras.utils import extras_features
 from extras.utils import extras_features
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models import OrganizationalModel, PrimaryModel
@@ -136,7 +135,6 @@ class DeviceType(PrimaryModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
-    tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
@@ -517,6 +515,13 @@ class Device(PrimaryModel, ConfigContextModel):
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='devices'
         related_name='devices'
     )
     )
+    location = models.ForeignKey(
+        to='dcim.Location',
+        on_delete=models.PROTECT,
+        related_name='devices',
+        blank=True,
+        null=True
+    )
     rack = models.ForeignKey(
     rack = models.ForeignKey(
         to='dcim.Rack',
         to='dcim.Rack',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
@@ -594,16 +599,15 @@ class Device(PrimaryModel, ConfigContextModel):
         object_id_field='assigned_object_id',
         object_id_field='assigned_object_id',
         related_query_name='device'
         related_query_name='device'
     )
     )
-    tags = TaggableManager(through=TaggedItem)
 
 
     objects = ConfigContextModelQuerySet.as_manager()
     objects = ConfigContextModelQuerySet.as_manager()
 
 
     csv_headers = [
     csv_headers = [
         'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
         'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
-        'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
+        'site', 'location', 'rack_name', 'position', 'face', 'comments',
     ]
     ]
     clone_fields = [
     clone_fields = [
-        'device_type', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster',
+        'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'status', 'cluster',
     ]
     ]
 
 
     class Meta:
     class Meta:
@@ -640,11 +644,17 @@ class Device(PrimaryModel, ConfigContextModel):
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
-        # Validate site/rack combination
+        # Validate site/location/rack combination
         if self.rack and self.site != self.rack.site:
         if self.rack and self.site != self.rack.site:
             raise ValidationError({
             raise ValidationError({
                 'rack': f"Rack {self.rack} does not belong to site {self.site}.",
                 'rack': f"Rack {self.rack} does not belong to site {self.site}.",
             })
             })
+        if self.rack and self.location and self.rack.location != self.location:
+            raise ValidationError({
+                'rack': f"Rack {self.rack} does not belong to location {self.location}.",
+            })
+        elif self.rack:
+            self.location = self.rack.location
 
 
         if self.rack is None:
         if self.rack is None:
             if self.face:
             if self.face:
@@ -799,7 +809,7 @@ class Device(PrimaryModel, ConfigContextModel):
             self.asset_tag,
             self.asset_tag,
             self.get_status_display(),
             self.get_status_display(),
             self.site.name,
             self.site.name,
-            self.rack.group.name if self.rack and self.rack.group else None,
+            self.rack.location.name if self.rack and self.rack.location else None,
             self.rack.name if self.rack else None,
             self.rack.name if self.rack else None,
             self.position,
             self.position,
             self.get_face_display(),
             self.get_face_display(),
@@ -903,7 +913,6 @@ class VirtualChassis(PrimaryModel):
         max_length=30,
         max_length=30,
         blank=True
         blank=True
     )
     )
-    tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 

+ 10 - 14
netbox/dcim/models/power.py

@@ -2,11 +2,9 @@ from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
-from taggit.managers import TaggableManager
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
-from extras.models import TaggedItem
 from extras.utils import extras_features
 from extras.utils import extras_features
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
@@ -32,8 +30,8 @@ class PowerPanel(PrimaryModel):
         to='Site',
         to='Site',
         on_delete=models.PROTECT
         on_delete=models.PROTECT
     )
     )
-    rack_group = models.ForeignKey(
-        to='RackGroup',
+    location = models.ForeignKey(
+        to='dcim.Location',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         blank=True,
         blank=True,
         null=True
         null=True
@@ -41,11 +39,10 @@ class PowerPanel(PrimaryModel):
     name = models.CharField(
     name = models.CharField(
         max_length=100
         max_length=100
     )
     )
-    tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
-    csv_headers = ['site', 'rack_group', 'name']
+    csv_headers = ['site', 'location', 'name']
 
 
     class Meta:
     class Meta:
         ordering = ['site', 'name']
         ordering = ['site', 'name']
@@ -60,17 +57,17 @@ class PowerPanel(PrimaryModel):
     def to_csv(self):
     def to_csv(self):
         return (
         return (
             self.site.name,
             self.site.name,
-            self.rack_group.name if self.rack_group else None,
+            self.location.name if self.location else None,
             self.name,
             self.name,
         )
         )
 
 
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
-        # RackGroup must belong to assigned Site
-        if self.rack_group and self.rack_group.site != self.site:
+        # Location must belong to assigned Site
+        if self.location and self.location.site != self.site:
             raise ValidationError("Rack group {} ({}) is in a different site than {}".format(
             raise ValidationError("Rack group {} ({}) is in a different site than {}".format(
-                self.rack_group, self.rack_group.site, self.site
+                self.location, self.location.site, self.site
             ))
             ))
 
 
 
 
@@ -133,12 +130,11 @@ class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
-    tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
     csv_headers = [
     csv_headers = [
-        'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
+        'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
         'voltage', 'amperage', 'max_utilization', 'comments',
         'voltage', 'amperage', 'max_utilization', 'comments',
     ]
     ]
     clone_fields = [
     clone_fields = [
@@ -160,7 +156,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
         return (
         return (
             self.power_panel.site.name,
             self.power_panel.site.name,
             self.power_panel.name,
             self.power_panel.name,
-            self.rack.group.name if self.rack and self.rack.group else None,
+            self.rack.location.name if self.rack and self.rack.location else None,
             self.rack.name if self.rack else None,
             self.rack.name if self.rack else None,
             self.name,
             self.name,
             self.get_status_display(),
             self.get_status_display(),
@@ -201,7 +197,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
     @property
     @property
-    def parent(self):
+    def parent_object(self):
         return self.power_panel
         return self.power_panel
 
 
     def get_type_class(self):
     def get_type_class(self):

+ 22 - 90
netbox/dcim/models/racks.py

@@ -10,27 +10,22 @@ from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.db.models import Count, Sum
 from django.db.models import Count, Sum
 from django.urls import reverse
 from django.urls import reverse
-from mptt.models import TreeForeignKey
-from taggit.managers import TaggableManager
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.elevations import RackElevationSVG
 from dcim.elevations import RackElevationSVG
-from extras.models import ObjectChange, TaggedItem
 from extras.utils import extras_features
 from extras.utils import extras_features
-from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
+from netbox.models import OrganizationalModel, PrimaryModel
 from utilities.choices import ColorChoices
 from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
-from utilities.mptt import TreeManager
-from utilities.utils import array_to_string, serialize_object
+from utilities.utils import array_to_string
 from .device_components import PowerOutlet, PowerPort
 from .device_components import PowerOutlet, PowerPort
 from .devices import Device
 from .devices import Device
 from .power import PowerFeed
 from .power import PowerFeed
 
 
 __all__ = (
 __all__ = (
     'Rack',
     'Rack',
-    'RackGroup',
     'RackReservation',
     'RackReservation',
     'RackRole',
     'RackRole',
 )
 )
@@ -40,66 +35,6 @@ __all__ = (
 # Racks
 # Racks
 #
 #
 
 
-@extras_features('custom_fields', 'export_templates', 'webhooks')
-class RackGroup(NestedGroupModel):
-    """
-    Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
-    example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that
-    campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor.
-    """
-    name = models.CharField(
-        max_length=100
-    )
-    slug = models.SlugField(
-        max_length=100
-    )
-    site = models.ForeignKey(
-        to='dcim.Site',
-        on_delete=models.CASCADE,
-        related_name='rack_groups'
-    )
-    parent = TreeForeignKey(
-        to='self',
-        on_delete=models.CASCADE,
-        related_name='children',
-        blank=True,
-        null=True,
-        db_index=True
-    )
-    description = models.CharField(
-        max_length=200,
-        blank=True
-    )
-
-    csv_headers = ['site', 'parent', 'name', 'slug', 'description']
-
-    class Meta:
-        ordering = ['site', 'name']
-        unique_together = [
-            ['site', 'name'],
-            ['site', 'slug'],
-        ]
-
-    def get_absolute_url(self):
-        return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
-
-    def to_csv(self):
-        return (
-            self.site,
-            self.parent.name if self.parent else '',
-            self.name,
-            self.slug,
-            self.description,
-        )
-
-    def clean(self):
-        super().clean()
-
-        # Parent RackGroup (if any) must belong to the same Site
-        if self.parent and self.parent.site != self.site:
-            raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})")
-
-
 @extras_features('custom_fields', 'export_templates', 'webhooks')
 @extras_features('custom_fields', 'export_templates', 'webhooks')
 class RackRole(OrganizationalModel):
 class RackRole(OrganizationalModel):
     """
     """
@@ -147,7 +82,7 @@ class RackRole(OrganizationalModel):
 class Rack(PrimaryModel):
 class Rack(PrimaryModel):
     """
     """
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
-    Each Rack is assigned to a Site and (optionally) a RackGroup.
+    Each Rack is assigned to a Site and (optionally) a Location.
     """
     """
     name = models.CharField(
     name = models.CharField(
         max_length=100
         max_length=100
@@ -169,13 +104,12 @@ class Rack(PrimaryModel):
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='racks'
         related_name='racks'
     )
     )
-    group = models.ForeignKey(
-        to='dcim.RackGroup',
+    location = models.ForeignKey(
+        to='dcim.Location',
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,
         related_name='racks',
         related_name='racks',
         blank=True,
         blank=True,
-        null=True,
-        help_text='Assigned group'
+        null=True
     )
     )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         to='tenancy.Tenant',
@@ -254,25 +188,24 @@ class Rack(PrimaryModel):
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )
-    tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
     csv_headers = [
     csv_headers = [
-        'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
+        'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
         'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
         'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
     ]
     ]
     clone_fields = [
     clone_fields = [
-        'site', 'group', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
+        'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
         'outer_depth', 'outer_unit',
         'outer_depth', 'outer_unit',
     ]
     ]
 
 
     class Meta:
     class Meta:
-        ordering = ('site', 'group', '_name', 'pk')  # (site, group, name) may be non-unique
+        ordering = ('site', 'location', '_name', 'pk')  # (site, location, name) may be non-unique
         unique_together = (
         unique_together = (
-            # Name and facility_id must be unique *only* within a RackGroup
-            ('group', 'name'),
-            ('group', 'facility_id'),
+            # Name and facility_id must be unique *only* within a Location
+            ('location', 'name'),
+            ('location', 'facility_id'),
         )
         )
 
 
     def __str__(self):
     def __str__(self):
@@ -284,9 +217,9 @@ class Rack(PrimaryModel):
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
-        # Validate group/site assignment
-        if self.site and self.group and self.group.site != self.site:
-            raise ValidationError(f"Assigned rack group must belong to parent site ({self.site}).")
+        # Validate location/site assignment
+        if self.site and self.location and self.location.site != self.site:
+            raise ValidationError(f"Assigned location must belong to parent site ({self.site}).")
 
 
         # Validate outer dimensions and unit
         # Validate outer dimensions and unit
         if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
         if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
@@ -309,17 +242,17 @@ class Rack(PrimaryModel):
                             min_height
                             min_height
                         )
                         )
                     })
                     })
-            # Validate that Rack was assigned a group of its same site, if applicable
-            if self.group:
-                if self.group.site != self.site:
+            # Validate that Rack was assigned a Location of its same site, if applicable
+            if self.location:
+                if self.location.site != self.site:
                     raise ValidationError({
                     raise ValidationError({
-                        'group': "Rack group must be from the same site, {}.".format(self.site)
+                        'location': f"Location must be from the same site, {self.site}."
                     })
                     })
 
 
     def to_csv(self):
     def to_csv(self):
         return (
         return (
             self.site.name,
             self.site.name,
-            self.group.name if self.group else None,
+            self.location.name if self.location else None,
             self.name,
             self.name,
             self.facility_id,
             self.facility_id,
             self.tenant.name if self.tenant else None,
             self.tenant.name if self.tenant else None,
@@ -561,11 +494,10 @@ class RackReservation(PrimaryModel):
     description = models.CharField(
     description = models.CharField(
         max_length=200
         max_length=200
     )
     )
-    tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
-    csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
+    csv_headers = ['site', 'location', 'rack', 'units', 'tenant', 'user', 'description']
 
 
     class Meta:
     class Meta:
         ordering = ['created', 'pk']
         ordering = ['created', 'pk']
@@ -606,7 +538,7 @@ class RackReservation(PrimaryModel):
     def to_csv(self):
     def to_csv(self):
         return (
         return (
             self.rack.site.name,
             self.rack.site.name,
-            self.rack.group if self.rack.group else None,
+            self.rack.location if self.rack.location else None,
             self.rack.name,
             self.rack.name,
             ','.join([str(u) for u in self.units]),
             ','.join([str(u) for u in self.units]),
             self.tenant.name if self.tenant else None,
             self.tenant.name if self.tenant else None,

+ 133 - 9
netbox/dcim/models/sites.py

@@ -1,24 +1,23 @@
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.fields import GenericRelation
+from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from mptt.models import TreeForeignKey
 from mptt.models import TreeForeignKey
-from taggit.managers import TaggableManager
 from timezone_field import TimeZoneField
 from timezone_field import TimeZoneField
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import ASNField
 from dcim.fields import ASNField
-from extras.models import ObjectChange, TaggedItem
 from extras.utils import extras_features
 from extras.utils import extras_features
 from netbox.models import NestedGroupModel, PrimaryModel
 from netbox.models import NestedGroupModel, PrimaryModel
 from utilities.fields import NaturalOrderingField
 from utilities.fields import NaturalOrderingField
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
-from utilities.mptt import TreeManager
-from utilities.utils import serialize_object
 
 
 __all__ = (
 __all__ = (
+    'Location',
     'Region',
     'Region',
     'Site',
     'Site',
+    'SiteGroup',
 )
 )
 
 
 
 
@@ -29,7 +28,9 @@ __all__ = (
 @extras_features('custom_fields', 'export_templates', 'webhooks')
 @extras_features('custom_fields', 'export_templates', 'webhooks')
 class Region(NestedGroupModel):
 class Region(NestedGroupModel):
     """
     """
-    Sites can be grouped within geographic Regions.
+    A region represents a geographic collection of sites. For example, you might create regions representing countries,
+    states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are
+    also considered to be members of its parent and ancestor region(s).
     """
     """
     parent = TreeForeignKey(
     parent = TreeForeignKey(
         to='self',
         to='self',
@@ -72,6 +73,58 @@ class Region(NestedGroupModel):
         ).count()
         ).count()
 
 
 
 
+#
+# Site groups
+#
+
+@extras_features('custom_fields', 'export_templates', 'webhooks')
+class SiteGroup(NestedGroupModel):
+    """
+    A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
+    within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be
+    nested recursively to form a hierarchy.
+    """
+    parent = TreeForeignKey(
+        to='self',
+        on_delete=models.CASCADE,
+        related_name='children',
+        blank=True,
+        null=True,
+        db_index=True
+    )
+    name = models.CharField(
+        max_length=100,
+        unique=True
+    )
+    slug = models.SlugField(
+        max_length=100,
+        unique=True
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True
+    )
+
+    csv_headers = ['name', 'slug', 'parent', 'description']
+
+    def get_absolute_url(self):
+        return "{}?group={}".format(reverse('dcim:site_list'), self.slug)
+
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.parent.name if self.parent else None,
+            self.description,
+        )
+
+    def get_site_count(self):
+        return Site.objects.filter(
+            Q(group=self) |
+            Q(group__in=self.get_descendants())
+        ).count()
+
+
 #
 #
 # Sites
 # Sites
 #
 #
@@ -107,6 +160,13 @@ class Site(PrimaryModel):
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
+    group = models.ForeignKey(
+        to='dcim.SiteGroup',
+        on_delete=models.SET_NULL,
+        related_name='sites',
+        blank=True,
+        null=True
+    )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         to='tenancy.Tenant',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
@@ -172,16 +232,16 @@ class Site(PrimaryModel):
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )
-    tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
     csv_headers = [
     csv_headers = [
-        'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
-        'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
+        'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
+        'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
+        'contact_email', 'comments',
     ]
     ]
     clone_fields = [
     clone_fields = [
-        'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
+        'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
         'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
         'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
     ]
     ]
 
 
@@ -200,6 +260,7 @@ class Site(PrimaryModel):
             self.slug,
             self.slug,
             self.get_status_display(),
             self.get_status_display(),
             self.region.name if self.region else None,
             self.region.name if self.region else None,
+            self.group.name if self.group else None,
             self.tenant.name if self.tenant else None,
             self.tenant.name if self.tenant else None,
             self.facility,
             self.facility,
             self.asn,
             self.asn,
@@ -217,3 +278,66 @@ class Site(PrimaryModel):
 
 
     def get_status_class(self):
     def get_status_class(self):
         return SiteStatusChoices.CSS_CLASSES.get(self.status)
         return SiteStatusChoices.CSS_CLASSES.get(self.status)
+
+
+#
+# Locations
+#
+
+@extras_features('custom_fields', 'export_templates', 'webhooks')
+class Location(NestedGroupModel):
+    """
+    A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a
+    site, or a room within a building, for example.
+    """
+    name = models.CharField(
+        max_length=100
+    )
+    slug = models.SlugField(
+        max_length=100
+    )
+    site = models.ForeignKey(
+        to='dcim.Site',
+        on_delete=models.CASCADE,
+        related_name='locations'
+    )
+    parent = TreeForeignKey(
+        to='self',
+        on_delete=models.CASCADE,
+        related_name='children',
+        blank=True,
+        null=True,
+        db_index=True
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True
+    )
+
+    csv_headers = ['site', 'parent', 'name', 'slug', 'description']
+
+    class Meta:
+        ordering = ['site', 'name']
+        unique_together = [
+            ['site', 'name'],
+            ['site', 'slug'],
+        ]
+
+    def get_absolute_url(self):
+        return "{}?location_id={}".format(reverse('dcim:rack_list'), self.pk)
+
+    def to_csv(self):
+        return (
+            self.site,
+            self.parent.name if self.parent else '',
+            self.name,
+            self.slug,
+            self.description,
+        )
+
+    def clean(self):
+        super().clean()
+
+        # Parent Location (if any) must belong to the same Site
+        if self.parent and self.parent.site != self.site:
+            raise ValidationError(f"Parent location ({self.parent}) must belong to the same site ({self.site})")

+ 12 - 18
netbox/dcim/signals.py

@@ -7,7 +7,7 @@ from django.db import transaction
 from django.dispatch import receiver
 from django.dispatch import receiver
 
 
 from .choices import CableStatusChoices
 from .choices import CableStatusChoices
-from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, RackGroup, VirtualChassis
+from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
 
 
 
 
 def create_cablepath(node):
 def create_cablepath(node):
@@ -37,36 +37,30 @@ def rebuild_paths(obj):
 
 
 
 
 #
 #
-# Site/rack/device assignment
+# Location/rack/device assignment
 #
 #
 
 
-@receiver(post_save, sender=RackGroup)
-def handle_rackgroup_site_change(instance, created, **kwargs):
+@receiver(post_save, sender=Location)
+def handle_location_site_change(instance, created, **kwargs):
     """
     """
-    Update child RackGroups and Racks if Site assignment has changed. We intentionally recurse through each child
+    Update child objects if Site assignment has changed. We intentionally recurse through each child
     object instead of calling update() on the QuerySet to ensure the proper change records get created for each.
     object instead of calling update() on the QuerySet to ensure the proper change records get created for each.
     """
     """
     if not created:
     if not created:
-        for rackgroup in instance.get_children():
-            rackgroup.site = instance.site
-            rackgroup.save()
-        for rack in Rack.objects.filter(group=instance).exclude(site=instance.site):
-            rack.site = instance.site
-            rack.save()
-        for powerpanel in PowerPanel.objects.filter(rack_group=instance).exclude(site=instance.site):
-            powerpanel.site = instance.site
-            powerpanel.save()
+        instance.get_descendants().update(site=instance.site)
+        locations = instance.get_descendants(include_self=True).values_list('pk', flat=True)
+        Rack.objects.filter(location__in=locations).update(site=instance.site)
+        Device.objects.filter(location__in=locations).update(site=instance.site)
+        PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
 
 
 
 
 @receiver(post_save, sender=Rack)
 @receiver(post_save, sender=Rack)
 def handle_rack_site_change(instance, created, **kwargs):
 def handle_rack_site_change(instance, created, **kwargs):
     """
     """
-    Update child Devices if Site assignment has changed.
+    Update child Devices if Site or Location assignment has changed.
     """
     """
     if not created:
     if not created:
-        for device in Device.objects.filter(rack=instance).exclude(site=instance.site):
-            device.site = instance.site
-            device.save()
+        Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
 
 
 
 
 #
 #

+ 41 - 30
netbox/dcim/tables/devices.py

@@ -1,11 +1,12 @@
 import django_tables2 as tables
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
+from django.conf import settings
 
 
 from dcim.models import (
 from dcim.models import (
     ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform,
     ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform,
     PowerOutlet, PowerPort, RearPort, VirtualChassis,
     PowerOutlet, PowerPort, RearPort, VirtualChassis,
 )
 )
-from tenancy.tables import COL_TENANT
+from tenancy.tables import TenantColumn
 from utilities.tables import (
 from utilities.tables import (
     BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
     BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
     TagColumn, ToggleColumn,
     TagColumn, ToggleColumn,
@@ -109,12 +110,13 @@ class DeviceTable(BaseTable):
         template_code=DEVICE_LINK
         template_code=DEVICE_LINK
     )
     )
     status = ChoiceFieldColumn()
     status = ChoiceFieldColumn()
-    tenant = tables.TemplateColumn(
-        template_code=COL_TENANT
-    )
+    tenant = TenantColumn()
     site = tables.Column(
     site = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    location = tables.Column(
+        linkify=True
+    )
     rack = tables.Column(
     rack = tables.Column(
         linkify=True
         linkify=True
     )
     )
@@ -127,11 +129,18 @@ class DeviceTable(BaseTable):
         verbose_name='Type',
         verbose_name='Type',
         text=lambda record: record.device_type.display_name
         text=lambda record: record.device_type.display_name
     )
     )
-    primary_ip = tables.Column(
-        linkify=True,
-        order_by=('primary_ip6', 'primary_ip4'),
-        verbose_name='IP Address'
-    )
+    if settings.PREFER_IPV4:
+        primary_ip = tables.Column(
+            linkify=True,
+            order_by=('primary_ip4', 'primary_ip6'),
+            verbose_name='IP Address'
+        )
+    else:
+        primary_ip = tables.Column(
+            linkify=True,
+            order_by=('primary_ip6', 'primary_ip4'),
+            verbose_name='IP Address'
+        )
     primary_ip4 = tables.Column(
     primary_ip4 = tables.Column(
         linkify=True,
         linkify=True,
         verbose_name='IPv4 Address'
         verbose_name='IPv4 Address'
@@ -162,11 +171,11 @@ class DeviceTable(BaseTable):
         model = Device
         model = Device
         fields = (
         fields = (
             'pk', 'name', 'status', 'tenant', 'device_role', 'device_type', 'platform', 'serial', 'asset_tag', 'site',
             'pk', 'name', 'status', 'tenant', 'device_role', 'device_type', 'platform', 'serial', 'asset_tag', 'site',
-            'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis',
-            'vc_position', 'vc_priority', 'tags',
+            'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster',
+            'virtual_chassis', 'vc_position', 'vc_priority', 'tags',
         )
         )
         default_columns = (
         default_columns = (
-            'pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip',
+            'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'device_type', 'primary_ip',
         )
         )
 
 
 
 
@@ -175,9 +184,7 @@ class DeviceImportTable(BaseTable):
         template_code=DEVICE_LINK
         template_code=DEVICE_LINK
     )
     )
     status = ChoiceFieldColumn()
     status = ChoiceFieldColumn()
-    tenant = tables.TemplateColumn(
-        template_code=COL_TENANT
-    )
+    tenant = TenantColumn()
     site = tables.Column(
     site = tables.Column(
         linkify=True
         linkify=True
     )
     )
@@ -249,10 +256,10 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ConsolePort
         model = ConsolePort
         fields = (
         fields = (
-            'pk', 'device', 'name', 'label', 'type', 'description', 'mark_connected', 'cable', 'cable_peer',
+            'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer',
             'connection', 'tags',
             'connection', 'tags',
         )
         )
-        default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
+        default_columns = ('pk', 'device', 'name', 'label', 'type', 'speed', 'description')
 
 
 
 
 class DeviceConsolePortTable(ConsolePortTable):
 class DeviceConsolePortTable(ConsolePortTable):
@@ -269,10 +276,10 @@ class DeviceConsolePortTable(ConsolePortTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ConsolePort
         model = ConsolePort
         fields = (
         fields = (
-            'pk', 'name', 'label', 'type', 'description', 'mark_connected', 'cable', 'cable_peer', 'connection',
-            'tags', 'actions'
+            'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer',
+            'connection', 'tags', 'actions'
         )
         )
-        default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions')
+        default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
         row_attrs = {
         row_attrs = {
             'class': lambda record: record.cable.get_status_class() if record.cable else ''
             'class': lambda record: record.cable.get_status_class() if record.cable else ''
         }
         }
@@ -286,10 +293,10 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ConsoleServerPort
         model = ConsoleServerPort
         fields = (
         fields = (
-            'pk', 'device', 'name', 'label', 'type', 'description', 'mark_connected', 'cable', 'cable_peer',
+            'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer',
             'connection', 'tags',
             'connection', 'tags',
         )
         )
-        default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
+        default_columns = ('pk', 'device', 'name', 'label', 'type', 'speed', 'description')
 
 
 
 
 class DeviceConsoleServerPortTable(ConsoleServerPortTable):
 class DeviceConsoleServerPortTable(ConsoleServerPortTable):
@@ -307,10 +314,10 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ConsoleServerPort
         model = ConsoleServerPort
         fields = (
         fields = (
-            'pk', 'name', 'label', 'type', 'description', 'mark_connected', 'cable', 'cable_peer', 'connection', 'tags',
-            'actions',
+            'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer',
+            'connection', 'tags', 'actions',
         )
         )
-        default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions')
+        default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
         row_attrs = {
         row_attrs = {
             'class': lambda record: record.cable.get_status_class() if record.cable else ''
             'class': lambda record: record.cable.get_status_class() if record.cable else ''
         }
         }
@@ -437,6 +444,10 @@ class DeviceInterfaceTable(InterfaceTable):
                       '{% endif %}"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
                       '{% endif %}"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
         attrs={'td': {'class': 'text-nowrap'}}
         attrs={'td': {'class': 'text-nowrap'}}
     )
     )
+    parent = tables.Column(
+        linkify=True,
+        verbose_name='Parent'
+    )
     lag = tables.Column(
     lag = tables.Column(
         linkify=True,
         linkify=True,
         verbose_name='LAG'
         verbose_name='LAG'
@@ -450,13 +461,13 @@ class DeviceInterfaceTable(InterfaceTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = Interface
         model = Interface
         fields = (
         fields = (
-            'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'description',
-            'mark_connected', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan',
-            'tagged_vlans', 'actions',
+            'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address',
+            'description', 'mark_connected', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses',
+            'untagged_vlan', 'tagged_vlans', 'actions',
         )
         )
         default_columns = (
         default_columns = (
-            'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'cable',
-            'connection', 'actions',
+            'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
+            'cable', 'connection', 'actions',
         )
         )
         row_attrs = {
         row_attrs = {
             'class': lambda record: record.cable.get_status_class() if record.cable else '',
             'class': lambda record: record.cable.get_status_class() if record.cable else '',

+ 2 - 2
netbox/dcim/tables/power.py

@@ -33,8 +33,8 @@ class PowerPanelTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = PowerPanel
         model = PowerPanel
-        fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count', 'tags')
-        default_columns = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count')
+        fields = ('pk', 'name', 'site', 'location', 'powerfeed_count', 'tags')
+        default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
 
 
 
 
 #
 #

+ 15 - 26
netbox/dcim/tables/racks.py

@@ -1,18 +1,18 @@
 import django_tables2 as tables
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
-from dcim.models import Rack, RackGroup, RackReservation, RackRole
-from tenancy.tables import COL_TENANT
+from dcim.models import Rack, Location, RackReservation, RackRole
+from tenancy.tables import TenantColumn
 from utilities.tables import (
 from utilities.tables import (
-    BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn,
-    ToggleColumn,
+    BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MPTTColumn,
+    TagColumn, ToggleColumn, UtilizationColumn,
 )
 )
-from .template_code import MPTT_LINK, RACKGROUP_ELEVATIONS, UTILIZATION_GRAPH
+from .template_code import LOCATION_ELEVATIONS
 
 
 __all__ = (
 __all__ = (
     'RackTable',
     'RackTable',
     'RackDetailTable',
     'RackDetailTable',
-    'RackGroupTable',
+    'LocationTable',
     'RackReservationTable',
     'RackReservationTable',
     'RackRoleTable',
     'RackRoleTable',
 )
 )
@@ -22,13 +22,9 @@ __all__ = (
 # Rack groups
 # Rack groups
 #
 #
 
 
-class RackGroupTable(BaseTable):
+class LocationTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
-    name = tables.TemplateColumn(
-        template_code=MPTT_LINK,
-        orderable=False,
-        attrs={'td': {'class': 'text-nowrap'}}
-    )
+    name = MPTTColumn()
     site = tables.Column(
     site = tables.Column(
         linkify=True
         linkify=True
     )
     )
@@ -36,12 +32,12 @@ class RackGroupTable(BaseTable):
         verbose_name='Racks'
         verbose_name='Racks'
     )
     )
     actions = ButtonsColumn(
     actions = ButtonsColumn(
-        model=RackGroup,
-        prepend_template=RACKGROUP_ELEVATIONS
+        model=Location,
+        prepend_template=LOCATION_ELEVATIONS
     )
     )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
-        model = RackGroup
+        model = Location
         fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions')
         fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions')
         default_columns = ('pk', 'name', 'site', 'rack_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'site', 'rack_count', 'description', 'actions')
 
 
@@ -79,9 +75,7 @@ class RackTable(BaseTable):
     site = tables.Column(
     site = tables.Column(
         linkify=True
         linkify=True
     )
     )
-    tenant = tables.TemplateColumn(
-        template_code=COL_TENANT
-    )
+    tenant = TenantColumn()
     status = ChoiceFieldColumn()
     status = ChoiceFieldColumn()
     role = ColoredLabelColumn()
     role = ColoredLabelColumn()
     u_height = tables.TemplateColumn(
     u_height = tables.TemplateColumn(
@@ -104,13 +98,10 @@ class RackDetailTable(RackTable):
         url_params={'rack_id': 'pk'},
         url_params={'rack_id': 'pk'},
         verbose_name='Devices'
         verbose_name='Devices'
     )
     )
-    get_utilization = tables.TemplateColumn(
-        template_code=UTILIZATION_GRAPH,
-        orderable=False,
+    get_utilization = UtilizationColumn(
         verbose_name='Space'
         verbose_name='Space'
     )
     )
-    get_power_utilization = tables.TemplateColumn(
-        template_code=UTILIZATION_GRAPH,
+    get_power_utilization = UtilizationColumn(
         orderable=False,
         orderable=False,
         verbose_name='Power'
         verbose_name='Power'
     )
     )
@@ -143,9 +134,7 @@ class RackReservationTable(BaseTable):
         accessor=Accessor('rack__site'),
         accessor=Accessor('rack__site'),
         linkify=True
         linkify=True
     )
     )
-    tenant = tables.TemplateColumn(
-        template_code=COL_TENANT
-    )
+    tenant = TenantColumn()
     rack = tables.Column(
     rack = tables.Column(
         linkify=True
         linkify=True
     )
     )

+ 28 - 13
netbox/dcim/tables/sites.py

@@ -1,13 +1,13 @@
 import django_tables2 as tables
 import django_tables2 as tables
 
 
-from dcim.models import Region, Site
-from tenancy.tables import COL_TENANT
-from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
-from .template_code import MPTT_LINK
+from dcim.models import Region, Site, SiteGroup
+from tenancy.tables import TenantColumn
+from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MPTTColumn, TagColumn, ToggleColumn
 
 
 __all__ = (
 __all__ = (
     'RegionTable',
     'RegionTable',
     'SiteTable',
     'SiteTable',
+    'SiteGroupTable',
 )
 )
 
 
 
 
@@ -17,11 +17,7 @@ __all__ = (
 
 
 class RegionTable(BaseTable):
 class RegionTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
-    name = tables.TemplateColumn(
-        template_code=MPTT_LINK,
-        orderable=False,
-        attrs={'td': {'class': 'text-nowrap'}}
-    )
+    name = MPTTColumn()
     site_count = tables.Column(
     site_count = tables.Column(
         verbose_name='Sites'
         verbose_name='Sites'
     )
     )
@@ -33,6 +29,24 @@ class RegionTable(BaseTable):
         default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
 
 
 
 
+#
+# Site groups
+#
+
+class SiteGroupTable(BaseTable):
+    pk = ToggleColumn()
+    name = MPTTColumn()
+    site_count = tables.Column(
+        verbose_name='Sites'
+    )
+    actions = ButtonsColumn(SiteGroup)
+
+    class Meta(BaseTable.Meta):
+        model = SiteGroup
+        fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions')
+        default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
+
+
 #
 #
 # Sites
 # Sites
 #
 #
@@ -46,9 +60,10 @@ class SiteTable(BaseTable):
     region = tables.Column(
     region = tables.Column(
         linkify=True
         linkify=True
     )
     )
-    tenant = tables.TemplateColumn(
-        template_code=COL_TENANT
+    group = tables.Column(
+        linkify=True
     )
     )
+    tenant = TenantColumn()
     tags = TagColumn(
     tags = TagColumn(
         url_name='dcim:site_list'
         url_name='dcim:site_list'
     )
     )
@@ -56,8 +71,8 @@ class SiteTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Site
         model = Site
         fields = (
         fields = (
-            'pk', 'name', 'slug', 'status', 'facility', 'region', 'tenant', 'asn', 'time_zone', 'description',
+            'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description',
             'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
             'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
             'contact_email', 'tags',
             'contact_email', 'tags',
         )
         )
-        default_columns = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description')
+        default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description')

+ 4 - 16
netbox/dcim/tables/template_code.py

@@ -1,6 +1,6 @@
 CABLETERMINATION = """
 CABLETERMINATION = """
 {% if value %}
 {% if value %}
-    <a href="{{ value.parent.get_absolute_url }}">{{ value.parent }}</a>
+    <a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a>
     <i class="mdi mdi-chevron-right"></i>
     <i class="mdi mdi-chevron-right"></i>
     <a href="{{ value.get_absolute_url }}">{{ value }}</a>
     <a href="{{ value.get_absolute_url }}">{{ value }}</a>
 {% else %}
 {% else %}
@@ -56,13 +56,6 @@ INTERFACE_TAGGED_VLANS = """
 {% endif %}
 {% endif %}
 """
 """
 
 
-MPTT_LINK = """
-{% for i in record.get_ancestors %}
-    <i class="mdi mdi-circle-small"></i>
-{% endfor %}
-<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
-"""
-
 POWERFEED_CABLE = """
 POWERFEED_CABLE = """
 <a href="{{ value.get_absolute_url }}">{{ value }}</a>
 <a href="{{ value.get_absolute_url }}">{{ value }}</a>
 <a href="{% url 'dcim:powerfeed_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace">
 <a href="{% url 'dcim:powerfeed_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace">
@@ -71,22 +64,17 @@ POWERFEED_CABLE = """
 """
 """
 
 
 POWERFEED_CABLETERMINATION = """
 POWERFEED_CABLETERMINATION = """
-<a href="{{ value.parent.get_absolute_url }}">{{ value.parent }}</a>
+<a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a>
 <i class="mdi mdi-chevron-right"></i>
 <i class="mdi mdi-chevron-right"></i>
 <a href="{{ value.get_absolute_url }}">{{ value }}</a>
 <a href="{{ value.get_absolute_url }}">{{ value }}</a>
 """
 """
 
 
-RACKGROUP_ELEVATIONS = """
-<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-sm btn-primary" title="View elevations">
+LOCATION_ELEVATIONS = """
+<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&location_id={{ record.pk }}" class="btn btn-sm btn-primary" title="View elevations">
     <i class="mdi mdi-server"></i>
     <i class="mdi mdi-server"></i>
 </a>
 </a>
 """
 """
 
 
-UTILIZATION_GRAPH = """
-{% load helpers %}
-{% utilization_graph value %}
-"""
-
 #
 #
 # Device component buttons
 # Device component buttons
 #
 #

+ 90 - 87
netbox/dcim/tests/test_api.py

@@ -4,12 +4,7 @@ from rest_framework import status
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
-from dcim.models import (
-    Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer,
-    InventoryItem, Platform, PowerFeed, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, PowerPanel,
-    Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
-)
+from dcim.models import *
 from ipam.models import VLAN
 from ipam.models import VLAN
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
 from virtualization.models import Cluster, ClusterType
 from virtualization.models import Cluster, ClusterType
@@ -64,7 +59,7 @@ class Mixins:
 
 
 class RegionTest(APIViewTestCases.APIViewTestCase):
 class RegionTest(APIViewTestCases.APIViewTestCase):
     model = Region
     model = Region
-    brief_fields = ['_depth', 'id', 'name', 'site_count', 'slug', 'url']
+    brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url']
     create_data = [
     create_data = [
         {
         {
             'name': 'Region 4',
             'name': 'Region 4',
@@ -93,7 +88,7 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
 
 
 class SiteTest(APIViewTestCases.APIViewTestCase):
 class SiteTest(APIViewTestCases.APIViewTestCase):
     model = Site
     model = Site
-    brief_fields = ['id', 'name', 'slug', 'url']
+    brief_fields = ['display', 'id', 'name', 'slug', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'status': 'planned',
         'status': 'planned',
     }
     }
@@ -102,14 +97,19 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
         regions = (
         regions = (
-            Region.objects.create(name='Test Region 1', slug='test-region-1'),
-            Region.objects.create(name='Test Region 2', slug='test-region-2'),
+            Region.objects.create(name='Region 1', slug='region-1'),
+            Region.objects.create(name='Region 2', slug='region-2'),
+        )
+
+        groups = (
+            SiteGroup.objects.create(name='Site Group 1', slug='site-group-1'),
+            SiteGroup.objects.create(name='Site Group 2', slug='site-group-2'),
         )
         )
 
 
         sites = (
         sites = (
-            Site(region=regions[0], name='Site 1', slug='site-1'),
-            Site(region=regions[0], name='Site 2', slug='site-2'),
-            Site(region=regions[0], name='Site 3', slug='site-3'),
+            Site(region=regions[0], group=groups[0], name='Site 1', slug='site-1'),
+            Site(region=regions[0], group=groups[0], name='Site 2', slug='site-2'),
+            Site(region=regions[0], group=groups[0], name='Site 3', slug='site-3'),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
@@ -118,26 +118,29 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
                 'name': 'Site 4',
                 'name': 'Site 4',
                 'slug': 'site-4',
                 'slug': 'site-4',
                 'region': regions[1].pk,
                 'region': regions[1].pk,
+                'group': groups[1].pk,
                 'status': SiteStatusChoices.STATUS_ACTIVE,
                 'status': SiteStatusChoices.STATUS_ACTIVE,
             },
             },
             {
             {
                 'name': 'Site 5',
                 'name': 'Site 5',
                 'slug': 'site-5',
                 'slug': 'site-5',
                 'region': regions[1].pk,
                 'region': regions[1].pk,
+                'group': groups[1].pk,
                 'status': SiteStatusChoices.STATUS_ACTIVE,
                 'status': SiteStatusChoices.STATUS_ACTIVE,
             },
             },
             {
             {
                 'name': 'Site 6',
                 'name': 'Site 6',
                 'slug': 'site-6',
                 'slug': 'site-6',
                 'region': regions[1].pk,
                 'region': regions[1].pk,
+                'group': groups[1].pk,
                 'status': SiteStatusChoices.STATUS_ACTIVE,
                 'status': SiteStatusChoices.STATUS_ACTIVE,
             },
             },
         ]
         ]
 
 
 
 
-class RackGroupTest(APIViewTestCases.APIViewTestCase):
-    model = RackGroup
-    brief_fields = ['_depth', 'id', 'name', 'rack_count', 'slug', 'url']
+class LocationTest(APIViewTestCases.APIViewTestCase):
+    model = Location
+    brief_fields = ['_depth', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -151,40 +154,40 @@ class RackGroupTest(APIViewTestCases.APIViewTestCase):
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
-        parent_rack_groups = (
-            RackGroup.objects.create(site=sites[0], name='Parent Rack Group 1', slug='parent-rack-group-1'),
-            RackGroup.objects.create(site=sites[1], name='Parent Rack Group 2', slug='parent-rack-group-2'),
+        parent_locations = (
+            Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1'),
+            Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2'),
         )
         )
 
 
-        RackGroup.objects.create(site=sites[0], name='Rack Group 1', slug='rack-group-1', parent=parent_rack_groups[0])
-        RackGroup.objects.create(site=sites[0], name='Rack Group 2', slug='rack-group-2', parent=parent_rack_groups[0])
-        RackGroup.objects.create(site=sites[0], name='Rack Group 3', slug='rack-group-3', parent=parent_rack_groups[0])
+        Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0])
+        Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0])
+        Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0])
 
 
         cls.create_data = [
         cls.create_data = [
             {
             {
-                'name': 'Test Rack Group 4',
-                'slug': 'test-rack-group-4',
+                'name': 'Test Location 4',
+                'slug': 'test-location-4',
                 'site': sites[1].pk,
                 'site': sites[1].pk,
-                'parent': parent_rack_groups[1].pk,
+                'parent': parent_locations[1].pk,
             },
             },
             {
             {
-                'name': 'Test Rack Group 5',
-                'slug': 'test-rack-group-5',
+                'name': 'Test Location 5',
+                'slug': 'test-location-5',
                 'site': sites[1].pk,
                 'site': sites[1].pk,
-                'parent': parent_rack_groups[1].pk,
+                'parent': parent_locations[1].pk,
             },
             },
             {
             {
-                'name': 'Test Rack Group 6',
-                'slug': 'test-rack-group-6',
+                'name': 'Test Location 6',
+                'slug': 'test-location-6',
                 'site': sites[1].pk,
                 'site': sites[1].pk,
-                'parent': parent_rack_groups[1].pk,
+                'parent': parent_locations[1].pk,
             },
             },
         ]
         ]
 
 
 
 
 class RackRoleTest(APIViewTestCases.APIViewTestCase):
 class RackRoleTest(APIViewTestCases.APIViewTestCase):
     model = RackRole
     model = RackRole
-    brief_fields = ['id', 'name', 'rack_count', 'slug', 'url']
+    brief_fields = ['display', 'id', 'name', 'rack_count', 'slug', 'url']
     create_data = [
     create_data = [
         {
         {
             'name': 'Rack Role 4',
             'name': 'Rack Role 4',
@@ -219,7 +222,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
 
 
 class RackTest(APIViewTestCases.APIViewTestCase):
 class RackTest(APIViewTestCases.APIViewTestCase):
     model = Rack
     model = Rack
-    brief_fields = ['device_count', 'display_name', 'id', 'name', 'url']
+    brief_fields = ['device_count', 'display', 'display_name', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'status': 'planned',
         'status': 'planned',
     }
     }
@@ -233,9 +236,9 @@ class RackTest(APIViewTestCases.APIViewTestCase):
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
-        rack_groups = (
-            RackGroup.objects.create(site=sites[0], name='Rack Group 1', slug='rack-group-1'),
-            RackGroup.objects.create(site=sites[1], name='Rack Group 2', slug='rack-group-2'),
+        locations = (
+            Location.objects.create(site=sites[0], name='Location 1', slug='location-1'),
+            Location.objects.create(site=sites[1], name='Location 2', slug='location-2'),
         )
         )
 
 
         rack_roles = (
         rack_roles = (
@@ -245,9 +248,9 @@ class RackTest(APIViewTestCases.APIViewTestCase):
         RackRole.objects.bulk_create(rack_roles)
         RackRole.objects.bulk_create(rack_roles)
 
 
         racks = (
         racks = (
-            Rack(site=sites[0], group=rack_groups[0], role=rack_roles[0], name='Rack 1'),
-            Rack(site=sites[0], group=rack_groups[0], role=rack_roles[0], name='Rack 2'),
-            Rack(site=sites[0], group=rack_groups[0], role=rack_roles[0], name='Rack 3'),
+            Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 1'),
+            Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 2'),
+            Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 3'),
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
@@ -255,19 +258,19 @@ class RackTest(APIViewTestCases.APIViewTestCase):
             {
             {
                 'name': 'Test Rack 4',
                 'name': 'Test Rack 4',
                 'site': sites[1].pk,
                 'site': sites[1].pk,
-                'group': rack_groups[1].pk,
+                'location': locations[1].pk,
                 'role': rack_roles[1].pk,
                 'role': rack_roles[1].pk,
             },
             },
             {
             {
                 'name': 'Test Rack 5',
                 'name': 'Test Rack 5',
                 'site': sites[1].pk,
                 'site': sites[1].pk,
-                'group': rack_groups[1].pk,
+                'location': locations[1].pk,
                 'role': rack_roles[1].pk,
                 'role': rack_roles[1].pk,
             },
             },
             {
             {
                 'name': 'Test Rack 6',
                 'name': 'Test Rack 6',
                 'site': sites[1].pk,
                 'site': sites[1].pk,
-                'group': rack_groups[1].pk,
+                'location': locations[1].pk,
                 'role': rack_roles[1].pk,
                 'role': rack_roles[1].pk,
             },
             },
         ]
         ]
@@ -307,7 +310,7 @@ class RackTest(APIViewTestCases.APIViewTestCase):
 
 
 class RackReservationTest(APIViewTestCases.APIViewTestCase):
 class RackReservationTest(APIViewTestCases.APIViewTestCase):
     model = RackReservation
     model = RackReservation
-    brief_fields = ['id', 'units', 'url', 'user']
+    brief_fields = ['display', 'id', 'units', 'url', 'user']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -358,7 +361,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
 
 
 class ManufacturerTest(APIViewTestCases.APIViewTestCase):
 class ManufacturerTest(APIViewTestCases.APIViewTestCase):
     model = Manufacturer
     model = Manufacturer
-    brief_fields = ['devicetype_count', 'id', 'name', 'slug', 'url']
+    brief_fields = ['devicetype_count', 'display', 'id', 'name', 'slug', 'url']
     create_data = [
     create_data = [
         {
         {
             'name': 'Manufacturer 4',
             'name': 'Manufacturer 4',
@@ -390,7 +393,7 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
 
 
 class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
 class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
     model = DeviceType
     model = DeviceType
-    brief_fields = ['device_count', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url']
+    brief_fields = ['device_count', 'display', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'part_number': 'ABC123',
         'part_number': 'ABC123',
     }
     }
@@ -432,7 +435,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
 
 
 class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
 class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ConsolePortTemplate
     model = ConsolePortTemplate
-    brief_fields = ['id', 'name', 'url']
+    brief_fields = ['display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -469,7 +472,7 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
 
 
 class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
 class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ConsoleServerPortTemplate
     model = ConsoleServerPortTemplate
-    brief_fields = ['id', 'name', 'url']
+    brief_fields = ['display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -506,7 +509,7 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
 
 
 class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
 class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
     model = PowerPortTemplate
     model = PowerPortTemplate
-    brief_fields = ['id', 'name', 'url']
+    brief_fields = ['display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -543,7 +546,7 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
 
 
 class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
 class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
     model = PowerOutletTemplate
     model = PowerOutletTemplate
-    brief_fields = ['id', 'name', 'url']
+    brief_fields = ['display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -580,7 +583,7 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
 
 
 class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
 class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
     model = InterfaceTemplate
     model = InterfaceTemplate
-    brief_fields = ['id', 'name', 'url']
+    brief_fields = ['display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -620,7 +623,7 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
 
 
 class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
 class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
     model = FrontPortTemplate
     model = FrontPortTemplate
-    brief_fields = ['id', 'name', 'url']
+    brief_fields = ['display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -691,7 +694,7 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
 
 
 class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
 class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
     model = RearPortTemplate
     model = RearPortTemplate
-    brief_fields = ['id', 'name', 'url']
+    brief_fields = ['display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -731,7 +734,7 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
 
 
 class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
 class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
     model = DeviceBayTemplate
     model = DeviceBayTemplate
-    brief_fields = ['id', 'name', 'url']
+    brief_fields = ['display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -771,7 +774,7 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
 
 
 class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
 class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
     model = DeviceRole
     model = DeviceRole
-    brief_fields = ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
+    brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
     create_data = [
     create_data = [
         {
         {
             'name': 'Device Role 4',
             'name': 'Device Role 4',
@@ -806,7 +809,7 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
 
 
 class PlatformTest(APIViewTestCases.APIViewTestCase):
 class PlatformTest(APIViewTestCases.APIViewTestCase):
     model = Platform
     model = Platform
-    brief_fields = ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
+    brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
     create_data = [
     create_data = [
         {
         {
             'name': 'Platform 4',
             'name': 'Platform 4',
@@ -838,7 +841,7 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
 
 
 class DeviceTest(APIViewTestCases.APIViewTestCase):
 class DeviceTest(APIViewTestCases.APIViewTestCase):
     model = Device
     model = Device
-    brief_fields = ['display_name', 'id', 'name', 'url']
+    brief_fields = ['display', 'display_name', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'status': 'failed',
         'status': 'failed',
     }
     }
@@ -979,7 +982,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
 
 
 class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
 class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = ConsolePort
     model = ConsolePort
-    brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url']
+    brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -1018,7 +1021,7 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
 
 
 class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
 class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = ConsoleServerPort
     model = ConsoleServerPort
-    brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url']
+    brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -1057,7 +1060,7 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
 
 
 class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
 class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = PowerPort
     model = PowerPort
-    brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url']
+    brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -1096,7 +1099,7 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
 
 
 class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
 class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = PowerOutlet
     model = PowerOutlet
-    brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url']
+    brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -1135,7 +1138,7 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
 
 
 class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
 class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = Interface
     model = Interface
-    brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url']
+    brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -1193,7 +1196,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
 
 
 class FrontPortTest(APIViewTestCases.APIViewTestCase):
 class FrontPortTest(APIViewTestCases.APIViewTestCase):
     model = FrontPort
     model = FrontPort
-    brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url']
+    brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -1251,7 +1254,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
 
 
 class RearPortTest(APIViewTestCases.APIViewTestCase):
 class RearPortTest(APIViewTestCases.APIViewTestCase):
     model = RearPort
     model = RearPort
-    brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url']
+    brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -1293,7 +1296,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
 
 
 class DeviceBayTest(APIViewTestCases.APIViewTestCase):
 class DeviceBayTest(APIViewTestCases.APIViewTestCase):
     model = DeviceBay
     model = DeviceBay
-    brief_fields = ['device', 'id', 'name', 'url']
+    brief_fields = ['device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -1356,7 +1359,7 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase):
 
 
 class InventoryItemTest(APIViewTestCases.APIViewTestCase):
 class InventoryItemTest(APIViewTestCases.APIViewTestCase):
     model = InventoryItem
     model = InventoryItem
-    brief_fields = ['_depth', 'device', 'id', 'name', 'url']
+    brief_fields = ['_depth', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -1394,7 +1397,7 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
 
 
 class CableTest(APIViewTestCases.APIViewTestCase):
 class CableTest(APIViewTestCases.APIViewTestCase):
     model = Cable
     model = Cable
-    brief_fields = ['id', 'label', 'url']
+    brief_fields = ['display', 'id', 'label', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'length': 100,
         'length': 100,
         'length_unit': 'm',
         'length_unit': 'm',
@@ -1579,7 +1582,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
 
 
 class PowerPanelTest(APIViewTestCases.APIViewTestCase):
 class PowerPanelTest(APIViewTestCases.APIViewTestCase):
     model = PowerPanel
     model = PowerPanel
-    brief_fields = ['id', 'name', 'powerfeed_count', 'url']
+    brief_fields = ['display', 'id', 'name', 'powerfeed_count', 'url']
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1588,17 +1591,17 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase):
             Site.objects.create(name='Site 2', slug='site-2'),
             Site.objects.create(name='Site 2', slug='site-2'),
         )
         )
 
 
-        rack_groups = (
-            RackGroup.objects.create(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
-            RackGroup.objects.create(name='Rack Group 2', slug='rack-group-2', site=sites[0]),
-            RackGroup.objects.create(name='Rack Group 3', slug='rack-group-3', site=sites[0]),
-            RackGroup.objects.create(name='Rack Group 4', slug='rack-group-3', site=sites[1]),
+        locations = (
+            Location.objects.create(name='Location 1', slug='location-1', site=sites[0]),
+            Location.objects.create(name='Location 2', slug='location-2', site=sites[0]),
+            Location.objects.create(name='Location 3', slug='location-3', site=sites[0]),
+            Location.objects.create(name='Location 4', slug='location-3', site=sites[1]),
         )
         )
 
 
         power_panels = (
         power_panels = (
-            PowerPanel(site=sites[0], rack_group=rack_groups[0], name='Power Panel 1'),
-            PowerPanel(site=sites[0], rack_group=rack_groups[1], name='Power Panel 2'),
-            PowerPanel(site=sites[0], rack_group=rack_groups[2], name='Power Panel 3'),
+            PowerPanel(site=sites[0], location=locations[0], name='Power Panel 1'),
+            PowerPanel(site=sites[0], location=locations[1], name='Power Panel 2'),
+            PowerPanel(site=sites[0], location=locations[2], name='Power Panel 3'),
         )
         )
         PowerPanel.objects.bulk_create(power_panels)
         PowerPanel.objects.bulk_create(power_panels)
 
 
@@ -1606,29 +1609,29 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase):
             {
             {
                 'name': 'Power Panel 4',
                 'name': 'Power Panel 4',
                 'site': sites[0].pk,
                 'site': sites[0].pk,
-                'rack_group': rack_groups[0].pk,
+                'location': locations[0].pk,
             },
             },
             {
             {
                 'name': 'Power Panel 5',
                 'name': 'Power Panel 5',
                 'site': sites[0].pk,
                 'site': sites[0].pk,
-                'rack_group': rack_groups[1].pk,
+                'location': locations[1].pk,
             },
             },
             {
             {
                 'name': 'Power Panel 6',
                 'name': 'Power Panel 6',
                 'site': sites[0].pk,
                 'site': sites[0].pk,
-                'rack_group': rack_groups[2].pk,
+                'location': locations[2].pk,
             },
             },
         ]
         ]
 
 
         cls.bulk_update_data = {
         cls.bulk_update_data = {
             'site': sites[1].pk,
             'site': sites[1].pk,
-            'rack_group': rack_groups[3].pk
+            'location': locations[3].pk
         }
         }
 
 
 
 
 class PowerFeedTest(APIViewTestCases.APIViewTestCase):
 class PowerFeedTest(APIViewTestCases.APIViewTestCase):
     model = PowerFeed
     model = PowerFeed
-    brief_fields = ['_occupied', 'cable', 'id', 'name', 'url']
+    brief_fields = ['_occupied', 'cable', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'status': 'planned',
         'status': 'planned',
     }
     }
@@ -1636,20 +1639,20 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         site = Site.objects.create(name='Site 1', slug='site-1')
         site = Site.objects.create(name='Site 1', slug='site-1')
-        rackgroup = RackGroup.objects.create(site=site, name='Rack Group 1', slug='rack-group-1')
+        location = Location.objects.create(site=site, name='Location 1', slug='location-1')
         rackrole = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1', color='ff0000')
         rackrole = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1', color='ff0000')
 
 
         racks = (
         racks = (
-            Rack(site=site, group=rackgroup, role=rackrole, name='Rack 1'),
-            Rack(site=site, group=rackgroup, role=rackrole, name='Rack 2'),
-            Rack(site=site, group=rackgroup, role=rackrole, name='Rack 3'),
-            Rack(site=site, group=rackgroup, role=rackrole, name='Rack 4'),
+            Rack(site=site, location=location, role=rackrole, name='Rack 1'),
+            Rack(site=site, location=location, role=rackrole, name='Rack 2'),
+            Rack(site=site, location=location, role=rackrole, name='Rack 3'),
+            Rack(site=site, location=location, role=rackrole, name='Rack 4'),
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
         power_panels = (
         power_panels = (
-            PowerPanel(site=site, rack_group=rackgroup, name='Power Panel 1'),
-            PowerPanel(site=site, rack_group=rackgroup, name='Power Panel 2'),
+            PowerPanel(site=site, location=location, name='Power Panel 1'),
+            PowerPanel(site=site, location=location, name='Power Panel 2'),
         )
         )
         PowerPanel.objects.bulk_create(power_panels)
         PowerPanel.objects.bulk_create(power_panels)
 
 

+ 446 - 126
netbox/dcim/tests/test_filters.py

@@ -3,13 +3,7 @@ from django.test import TestCase
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.filters import *
 from dcim.filters import *
-from dcim.models import (
-    Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
-    InventoryItem, Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerPortTemplate, PowerOutlet,
-    PowerOutletTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
-    VirtualChassis,
-)
+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 virtualization.models import Cluster, ClusterType
 from virtualization.models import Cluster, ClusterType
@@ -65,6 +59,56 @@ 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):
+    queryset = SiteGroup.objects.all()
+    filterset = SiteGroupFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        sitegroups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1', description='A'),
+            SiteGroup(name='Site Group 2', slug='site-group-2', description='B'),
+            SiteGroup(name='Site Group 3', slug='site-group-3', description='C'),
+        )
+        for sitegroup in sitegroups:
+            sitegroup.save()
+
+        child_sitegroups = (
+            SiteGroup(name='Site Group 1A', slug='site-group-1a', parent=sitegroups[0]),
+            SiteGroup(name='Site Group 1B', slug='site-group-1b', parent=sitegroups[0]),
+            SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=sitegroups[1]),
+            SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=sitegroups[1]),
+            SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=sitegroups[2]),
+            SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=sitegroups[2]),
+        )
+        for sitegroup in child_sitegroups:
+            sitegroup.save()
+
+    def test_id(self):
+        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_name(self):
+        params = {'name': ['Site Group 1', 'Site Group 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_slug(self):
+        params = {'slug': ['site-group-1', 'site-group-2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['A', 'B']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_parent(self):
+        parent_sitegroups = SiteGroup.objects.filter(parent__isnull=True)[:2]
+        params = {'parent_id': [parent_sitegroups[0].pk, parent_sitegroups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'parent': [parent_sitegroups[0].slug, parent_sitegroups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+
 class SiteTestCase(TestCase):
 class SiteTestCase(TestCase):
     queryset = Site.objects.all()
     queryset = Site.objects.all()
     filterset = SiteFilterSet
     filterset = SiteFilterSet
@@ -80,6 +124,14 @@ class SiteTestCase(TestCase):
         for region in regions:
         for region in regions:
             region.save()
             region.save()
 
 
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
         tenant_groups = (
         tenant_groups = (
             TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
@@ -96,9 +148,9 @@ class SiteTestCase(TestCase):
         Tenant.objects.bulk_create(tenants)
         Tenant.objects.bulk_create(tenants)
 
 
         sites = (
         sites = (
-            Site(name='Site 1', slug='site-1', region=regions[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'),
-            Site(name='Site 2', slug='site-2', region=regions[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'),
-            Site(name='Site 3', slug='site-3', region=regions[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
@@ -153,6 +205,13 @@ class SiteTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_site_group(self):
+        groups = SiteGroup.objects.all()[:2]
+        params = {'group_id': [groups[0].pk, groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'group': [groups[0].slug, groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_tenant(self):
     def test_tenant(self):
         tenants = Tenant.objects.all()[:2]
         tenants = Tenant.objects.all()[:2]
         params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
         params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
@@ -168,9 +227,9 @@ class SiteTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class RackGroupTestCase(TestCase):
-    queryset = RackGroup.objects.all()
-    filterset = RackGroupFilterSet
+class LocationTestCase(TestCase):
+    queryset = Location.objects.all()
+    filterset = LocationFilterSet
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -183,39 +242,47 @@ class RackGroupTestCase(TestCase):
         for region in regions:
         for region in regions:
             region.save()
             region.save()
 
 
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
         sites = (
         sites = (
-            Site(name='Site 1', slug='site-1', region=regions[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
-        parent_rack_groups = (
-            RackGroup(name='Parent Rack Group 1', slug='parent-rack-group-1', site=sites[0]),
-            RackGroup(name='Parent Rack Group 2', slug='parent-rack-group-2', site=sites[1]),
-            RackGroup(name='Parent Rack Group 3', slug='parent-rack-group-3', site=sites[2]),
+        parent_locations = (
+            Location(name='Parent Location 1', slug='parent-location-1', site=sites[0]),
+            Location(name='Parent Location 2', slug='parent-location-2', site=sites[1]),
+            Location(name='Parent Location 3', slug='parent-location-3', site=sites[2]),
         )
         )
-        for rackgroup in parent_rack_groups:
-            rackgroup.save()
+        for location in parent_locations:
+            location.save()
 
 
-        rack_groups = (
-            RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0], parent=parent_rack_groups[0], description='A'),
-            RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1], parent=parent_rack_groups[1], description='B'),
-            RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2], parent=parent_rack_groups[2], description='C'),
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], description='A'),
+            Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], description='B'),
+            Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], description='C'),
         )
         )
-        for rackgroup in rack_groups:
-            rackgroup.save()
+        for location in locations:
+            location.save()
 
 
     def test_id(self):
     def test_id(self):
         params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
         params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_name(self):
     def test_name(self):
-        params = {'name': ['Rack Group 1', 'Rack Group 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)
 
 
     def test_slug(self):
     def test_slug(self):
-        params = {'slug': ['rack-group-1', 'rack-group-2']}
+        params = {'slug': ['location-1', 'location-2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_description(self):
     def test_description(self):
@@ -229,6 +296,13 @@ class RackGroupTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
     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]}
@@ -237,7 +311,7 @@ class RackGroupTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
     def test_parent(self):
     def test_parent(self):
-        parent_groups = RackGroup.objects.filter(name__startswith='Parent')[:2]
+        parent_groups = Location.objects.filter(name__startswith='Parent')[:2]
         params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]}
         params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]}
         params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]}
@@ -290,20 +364,28 @@ class RackTestCase(TestCase):
         for region in regions:
         for region in regions:
             region.save()
             region.save()
 
 
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
         sites = (
         sites = (
-            Site(name='Site 1', slug='site-1', region=regions[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
-        rack_groups = (
-            RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
-            RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
-            RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]),
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
         )
         )
-        for rackgroup in rack_groups:
-            rackgroup.save()
+        for location in locations:
+            location.save()
 
 
         rack_roles = (
         rack_roles = (
             RackRole(name='Rack Role 1', slug='rack-role-1'),
             RackRole(name='Rack Role 1', slug='rack-role-1'),
@@ -328,9 +410,9 @@ class RackTestCase(TestCase):
         Tenant.objects.bulk_create(tenants)
         Tenant.objects.bulk_create(tenants)
 
 
         racks = (
         racks = (
-            Rack(name='Rack 1', facility_id='rack-1', site=sites[0], group=rack_groups[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
-            Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
-            Rack(name='Rack 3', facility_id='rack-3', site=sites[2], group=rack_groups[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH),
+            Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
+            Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
+            Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH),
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
@@ -388,6 +470,13 @@ class RackTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        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]}
@@ -395,11 +484,11 @@ class RackTestCase(TestCase):
         params = {'site': [sites[0].slug, sites[1].slug]}
         params = {'site': [sites[0].slug, sites[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-    def test_group(self):
-        groups = RackGroup.objects.all()[:2]
-        params = {'group_id': [groups[0].pk, groups[1].pk]}
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'group': [groups[0].slug, groups[1].slug]}
+        params = {'location': [locations[0].slug, locations[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_status(self):
     def test_status(self):
@@ -448,18 +537,18 @@ class RackReservationTestCase(TestCase):
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
-        rack_groups = (
-            RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
-            RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
-            RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]),
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
         )
         )
-        for rackgroup in rack_groups:
-            rackgroup.save()
+        for location in locations:
+            location.save()
 
 
         racks = (
         racks = (
-            Rack(name='Rack 1', site=sites[0], group=rack_groups[0]),
-            Rack(name='Rack 2', site=sites[1], group=rack_groups[1]),
-            Rack(name='Rack 3', site=sites[2], group=rack_groups[2]),
+            Rack(name='Rack 1', site=sites[0], location=locations[0]),
+            Rack(name='Rack 2', site=sites[1], location=locations[1]),
+            Rack(name='Rack 3', site=sites[2], location=locations[2]),
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
@@ -503,11 +592,11 @@ class RackReservationTestCase(TestCase):
         params = {'site': [sites[0].slug, sites[1].slug]}
         params = {'site': [sites[0].slug, sites[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-    def test_group(self):
-        groups = RackGroup.objects.all()[:2]
-        params = {'group_id': [groups[0].pk, groups[1].pk]}
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'group': [groups[0].slug, groups[1].slug]}
+        params = {'location': [locations[0].slug, locations[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_user(self):
     def test_user(self):
@@ -1161,25 +1250,33 @@ class DeviceTestCase(TestCase):
         for region in regions:
         for region in regions:
             region.save()
             region.save()
 
 
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
         sites = (
         sites = (
-            Site(name='Site 1', slug='site-1', region=regions[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
-        rack_groups = (
-            RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
-            RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
-            RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]),
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
         )
         )
-        for rackgroup in rack_groups:
-            rackgroup.save()
+        for location in locations:
+            location.save()
 
 
         racks = (
         racks = (
-            Rack(name='Rack 1', site=sites[0], group=rack_groups[0]),
-            Rack(name='Rack 2', site=sites[1], group=rack_groups[1]),
-            Rack(name='Rack 3', site=sites[2], group=rack_groups[2]),
+            Rack(name='Rack 1', site=sites[0], location=locations[0]),
+            Rack(name='Rack 2', site=sites[1], location=locations[1]),
+            Rack(name='Rack 3', site=sites[2], location=locations[2]),
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
@@ -1207,9 +1304,9 @@ class DeviceTestCase(TestCase):
         Tenant.objects.bulk_create(tenants)
         Tenant.objects.bulk_create(tenants)
 
 
         devices = (
         devices = (
-            Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}),
-            Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, cluster=clusters[1]),
-            Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, cluster=clusters[2]),
+            Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}),
+            Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, cluster=clusters[1]),
+            Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, cluster=clusters[2]),
         )
         )
         Device.objects.bulk_create(devices)
         Device.objects.bulk_create(devices)
 
 
@@ -1324,6 +1421,13 @@ class DeviceTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        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]}
@@ -1331,9 +1435,9 @@ class DeviceTestCase(TestCase):
         params = {'site': [sites[0].slug, sites[1].slug]}
         params = {'site': [sites[0].slug, sites[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-    def test_rackgroup(self):
-        rack_groups = RackGroup.objects.all()[:2]
-        params = {'rack_group_id': [rack_groups[0].pk, rack_groups[1].pk]}
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_rack(self):
     def test_rack(self):
@@ -1463,10 +1567,19 @@ class ConsolePortTestCase(TestCase):
         )
         )
         for region in regions:
         for region in regions:
             region.save()
             region.save()
+
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
         sites = Site.objects.bulk_create((
         sites = Site.objects.bulk_create((
-            Site(name='Site 1', slug='site-1', region=regions[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -1524,6 +1637,13 @@ class ConsolePortTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        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]}
@@ -1559,10 +1679,19 @@ class ConsoleServerPortTestCase(TestCase):
         )
         )
         for region in regions:
         for region in regions:
             region.save()
             region.save()
+
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
         sites = Site.objects.bulk_create((
         sites = Site.objects.bulk_create((
-            Site(name='Site 1', slug='site-1', region=regions[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -1620,6 +1749,13 @@ class ConsoleServerPortTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        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]}
@@ -1655,10 +1791,19 @@ class PowerPortTestCase(TestCase):
         )
         )
         for region in regions:
         for region in regions:
             region.save()
             region.save()
+
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
         sites = Site.objects.bulk_create((
         sites = Site.objects.bulk_create((
-            Site(name='Site 1', slug='site-1', region=regions[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -1724,6 +1869,13 @@ class PowerPortTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        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]}
@@ -1759,10 +1911,19 @@ class PowerOutletTestCase(TestCase):
         )
         )
         for region in regions:
         for region in regions:
             region.save()
             region.save()
+
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
         sites = Site.objects.bulk_create((
         sites = Site.objects.bulk_create((
-            Site(name='Site 1', slug='site-1', region=regions[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -1825,6 +1986,13 @@ class PowerOutletTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        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]}
@@ -1860,10 +2028,19 @@ class InterfaceTestCase(TestCase):
         )
         )
         for region in regions:
         for region in regions:
             region.save()
             region.save()
+
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
         sites = Site.objects.bulk_create((
         sites = Site.objects.bulk_create((
-            Site(name='Site 1', slug='site-1', region=regions[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -1931,6 +2108,34 @@ class InterfaceTestCase(TestCase):
         params = {'description': ['First', 'Second']}
         params = {'description': ['First', 'Second']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_parent(self):
+        # Create child interfaces
+        parent_interface = Interface.objects.first()
+        child_interfaces = (
+            Interface(device=parent_interface.device, name='Child 1', parent=parent_interface, type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=parent_interface.device, name='Child 2', parent=parent_interface, type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=parent_interface.device, name='Child 3', parent=parent_interface, type=InterfaceTypeChoices.TYPE_VIRTUAL),
+        )
+        Interface.objects.bulk_create(child_interfaces)
+
+        params = {'parent_id': [parent_interface.pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+    def test_lag(self):
+        # Create LAG members
+        device = Device.objects.first()
+        lag_interface = Interface(device=device, name='LAG', type=InterfaceTypeChoices.TYPE_LAG)
+        lag_interface.save()
+        lag_members = (
+            Interface(device=device, name='Member 1', lag=lag_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=device, name='Member 2', lag=lag_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=device, name='Member 3', lag=lag_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+        )
+        Interface.objects.bulk_create(lag_members)
+
+        params = {'lag_id': [lag_interface.pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
     def test_region(self):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -1938,6 +2143,13 @@ class InterfaceTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        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]}
@@ -1987,10 +2199,19 @@ class FrontPortTestCase(TestCase):
         )
         )
         for region in regions:
         for region in regions:
             region.save()
             region.save()
+
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
         sites = Site.objects.bulk_create((
         sites = Site.objects.bulk_create((
-            Site(name='Site 1', slug='site-1', region=regions[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -2054,6 +2275,13 @@ class FrontPortTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        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]}
@@ -2089,10 +2317,19 @@ class RearPortTestCase(TestCase):
         )
         )
         for region in regions:
         for region in regions:
             region.save()
             region.save()
+
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
         sites = Site.objects.bulk_create((
         sites = Site.objects.bulk_create((
-            Site(name='Site 1', slug='site-1', region=regions[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -2150,6 +2387,13 @@ class RearPortTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        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]}
@@ -2185,10 +2429,19 @@ class DeviceBayTestCase(TestCase):
         )
         )
         for region in regions:
         for region in regions:
             region.save()
             region.save()
+
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
         sites = Site.objects.bulk_create((
         sites = Site.objects.bulk_create((
-            Site(name='Site 1', slug='site-1', region=regions[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
             Site(name='Site X', slug='site-x'),
             Site(name='Site X', slug='site-x'),
         ))
         ))
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -2228,6 +2481,13 @@ class DeviceBayTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        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]}
@@ -2268,10 +2528,18 @@ class InventoryItemTestCase(TestCase):
         for region in regions:
         for region in regions:
             region.save()
             region.save()
 
 
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
         sites = (
         sites = (
-            Site(name='Site 1', slug='site-1', region=regions[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
@@ -2328,6 +2596,13 @@ class InventoryItemTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
     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]}
@@ -2381,10 +2656,18 @@ class VirtualChassisTestCase(TestCase):
         for region in regions:
         for region in regions:
             region.save()
             region.save()
 
 
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
         sites = (
         sites = (
-            Site(name='Site 1', slug='site-1', region=regions[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
@@ -2435,6 +2718,13 @@ class VirtualChassisTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        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]}
@@ -2582,25 +2872,33 @@ class PowerPanelTestCase(TestCase):
         for region in regions:
         for region in regions:
             region.save()
             region.save()
 
 
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
         sites = (
         sites = (
-            Site(name='Site 1', slug='site-1', region=regions[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
-        rack_groups = (
-            RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
-            RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
-            RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]),
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
         )
         )
-        for rackgroup in rack_groups:
-            rackgroup.save()
+        for location in locations:
+            location.save()
 
 
         power_panels = (
         power_panels = (
-            PowerPanel(name='Power Panel 1', site=sites[0], rack_group=rack_groups[0]),
-            PowerPanel(name='Power Panel 2', site=sites[1], rack_group=rack_groups[1]),
-            PowerPanel(name='Power Panel 3', site=sites[2], rack_group=rack_groups[2]),
+            PowerPanel(name='Power Panel 1', site=sites[0], location=locations[0]),
+            PowerPanel(name='Power Panel 2', site=sites[1], location=locations[1]),
+            PowerPanel(name='Power Panel 3', site=sites[2], location=locations[2]),
         )
         )
         PowerPanel.objects.bulk_create(power_panels)
         PowerPanel.objects.bulk_create(power_panels)
 
 
@@ -2619,6 +2917,13 @@ class PowerPanelTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        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]}
@@ -2626,9 +2931,9 @@ class PowerPanelTestCase(TestCase):
         params = {'site': [sites[0].slug, sites[1].slug]}
         params = {'site': [sites[0].slug, sites[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-    def test_rack_group(self):
-        rack_groups = RackGroup.objects.all()[:2]
-        params = {'rack_group_id': [rack_groups[0].pk, rack_groups[1].pk]}
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
@@ -2647,10 +2952,18 @@ class PowerFeedTestCase(TestCase):
         for region in regions:
         for region in regions:
             region.save()
             region.save()
 
 
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
         sites = (
         sites = (
-            Site(name='Site 1', slug='site-1', region=regions[0]),
-            Site(name='Site 2', slug='site-2', region=regions[1]),
-            Site(name='Site 3', slug='site-3', region=regions[2]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
@@ -2731,6 +3044,13 @@ class PowerFeedTestCase(TestCase):
         params = {'region': [regions[0].slug, regions[1].slug]}
         params = {'region': [regions[0].slug, regions[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        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]}

+ 53 - 26
netbox/dcim/tests/test_models.py

@@ -7,39 +7,66 @@ from dcim.models import *
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 
 
 
 
-class RackGroupTestCase(TestCase):
+class LocationTestCase(TestCase):
 
 
-    def test_change_rackgroup_site(self):
+    def test_change_location_site(self):
         """
         """
-        Check that all child RackGroups and Racks get updated when a RackGroup is moved to a new Site. Topology:
+        Check that all child Locations and Racks get updated when a Location is moved to a new Site. Topology:
         Site A
         Site A
-          - RackGroup A1
-            - RackGroup A2
+          - Location A1
+            - Location A2
               - Rack 2
               - Rack 2
+              - Device 2
             - Rack 1
             - Rack 1
+            - Device 1
         """
         """
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+        )
+        device_role = DeviceRole.objects.create(
+            name='Device Role 1', slug='device-role-1', color='ff0000'
+        )
+
         site_a = Site.objects.create(name='Site A', slug='site-a')
         site_a = Site.objects.create(name='Site A', slug='site-a')
         site_b = Site.objects.create(name='Site B', slug='site-b')
         site_b = Site.objects.create(name='Site B', slug='site-b')
 
 
-        rackgroup_a1 = RackGroup(site=site_a, name='RackGroup A1', slug='rackgroup-a1')
-        rackgroup_a1.save()
-        rackgroup_a2 = RackGroup(site=site_a, parent=rackgroup_a1, name='RackGroup A2', slug='rackgroup-a2')
-        rackgroup_a2.save()
+        location_a1 = Location(site=site_a, name='Location A1', slug='location-a1')
+        location_a1.save()
+        location_a2 = Location(site=site_a, parent=location_a1, name='Location A2', slug='location-a2')
+        location_a2.save()
+
+        rack1 = Rack.objects.create(site=site_a, location=location_a1, name='Rack 1')
+        rack2 = Rack.objects.create(site=site_a, location=location_a2, name='Rack 2')
 
 
-        rack1 = Rack.objects.create(site=site_a, group=rackgroup_a1, name='Rack 1')
-        rack2 = Rack.objects.create(site=site_a, group=rackgroup_a2, name='Rack 2')
+        device1 = Device.objects.create(
+            site=site_a,
+            location=location_a1,
+            name='Device 1',
+            device_type=device_type,
+            device_role=device_role
+        )
+        device2 = Device.objects.create(
+            site=site_a,
+            location=location_a2,
+            name='Device 2',
+            device_type=device_type,
+            device_role=device_role
+        )
 
 
-        powerpanel1 = PowerPanel.objects.create(site=site_a, rack_group=rackgroup_a1, name='Power Panel 1')
+        powerpanel1 = PowerPanel.objects.create(site=site_a, location=location_a1, name='Power Panel 1')
 
 
-        # Move RackGroup A1 to Site B
-        rackgroup_a1.site = site_b
-        rackgroup_a1.save()
+        # Move Location A1 to Site B
+        location_a1.site = site_b
+        location_a1.save()
 
 
-        # Check that all objects within RackGroup A1 now belong to Site B
-        self.assertEqual(RackGroup.objects.get(pk=rackgroup_a1.pk).site, site_b)
-        self.assertEqual(RackGroup.objects.get(pk=rackgroup_a2.pk).site, site_b)
+        # Check that all objects within Location A1 now belong to Site B
+        self.assertEqual(Location.objects.get(pk=location_a1.pk).site, site_b)
+        self.assertEqual(Location.objects.get(pk=location_a2.pk).site, site_b)
         self.assertEqual(Rack.objects.get(pk=rack1.pk).site, site_b)
         self.assertEqual(Rack.objects.get(pk=rack1.pk).site, site_b)
         self.assertEqual(Rack.objects.get(pk=rack2.pk).site, site_b)
         self.assertEqual(Rack.objects.get(pk=rack2.pk).site, site_b)
+        self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b)
+        self.assertEqual(Device.objects.get(pk=device2.pk).site, site_b)
         self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b)
         self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b)
 
 
 
 
@@ -55,12 +82,12 @@ class RackTestCase(TestCase):
             name='TestSite2',
             name='TestSite2',
             slug='test-site-2'
             slug='test-site-2'
         )
         )
-        self.group1 = RackGroup.objects.create(
+        self.location1 = Location.objects.create(
             name='TestGroup1',
             name='TestGroup1',
             slug='test-group-1',
             slug='test-group-1',
             site=self.site1
             site=self.site1
         )
         )
-        self.group2 = RackGroup.objects.create(
+        self.location2 = Location.objects.create(
             name='TestGroup2',
             name='TestGroup2',
             slug='test-group-2',
             slug='test-group-2',
             site=self.site2
             site=self.site2
@@ -69,7 +96,7 @@ class RackTestCase(TestCase):
             name='TestRack1',
             name='TestRack1',
             facility_id='A101',
             facility_id='A101',
             site=self.site1,
             site=self.site1,
-            group=self.group1,
+            location=self.location1,
             u_height=42
             u_height=42
         )
         )
         self.manufacturer = Manufacturer.objects.create(
         self.manufacturer = Manufacturer.objects.create(
@@ -134,19 +161,19 @@ class RackTestCase(TestCase):
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
             rack1.clean()
             rack1.clean()
 
 
-    def test_rack_group_site(self):
+    def test_location_site(self):
 
 
-        rack_invalid_group = Rack(
+        rack_invalid_location = Rack(
             name='TestRack2',
             name='TestRack2',
             facility_id='A102',
             facility_id='A102',
             site=self.site1,
             site=self.site1,
             u_height=42,
             u_height=42,
-            group=self.group2
+            location=self.location2
         )
         )
-        rack_invalid_group.save()
+        rack_invalid_location.save()
 
 
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
-            rack_invalid_group.clean()
+            rack_invalid_location.clean()
 
 
     def test_mount_single_device(self):
     def test_mount_single_device(self):
 
 

+ 125 - 55
netbox/dcim/tests/test_views.py

@@ -57,6 +57,44 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Region 6,region-6,Sixth region",
             "Region 6,region-6,Sixth region",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
+
+class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+    model = SiteGroup
+
+    @classmethod
+    def setUpTestData(cls):
+
+        # Create three SiteGroups
+        sitegroups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for sitegroup in sitegroups:
+            sitegroup.save()
+
+        cls.form_data = {
+            'name': 'Site Group X',
+            'slug': 'site-group-x',
+            'parent': sitegroups[2].pk,
+            'description': 'A new site group',
+        }
+
+        cls.csv_data = (
+            "name,slug,description",
+            "Site Group 4,site-group-4,Fourth site group",
+            "Site Group 5,site-group-5,Fifth site group",
+            "Site Group 6,site-group-6,Sixth site group",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 
 class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Site
     model = Site
@@ -71,10 +109,17 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         for region in regions:
         for region in regions:
             region.save()
             region.save()
 
 
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+        )
+        for group in groups:
+            group.save()
+
         Site.objects.bulk_create([
         Site.objects.bulk_create([
-            Site(name='Site 1', slug='site-1', region=regions[0]),
-            Site(name='Site 2', slug='site-2', region=regions[0]),
-            Site(name='Site 3', slug='site-3', region=regions[0]),
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[1]),
+            Site(name='Site 2', slug='site-2', region=regions[0], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[0], group=groups[1]),
         ])
         ])
 
 
         tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
         tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
@@ -84,6 +129,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'slug': 'site-x',
             'slug': 'site-x',
             'status': SiteStatusChoices.STATUS_PLANNED,
             'status': SiteStatusChoices.STATUS_PLANNED,
             'region': regions[1].pk,
             'region': regions[1].pk,
+            'group': groups[1].pk,
             'tenant': None,
             'tenant': None,
             'facility': 'Facility X',
             'facility': 'Facility X',
             'asn': 65001,
             'asn': 65001,
@@ -110,6 +156,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'status': SiteStatusChoices.STATUS_PLANNED,
             'status': SiteStatusChoices.STATUS_PLANNED,
             'region': regions[1].pk,
             'region': regions[1].pk,
+            'group': groups[1].pk,
             'tenant': None,
             'tenant': None,
             'asn': 65009,
             'asn': 65009,
             'time_zone': pytz.timezone('US/Eastern'),
             'time_zone': pytz.timezone('US/Eastern'),
@@ -117,8 +164,8 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
 
 
-class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
-    model = RackGroup
+class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+    model = Location
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -126,28 +173,32 @@ class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
 
 
-        rack_groups = (
-            RackGroup(name='Rack Group 1', slug='rack-group-1', site=site),
-            RackGroup(name='Rack Group 2', slug='rack-group-2', site=site),
-            RackGroup(name='Rack Group 3', slug='rack-group-3', site=site),
+        locations = (
+            Location(name='Location 1', slug='location-1', site=site),
+            Location(name='Location 2', slug='location-2', site=site),
+            Location(name='Location 3', slug='location-3', site=site),
         )
         )
-        for rackgroup in rack_groups:
-            rackgroup.save()
+        for location in locations:
+            location.save()
 
 
         cls.form_data = {
         cls.form_data = {
-            'name': 'Rack Group X',
-            'slug': 'rack-group-x',
+            'name': 'Location X',
+            'slug': 'location-x',
             'site': site.pk,
             'site': site.pk,
-            'description': 'A new rack group',
+            'description': 'A new location',
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
             "site,name,slug,description",
             "site,name,slug,description",
-            "Site 1,Rack Group 4,rack-group-4,Fourth rack group",
-            "Site 1,Rack Group 5,rack-group-5,Fifth rack group",
-            "Site 1,Rack Group 6,rack-group-6,Sixth rack group",
+            "Site 1,Location 4,location-4,Fourth location",
+            "Site 1,Location 5,location-5,Fifth location",
+            "Site 1,Location 6,location-6,Sixth location",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 
 class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = RackRole
     model = RackRole
@@ -175,6 +226,11 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Rack Role 6,rack-role-6,0000ff",
             "Rack Role 6,rack-role-6,0000ff",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'color': '00ff00',
+            'description': 'New description',
+        }
+
 
 
 class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = RackReservation
     model = RackReservation
@@ -187,10 +243,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
         site = Site.objects.create(name='Site 1', slug='site-1')
         site = Site.objects.create(name='Site 1', slug='site-1')
 
 
-        rack_group = RackGroup(name='Rack Group 1', slug='rack-group-1', site=site)
-        rack_group.save()
+        location = Location(name='Location 1', slug='location-1', site=site)
+        location.save()
 
 
-        rack = Rack(name='Rack 1', site=site, group=rack_group)
+        rack = Rack(name='Rack 1', site=site, location=location)
         rack.save()
         rack.save()
 
 
         RackReservation.objects.bulk_create([
         RackReservation.objects.bulk_create([
@@ -211,10 +267,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
-            'site,rack_group,rack,units,description',
-            'Site 1,Rack Group 1,Rack 1,"10,11,12",Reservation 1',
-            'Site 1,Rack Group 1,Rack 1,"13,14,15",Reservation 2',
-            'Site 1,Rack Group 1,Rack 1,"16,17,18",Reservation 3',
+            'site,location,rack,units,description',
+            'Site 1,Location 1,Rack 1,"10,11,12",Reservation 1',
+            'Site 1,Location 1,Rack 1,"13,14,15",Reservation 2',
+            'Site 1,Location 1,Rack 1,"16,17,18",Reservation 3',
         )
         )
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
@@ -236,12 +292,12 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
-        rackgroups = (
-            RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
-            RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1])
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1])
         )
         )
-        for rackgroup in rackgroups:
-            rackgroup.save()
+        for location in locations:
+            location.save()
 
 
         rackroles = (
         rackroles = (
             RackRole(name='Rack Role 1', slug='rack-role-1'),
             RackRole(name='Rack Role 1', slug='rack-role-1'),
@@ -261,7 +317,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'name': 'Rack X',
             'name': 'Rack X',
             'facility_id': 'Facility X',
             'facility_id': 'Facility X',
             'site': sites[1].pk,
             'site': sites[1].pk,
-            'group': rackgroups[1].pk,
+            'location': locations[1].pk,
             'tenant': None,
             'tenant': None,
             'status': RackStatusChoices.STATUS_PLANNED,
             'status': RackStatusChoices.STATUS_PLANNED,
             'role': rackroles[1].pk,
             'role': rackroles[1].pk,
@@ -279,15 +335,15 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
-            "site,group,name,width,u_height",
+            "site,location,name,width,u_height",
             "Site 1,,Rack 4,19,42",
             "Site 1,,Rack 4,19,42",
-            "Site 1,Rack Group 1,Rack 5,19,42",
-            "Site 2,Rack Group 2,Rack 6,19,42",
+            "Site 1,Location 1,Rack 5,19,42",
+            "Site 2,Location 2,Rack 6,19,42",
         )
         )
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'site': sites[1].pk,
             'site': sites[1].pk,
-            'group': rackgroups[1].pk,
+            'location': locations[1].pk,
             'tenant': None,
             'tenant': None,
             'status': RackStatusChoices.STATUS_DEPRECATED,
             'status': RackStatusChoices.STATUS_DEPRECATED,
             'role': rackroles[1].pk,
             'role': rackroles[1].pk,
@@ -336,6 +392,10 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Manufacturer 6,manufacturer-6,Sixth manufacturer",
             "Manufacturer 6,manufacturer-6,Sixth manufacturer",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
 
 
 # TODO: Change base class to PrimaryObjectViewTestCase
 # TODO: Change base class to PrimaryObjectViewTestCase
 # Blocked by absence of bulk import view for DeviceTypes
 # Blocked by absence of bulk import view for DeviceTypes
@@ -885,6 +945,11 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Device Role 6,device-role-6,0000ff",
             "Device Role 6,device-role-6,0000ff",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'color': '00ff00',
+            'description': 'New description',
+        }
+
 
 
 class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = Platform
     model = Platform
@@ -916,6 +981,11 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             "Platform 6,platform-6,Sixth platform",
             "Platform 6,platform-6,Sixth platform",
         )
         )
 
 
+        cls.bulk_edit_data = {
+            'napalm_driver': 'ios',
+            'description': 'New description',
+        }
+
 
 
 class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Device
     model = Device
@@ -929,11 +999,11 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
-        rack_group = RackGroup(site=sites[0], name='Rack Group 1', slug='rack-group-1')
-        rack_group.save()
+        location = Location(site=sites[0], name='Location 1', slug='location-1')
+        location.save()
 
 
         racks = (
         racks = (
-            Rack(name='Rack 1', site=sites[0], group=rack_group),
+            Rack(name='Rack 1', site=sites[0], location=location),
             Rack(name='Rack 2', site=sites[1]),
             Rack(name='Rack 2', site=sites[1]),
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
@@ -991,10 +1061,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
-            "device_role,manufacturer,device_type,status,name,site,rack_group,rack,position,face",
-            "Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Rack Group 1,Rack 1,10,front",
-            "Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Rack Group 1,Rack 1,20,front",
-            "Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Rack Group 1,Rack 1,30,front",
+            "device_role,manufacturer,device_type,status,name,site,location,rack,position,face",
+            "Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Location 1,Rack 1,10,front",
+            "Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Location 1,Rack 1,20,front",
+            "Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front",
         )
         )
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
@@ -1771,38 +1841,38 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
-        rackgroups = (
-            RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
-            RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
         )
         )
-        for rackgroup in rackgroups:
-            rackgroup.save()
+        for location in locations:
+            location.save()
 
 
         PowerPanel.objects.bulk_create((
         PowerPanel.objects.bulk_create((
-            PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 1'),
-            PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 2'),
-            PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 3'),
+            PowerPanel(site=sites[0], location=locations[0], name='Power Panel 1'),
+            PowerPanel(site=sites[0], location=locations[0], name='Power Panel 2'),
+            PowerPanel(site=sites[0], location=locations[0], name='Power Panel 3'),
         ))
         ))
 
 
         tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
         tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'site': sites[1].pk,
             'site': sites[1].pk,
-            'rack_group': rackgroups[1].pk,
+            'location': locations[1].pk,
             'name': 'Power Panel X',
             'name': 'Power Panel X',
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
-            "site,rack_group,name",
-            "Site 1,Rack Group 1,Power Panel 4",
-            "Site 1,Rack Group 1,Power Panel 5",
-            "Site 1,Rack Group 1,Power Panel 6",
+            "site,location,name",
+            "Site 1,Location 1,Power Panel 4",
+            "Site 1,Location 1,Power Panel 5",
+            "Site 1,Location 1,Power Panel 6",
         )
         )
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'site': sites[1].pk,
             'site': sites[1].pk,
-            'rack_group': rackgroups[1].pk,
+            'location': locations[1].pk,
         }
         }
 
 
 
 

+ 35 - 14
netbox/dcim/urls.py

@@ -1,13 +1,9 @@
 from django.urls import path
 from django.urls import path
 
 
-from extras.views import ObjectChangeLogView, ImageAttachmentEditView
+from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView
 from ipam.views import ServiceEditView
 from ipam.views import ServiceEditView
 from . import views
 from . import views
-from .models import (
-    Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, FrontPort, Interface,
-    InventoryItem, Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup,
-    RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
-)
+from .models import *
 
 
 app_name = 'dcim'
 app_name = 'dcim'
 urlpatterns = [
 urlpatterns = [
@@ -16,11 +12,22 @@ urlpatterns = [
     path('regions/', views.RegionListView.as_view(), name='region_list'),
     path('regions/', views.RegionListView.as_view(), name='region_list'),
     path('regions/add/', views.RegionEditView.as_view(), name='region_add'),
     path('regions/add/', views.RegionEditView.as_view(), name='region_add'),
     path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
     path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
+    path('regions/edit/', views.RegionBulkEditView.as_view(), name='region_bulk_edit'),
     path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
     path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
     path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
     path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
     path('regions/<int:pk>/delete/', views.RegionDeleteView.as_view(), name='region_delete'),
     path('regions/<int:pk>/delete/', views.RegionDeleteView.as_view(), name='region_delete'),
     path('regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
     path('regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
 
 
+    # Site groups
+    path('site-groups/', views.SiteGroupListView.as_view(), name='sitegroup_list'),
+    path('site-groups/add/', views.SiteGroupEditView.as_view(), name='sitegroup_add'),
+    path('site-groups/import/', views.SiteGroupBulkImportView.as_view(), name='sitegroup_import'),
+    path('site-groups/edit/', views.SiteGroupBulkEditView.as_view(), name='sitegroup_bulk_edit'),
+    path('site-groups/delete/', views.SiteGroupBulkDeleteView.as_view(), name='sitegroup_bulk_delete'),
+    path('site-groups/<int:pk>/edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'),
+    path('site-groups/<int:pk>/delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'),
+    path('site-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='sitegroup_changelog', kwargs={'model': SiteGroup}),
+
     # Sites
     # Sites
     path('sites/', views.SiteListView.as_view(), name='site_list'),
     path('sites/', views.SiteListView.as_view(), name='site_list'),
     path('sites/add/', views.SiteEditView.as_view(), name='site_add'),
     path('sites/add/', views.SiteEditView.as_view(), name='site_add'),
@@ -31,21 +38,24 @@ urlpatterns = [
     path('sites/<int:pk>/edit/', views.SiteEditView.as_view(), name='site_edit'),
     path('sites/<int:pk>/edit/', views.SiteEditView.as_view(), name='site_edit'),
     path('sites/<int:pk>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
     path('sites/<int:pk>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
     path('sites/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
     path('sites/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
+    path('sites/<int:pk>/journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}),
     path('sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
     path('sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
 
 
-    # Rack groups
-    path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'),
-    path('rack-groups/add/', views.RackGroupEditView.as_view(), name='rackgroup_add'),
-    path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
-    path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
-    path('rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
-    path('rack-groups/<int:pk>/delete/', views.RackGroupDeleteView.as_view(), name='rackgroup_delete'),
-    path('rack-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
+    # Locations
+    path('locations/', views.LocationListView.as_view(), name='location_list'),
+    path('locations/add/', views.LocationEditView.as_view(), name='location_add'),
+    path('locations/import/', views.LocationBulkImportView.as_view(), name='location_import'),
+    path('locations/edit/', views.LocationBulkEditView.as_view(), name='location_bulk_edit'),
+    path('locations/delete/', views.LocationBulkDeleteView.as_view(), name='location_bulk_delete'),
+    path('locations/<int:pk>/edit/', views.LocationEditView.as_view(), name='location_edit'),
+    path('locations/<int:pk>/delete/', views.LocationDeleteView.as_view(), name='location_delete'),
+    path('locations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}),
 
 
     # Rack roles
     # Rack roles
     path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
     path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
     path('rack-roles/add/', views.RackRoleEditView.as_view(), name='rackrole_add'),
     path('rack-roles/add/', views.RackRoleEditView.as_view(), name='rackrole_add'),
     path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
     path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
+    path('rack-roles/edit/', views.RackRoleBulkEditView.as_view(), name='rackrole_bulk_edit'),
     path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
     path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
     path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
     path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
     path('rack-roles/<int:pk>/delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'),
     path('rack-roles/<int:pk>/delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'),
@@ -61,6 +71,7 @@ urlpatterns = [
     path('rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
     path('rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
     path('rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
     path('rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
     path('rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
     path('rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
+    path('rack-reservations/<int:pk>/journal/', ObjectJournalView.as_view(), name='rackreservation_journal', kwargs={'model': RackReservation}),
 
 
     # Racks
     # Racks
     path('racks/', views.RackListView.as_view(), name='rack_list'),
     path('racks/', views.RackListView.as_view(), name='rack_list'),
@@ -73,12 +84,14 @@ urlpatterns = [
     path('racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
     path('racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
     path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
     path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
     path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
     path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
+    path('racks/<int:pk>/journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}),
     path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
     path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
 
 
     # Manufacturers
     # Manufacturers
     path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
     path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
     path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'),
     path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'),
     path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
     path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
+    path('manufacturers/edit/', views.ManufacturerBulkEditView.as_view(), name='manufacturer_bulk_edit'),
     path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
     path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
     path('manufacturers/<int:pk>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
     path('manufacturers/<int:pk>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
     path('manufacturers/<int:pk>/delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'),
     path('manufacturers/<int:pk>/delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'),
@@ -94,6 +107,7 @@ urlpatterns = [
     path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
     path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
     path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
     path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
     path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
     path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
+    path('device-types/<int:pk>/journal/', ObjectJournalView.as_view(), name='devicetype_journal', kwargs={'model': DeviceType}),
 
 
     # Console port templates
     # Console port templates
     path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'),
     path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'),
@@ -163,6 +177,7 @@ urlpatterns = [
     path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
     path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
     path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'),
     path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'),
     path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
     path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
+    path('device-roles/edit/', views.DeviceRoleBulkEditView.as_view(), name='devicerole_bulk_edit'),
     path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
     path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
     path('device-roles/<int:pk>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
     path('device-roles/<int:pk>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
     path('device-roles/<int:pk>/delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'),
     path('device-roles/<int:pk>/delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'),
@@ -172,6 +187,7 @@ urlpatterns = [
     path('platforms/', views.PlatformListView.as_view(), name='platform_list'),
     path('platforms/', views.PlatformListView.as_view(), name='platform_list'),
     path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'),
     path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'),
     path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
     path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
+    path('platforms/edit/', views.PlatformBulkEditView.as_view(), name='platform_bulk_edit'),
     path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
     path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
     path('platforms/<int:pk>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
     path('platforms/<int:pk>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
     path('platforms/<int:pk>/delete/', views.PlatformDeleteView.as_view(), name='platform_delete'),
     path('platforms/<int:pk>/delete/', views.PlatformDeleteView.as_view(), name='platform_delete'),
@@ -198,6 +214,7 @@ urlpatterns = [
     path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
     path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
     path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
     path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
     path('devices/<int:pk>/changelog/', views.DeviceChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
     path('devices/<int:pk>/changelog/', views.DeviceChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
+    path('devices/<int:pk>/journal/', views.DeviceJournalView.as_view(), name='device_journal', kwargs={'model': Device}),
     path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
     path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
     path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
     path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
@@ -353,6 +370,7 @@ urlpatterns = [
     path('cables/<int:pk>/edit/', views.CableEditView.as_view(), name='cable_edit'),
     path('cables/<int:pk>/edit/', views.CableEditView.as_view(), name='cable_edit'),
     path('cables/<int:pk>/delete/', views.CableDeleteView.as_view(), name='cable_delete'),
     path('cables/<int:pk>/delete/', views.CableDeleteView.as_view(), name='cable_delete'),
     path('cables/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
     path('cables/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
+    path('cables/<int:pk>/journal/', ObjectJournalView.as_view(), name='cable_journal', kwargs={'model': Cable}),
 
 
     # Console/power/interface connections (read-only)
     # Console/power/interface connections (read-only)
     path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
     path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
@@ -369,6 +387,7 @@ urlpatterns = [
     path('virtual-chassis/<int:pk>/edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
     path('virtual-chassis/<int:pk>/edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
     path('virtual-chassis/<int:pk>/delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
     path('virtual-chassis/<int:pk>/delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
     path('virtual-chassis/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
     path('virtual-chassis/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
+    path('virtual-chassis/<int:pk>/journal/', ObjectJournalView.as_view(), name='virtualchassis_journal', kwargs={'model': VirtualChassis}),
     path('virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
     path('virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
     path('virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
     path('virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
 
 
@@ -382,6 +401,7 @@ urlpatterns = [
     path('power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
     path('power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
     path('power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
     path('power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
     path('power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
     path('power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
+    path('power-panels/<int:pk>/journal/', ObjectJournalView.as_view(), name='powerpanel_journal', kwargs={'model': PowerPanel}),
 
 
     # Power feeds
     # Power feeds
     path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
     path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
@@ -394,6 +414,7 @@ urlpatterns = [
     path('power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
     path('power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
     path('power-feeds/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}),
     path('power-feeds/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}),
     path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
     path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
+    path('power-feeds/<int:pk>/journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}),
     path('power-feeds/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}),
     path('power-feeds/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}),
 
 
 ]
 ]

+ 164 - 43
netbox/dcim/views.py

@@ -12,7 +12,7 @@ from django.utils.safestring import mark_safe
 from django.views.generic import View
 from django.views.generic import View
 
 
 from circuits.models import Circuit
 from circuits.models import Circuit
-from extras.views import ObjectChangeLogView, ObjectConfigContextView
+from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView
 from ipam.models import IPAddress, Prefix, Service, VLAN
 from ipam.models import IPAddress, Prefix, Service, VLAN
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
 from netbox.views import generic
 from netbox.views import generic
@@ -30,8 +30,8 @@ from .models import (
     Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
     InventoryItem, Manufacturer, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel,
     InventoryItem, Manufacturer, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel,
-    PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
-    VirtualChassis,
+    PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
+    SiteGroup, VirtualChassis,
 )
 )
 
 
 
 
@@ -126,6 +126,19 @@ class RegionBulkImportView(generic.BulkImportView):
     table = tables.RegionTable
     table = tables.RegionTable
 
 
 
 
+class RegionBulkEditView(generic.BulkEditView):
+    queryset = Region.objects.add_related_count(
+        Region.objects.all(),
+        Site,
+        'region',
+        'site_count',
+        cumulative=True
+    )
+    filterset = filters.RegionFilterSet
+    table = tables.RegionTable
+    form = forms.RegionBulkEditForm
+
+
 class RegionBulkDeleteView(generic.BulkDeleteView):
 class RegionBulkDeleteView(generic.BulkDeleteView):
     queryset = Region.objects.add_related_count(
     queryset = Region.objects.add_related_count(
         Region.objects.all(),
         Region.objects.all(),
@@ -138,6 +151,63 @@ class RegionBulkDeleteView(generic.BulkDeleteView):
     table = tables.RegionTable
     table = tables.RegionTable
 
 
 
 
+#
+# Site groups
+#
+
+class SiteGroupListView(generic.ObjectListView):
+    queryset = SiteGroup.objects.add_related_count(
+        SiteGroup.objects.all(),
+        Site,
+        'group',
+        'site_count',
+        cumulative=True
+    )
+    filterset = filters.SiteGroupFilterSet
+    filterset_form = forms.SiteGroupFilterForm
+    table = tables.SiteGroupTable
+
+
+class SiteGroupEditView(generic.ObjectEditView):
+    queryset = SiteGroup.objects.all()
+    model_form = forms.SiteGroupForm
+
+
+class SiteGroupDeleteView(generic.ObjectDeleteView):
+    queryset = SiteGroup.objects.all()
+
+
+class SiteGroupBulkImportView(generic.BulkImportView):
+    queryset = SiteGroup.objects.all()
+    model_form = forms.SiteGroupCSVForm
+    table = tables.SiteGroupTable
+
+
+class SiteGroupBulkEditView(generic.BulkEditView):
+    queryset = SiteGroup.objects.add_related_count(
+        SiteGroup.objects.all(),
+        Site,
+        'group',
+        'site_count',
+        cumulative=True
+    )
+    filterset = filters.SiteGroupFilterSet
+    table = tables.SiteGroupTable
+    form = forms.SiteGroupBulkEditForm
+
+
+class SiteGroupBulkDeleteView(generic.BulkDeleteView):
+    queryset = SiteGroup.objects.add_related_count(
+        SiteGroup.objects.all(),
+        Site,
+        'group',
+        'site_count',
+        cumulative=True
+    )
+    filterset = filters.SiteGroupFilterSet
+    table = tables.SiteGroupTable
+
+
 #
 #
 # Sites
 # Sites
 #
 #
@@ -161,17 +231,17 @@ class SiteView(generic.ObjectView):
             'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).count(),
             'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).count(),
             'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(),
             'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(),
         }
         }
-        rack_groups = RackGroup.objects.add_related_count(
-            RackGroup.objects.all(),
+        locations = Location.objects.add_related_count(
+            Location.objects.all(),
             Rack,
             Rack,
-            'group',
+            'location',
             'rack_count',
             'rack_count',
             cumulative=True
             cumulative=True
         ).restrict(request.user, 'view').filter(site=instance)
         ).restrict(request.user, 'view').filter(site=instance)
 
 
         return {
         return {
             'stats': stats,
             'stats': stats,
-            'rack_groups': rack_groups,
+            'locations': locations,
         }
         }
 
 
 
 
@@ -207,44 +277,57 @@ class SiteBulkDeleteView(generic.BulkDeleteView):
 # Rack groups
 # Rack groups
 #
 #
 
 
-class RackGroupListView(generic.ObjectListView):
-    queryset = RackGroup.objects.add_related_count(
-        RackGroup.objects.all(),
+class LocationListView(generic.ObjectListView):
+    queryset = Location.objects.add_related_count(
+        Location.objects.all(),
         Rack,
         Rack,
-        'group',
+        'location',
         'rack_count',
         'rack_count',
         cumulative=True
         cumulative=True
     )
     )
-    filterset = filters.RackGroupFilterSet
-    filterset_form = forms.RackGroupFilterForm
-    table = tables.RackGroupTable
+    filterset = filters.LocationFilterSet
+    filterset_form = forms.LocationFilterForm
+    table = tables.LocationTable
 
 
 
 
-class RackGroupEditView(generic.ObjectEditView):
-    queryset = RackGroup.objects.all()
-    model_form = forms.RackGroupForm
+class LocationEditView(generic.ObjectEditView):
+    queryset = Location.objects.all()
+    model_form = forms.LocationForm
 
 
 
 
-class RackGroupDeleteView(generic.ObjectDeleteView):
-    queryset = RackGroup.objects.all()
+class LocationDeleteView(generic.ObjectDeleteView):
+    queryset = Location.objects.all()
 
 
 
 
-class RackGroupBulkImportView(generic.BulkImportView):
-    queryset = RackGroup.objects.all()
-    model_form = forms.RackGroupCSVForm
-    table = tables.RackGroupTable
+class LocationBulkImportView(generic.BulkImportView):
+    queryset = Location.objects.all()
+    model_form = forms.LocationCSVForm
+    table = tables.LocationTable
 
 
 
 
-class RackGroupBulkDeleteView(generic.BulkDeleteView):
-    queryset = RackGroup.objects.add_related_count(
-        RackGroup.objects.all(),
+class LocationBulkEditView(generic.BulkEditView):
+    queryset = Location.objects.add_related_count(
+        Location.objects.all(),
         Rack,
         Rack,
-        'group',
+        'location',
+        'rack_count',
+        cumulative=True
+    ).prefetch_related('site')
+    filterset = filters.LocationFilterSet
+    table = tables.LocationTable
+    form = forms.LocationBulkEditForm
+
+
+class LocationBulkDeleteView(generic.BulkDeleteView):
+    queryset = Location.objects.add_related_count(
+        Location.objects.all(),
+        Rack,
+        'location',
         'rack_count',
         'rack_count',
         cumulative=True
         cumulative=True
     ).prefetch_related('site')
     ).prefetch_related('site')
-    filterset = filters.RackGroupFilterSet
-    table = tables.RackGroupTable
+    filterset = filters.LocationFilterSet
+    table = tables.LocationTable
 
 
 
 
 #
 #
@@ -273,6 +356,15 @@ class RackRoleBulkImportView(generic.BulkImportView):
     table = tables.RackRoleTable
     table = tables.RackRoleTable
 
 
 
 
+class RackRoleBulkEditView(generic.BulkEditView):
+    queryset = RackRole.objects.annotate(
+        rack_count=count_related(Rack, 'role')
+    )
+    filterset = filters.RackRoleFilterSet
+    table = tables.RackRoleTable
+    form = forms.RackRoleBulkEditForm
+
+
 class RackRoleBulkDeleteView(generic.BulkDeleteView):
 class RackRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = RackRole.objects.annotate(
     queryset = RackRole.objects.annotate(
         rack_count=count_related(Rack, 'role')
         rack_count=count_related(Rack, 'role')
@@ -286,7 +378,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
 
 
 class RackListView(generic.ObjectListView):
 class RackListView(generic.ObjectListView):
     queryset = Rack.objects.prefetch_related(
     queryset = Rack.objects.prefetch_related(
-        'site', 'group', 'tenant', 'role', 'devices__device_type'
+        'site', 'location', 'tenant', 'role', 'devices__device_type'
     ).annotate(
     ).annotate(
         device_count=count_related(Device, 'rack')
         device_count=count_related(Device, 'rack')
     )
     )
@@ -338,7 +430,7 @@ class RackElevationListView(generic.ObjectListView):
 
 
 
 
 class RackView(generic.ObjectView):
 class RackView(generic.ObjectView):
-    queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role')
+    queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role')
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         # Get 0U and child devices located within the rack
         # Get 0U and child devices located within the rack
@@ -349,10 +441,10 @@ class RackView(generic.ObjectView):
 
 
         peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
         peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
 
 
-        if instance.group:
-            peer_racks = peer_racks.filter(group=instance.group)
+        if instance.location:
+            peer_racks = peer_racks.filter(location=instance.location)
         else:
         else:
-            peer_racks = peer_racks.filter(group__isnull=True)
+            peer_racks = peer_racks.filter(location__isnull=True)
         next_rack = peer_racks.filter(name__gt=instance.name).order_by('name').first()
         next_rack = peer_racks.filter(name__gt=instance.name).order_by('name').first()
         prev_rack = peer_racks.filter(name__lt=instance.name).order_by('-name').first()
         prev_rack = peer_racks.filter(name__lt=instance.name).order_by('-name').first()
 
 
@@ -390,14 +482,14 @@ class RackBulkImportView(generic.BulkImportView):
 
 
 
 
 class RackBulkEditView(generic.BulkEditView):
 class RackBulkEditView(generic.BulkEditView):
-    queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role')
+    queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
     filterset = filters.RackFilterSet
     filterset = filters.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', 'group', 'tenant', 'role')
+    queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
     filterset = filters.RackFilterSet
     filterset = filters.RackFilterSet
     table = tables.RackTable
     table = tables.RackTable
 
 
@@ -490,6 +582,15 @@ class ManufacturerBulkImportView(generic.BulkImportView):
     table = tables.ManufacturerTable
     table = tables.ManufacturerTable
 
 
 
 
+class ManufacturerBulkEditView(generic.BulkEditView):
+    queryset = Manufacturer.objects.annotate(
+        devicetype_count=count_related(DeviceType, 'manufacturer')
+    )
+    filterset = filters.ManufacturerFilterSet
+    table = tables.ManufacturerTable
+    form = forms.ManufacturerBulkEditForm
+
+
 class ManufacturerBulkDeleteView(generic.BulkDeleteView):
 class ManufacturerBulkDeleteView(generic.BulkDeleteView):
     queryset = Manufacturer.objects.annotate(
     queryset = Manufacturer.objects.annotate(
         devicetype_count=count_related(DeviceType, 'manufacturer')
         devicetype_count=count_related(DeviceType, 'manufacturer')
@@ -931,6 +1032,13 @@ class DeviceRoleBulkImportView(generic.BulkImportView):
     table = tables.DeviceRoleTable
     table = tables.DeviceRoleTable
 
 
 
 
+class DeviceRoleBulkEditView(generic.BulkEditView):
+    queryset = DeviceRole.objects.all()
+    filterset = filters.DeviceRoleFilterSet
+    table = tables.DeviceRoleTable
+    form = forms.DeviceRoleBulkEditForm
+
+
 class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
 class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = DeviceRole.objects.all()
     queryset = DeviceRole.objects.all()
     table = tables.DeviceRoleTable
     table = tables.DeviceRoleTable
@@ -963,6 +1071,13 @@ class PlatformBulkImportView(generic.BulkImportView):
     table = tables.PlatformTable
     table = tables.PlatformTable
 
 
 
 
+class PlatformBulkEditView(generic.BulkEditView):
+    queryset = Platform.objects.all()
+    filterset = filters.PlatformFilterSet
+    table = tables.PlatformTable
+    form = forms.PlatformBulkEditForm
+
+
 class PlatformBulkDeleteView(generic.BulkDeleteView):
 class PlatformBulkDeleteView(generic.BulkDeleteView):
     queryset = Platform.objects.all()
     queryset = Platform.objects.all()
     table = tables.PlatformTable
     table = tables.PlatformTable
@@ -982,7 +1097,7 @@ class DeviceListView(generic.ObjectListView):
 
 
 class DeviceView(generic.ObjectView):
 class DeviceView(generic.ObjectView):
     queryset = Device.objects.prefetch_related(
     queryset = Device.objects.prefetch_related(
-        'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6'
+        'site__region', 'location', 'rack', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6'
     )
     )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
@@ -1268,6 +1383,10 @@ class DeviceChangeLogView(ObjectChangeLogView):
     base_template = 'dcim/device/base.html'
     base_template = 'dcim/device/base.html'
 
 
 
 
+class DeviceJournalView(ObjectJournalView):
+    base_template = 'dcim/device/base.html'
+
+
 class DeviceEditView(generic.ObjectEditView):
 class DeviceEditView(generic.ObjectEditView):
     queryset = Device.objects.all()
     queryset = Device.objects.all()
     model_form = forms.DeviceForm
     model_form = forms.DeviceForm
@@ -2178,13 +2297,15 @@ class CableCreateView(generic.ObjectEditView):
         initial_data = {k: request.GET[k] for k in request.GET}
         initial_data = {k: request.GET[k] for k in request.GET}
 
 
         # Set initial site and rack based on side A termination (if not already set)
         # Set initial site and rack based on side A termination (if not already set)
-        termination_a_site = getattr(obj.termination_a.parent, 'site', None)
+        termination_a_site = getattr(obj.termination_a.parent_object, 'site', None)
         if termination_a_site and 'termination_b_region' not in initial_data:
         if termination_a_site and 'termination_b_region' not in initial_data:
             initial_data['termination_b_region'] = termination_a_site.region
             initial_data['termination_b_region'] = termination_a_site.region
+        if termination_a_site and 'termination_b_site_group' not in initial_data:
+            initial_data['termination_b_site_group'] = termination_a_site.group
         if 'termination_b_site' not in initial_data:
         if 'termination_b_site' not in initial_data:
             initial_data['termination_b_site'] = termination_a_site
             initial_data['termination_b_site'] = termination_a_site
         if 'termination_b_rack' not in initial_data:
         if 'termination_b_rack' not in initial_data:
-            initial_data['termination_b_rack'] = getattr(obj.termination_a.parent, 'rack', None)
+            initial_data['termination_b_rack'] = getattr(obj.termination_a.parent_object, 'rack', None)
 
 
         form = self.model_form(instance=obj, initial=initial_data)
         form = self.model_form(instance=obj, initial=initial_data)
 
 
@@ -2560,7 +2681,7 @@ class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
 
 
 class PowerPanelListView(generic.ObjectListView):
 class PowerPanelListView(generic.ObjectListView):
     queryset = PowerPanel.objects.prefetch_related(
     queryset = PowerPanel.objects.prefetch_related(
-        'site', 'rack_group'
+        'site', 'location'
     ).annotate(
     ).annotate(
         powerfeed_count=count_related(PowerFeed, 'power_panel')
         powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
     )
@@ -2570,7 +2691,7 @@ class PowerPanelListView(generic.ObjectListView):
 
 
 
 
 class PowerPanelView(generic.ObjectView):
 class PowerPanelView(generic.ObjectView):
-    queryset = PowerPanel.objects.prefetch_related('site', 'rack_group')
+    queryset = PowerPanel.objects.prefetch_related('site', 'location')
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=instance).prefetch_related('rack')
         power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=instance).prefetch_related('rack')
@@ -2601,7 +2722,7 @@ class PowerPanelBulkImportView(generic.BulkImportView):
 
 
 
 
 class PowerPanelBulkEditView(generic.BulkEditView):
 class PowerPanelBulkEditView(generic.BulkEditView):
-    queryset = PowerPanel.objects.prefetch_related('site', 'rack_group')
+    queryset = PowerPanel.objects.prefetch_related('site', 'location')
     filterset = filters.PowerPanelFilterSet
     filterset = filters.PowerPanelFilterSet
     table = tables.PowerPanelTable
     table = tables.PowerPanelTable
     form = forms.PowerPanelBulkEditForm
     form = forms.PowerPanelBulkEditForm
@@ -2609,7 +2730,7 @@ class PowerPanelBulkEditView(generic.BulkEditView):
 
 
 class PowerPanelBulkDeleteView(generic.BulkDeleteView):
 class PowerPanelBulkDeleteView(generic.BulkDeleteView):
     queryset = PowerPanel.objects.prefetch_related(
     queryset = PowerPanel.objects.prefetch_related(
-        'site', 'rack_group'
+        'site', 'location'
     ).annotate(
     ).annotate(
         powerfeed_count=count_related(PowerFeed, 'power_panel')
         powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
     )

+ 6 - 6
netbox/extras/admin.py

@@ -132,15 +132,15 @@ class CustomLinkForm(forms.ModelForm):
         model = CustomLink
         model = CustomLink
         exclude = []
         exclude = []
         widgets = {
         widgets = {
-            'text': forms.Textarea,
-            'url': forms.Textarea,
+            'link_text': forms.Textarea,
+            'link_url': forms.Textarea,
         }
         }
         help_texts = {
         help_texts = {
             'weight': 'A numeric weight to influence the ordering of this link among its peers. Lower weights appear '
             'weight': 'A numeric weight to influence the ordering of this link among its peers. Lower weights appear '
                       'first in a list.',
                       'first in a list.',
-            'text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. Links '
-                    'which render as empty text will not be displayed.',
-            'url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
+            'link_text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. '
+                         'Links which render as empty text will not be displayed.',
+            'link_url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
         }
         }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -158,7 +158,7 @@ class CustomLinkAdmin(admin.ModelAdmin):
             'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window')
             'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window')
         }),
         }),
         ('Templates', {
         ('Templates', {
-            'fields': ('text', 'url'),
+            'fields': ('link_text', 'link_url'),
             'classes': ('monospace',)
             'classes': ('monospace',)
         })
         })
     )
     )

+ 29 - 9
netbox/extras/api/nested_serializers.py

@@ -2,24 +2,44 @@ from rest_framework import serializers
 
 
 from extras import choices, models
 from extras import choices, models
 from netbox.api import ChoiceField, WritableNestedSerializer
 from netbox.api import ChoiceField, WritableNestedSerializer
+from netbox.api.serializers import NestedTagSerializer
 from users.api.nested_serializers import NestedUserSerializer
 from users.api.nested_serializers import NestedUserSerializer
 
 
 __all__ = [
 __all__ = [
     'NestedConfigContextSerializer',
     'NestedConfigContextSerializer',
     'NestedCustomFieldSerializer',
     'NestedCustomFieldSerializer',
+    'NestedCustomLinkSerializer',
     'NestedExportTemplateSerializer',
     'NestedExportTemplateSerializer',
     'NestedImageAttachmentSerializer',
     'NestedImageAttachmentSerializer',
     'NestedJobResultSerializer',
     'NestedJobResultSerializer',
-    'NestedTagSerializer',
+    'NestedJournalEntrySerializer',
+    'NestedTagSerializer',  # Defined in netbox.api.serializers
+    'NestedWebhookSerializer',
 ]
 ]
 
 
 
 
+class NestedWebhookSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
+
+    class Meta:
+        model = models.Webhook
+        fields = ['id', 'url', 'display', 'name']
+
+
 class NestedCustomFieldSerializer(WritableNestedSerializer):
 class NestedCustomFieldSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
 
 
     class Meta:
     class Meta:
         model = models.CustomField
         model = models.CustomField
-        fields = ['id', 'url', 'name']
+        fields = ['id', 'url', 'display', 'name']
+
+
+class NestedCustomLinkSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
+
+    class Meta:
+        model = models.CustomLink
+        fields = ['id', 'url', 'display', 'name']
 
 
 
 
 class NestedConfigContextSerializer(WritableNestedSerializer):
 class NestedConfigContextSerializer(WritableNestedSerializer):
@@ -27,7 +47,7 @@ class NestedConfigContextSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.ConfigContext
         model = models.ConfigContext
-        fields = ['id', 'url', 'name']
+        fields = ['id', 'url', 'display', 'name']
 
 
 
 
 class NestedExportTemplateSerializer(WritableNestedSerializer):
 class NestedExportTemplateSerializer(WritableNestedSerializer):
@@ -35,7 +55,7 @@ class NestedExportTemplateSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.ExportTemplate
         model = models.ExportTemplate
-        fields = ['id', 'url', 'name']
+        fields = ['id', 'url', 'display', 'name']
 
 
 
 
 class NestedImageAttachmentSerializer(WritableNestedSerializer):
 class NestedImageAttachmentSerializer(WritableNestedSerializer):
@@ -43,15 +63,15 @@ class NestedImageAttachmentSerializer(WritableNestedSerializer):
 
 
     class Meta:
     class Meta:
         model = models.ImageAttachment
         model = models.ImageAttachment
-        fields = ['id', 'url', 'name', 'image']
+        fields = ['id', 'url', 'display', 'name', 'image']
 
 
 
 
-class NestedTagSerializer(WritableNestedSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
+class NestedJournalEntrySerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail')
 
 
     class Meta:
     class Meta:
-        model = models.Tag
-        fields = ['id', 'url', 'name', 'slug', 'color']
+        model = models.JournalEntry
+        fields = ['id', 'url', 'display', 'created']
 
 
 
 
 class NestedJobResultSerializer(serializers.ModelSerializer):
 class NestedJobResultSerializer(serializers.ModelSerializer):

+ 132 - 52
netbox/extras/api/serializers.py

@@ -5,16 +5,15 @@ from rest_framework import serializers
 
 
 from dcim.api.nested_serializers import (
 from dcim.api.nested_serializers import (
     NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer,
     NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer,
-    NestedRegionSerializer, NestedSiteSerializer,
+    NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
 )
 )
-from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
+from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from extras.choices import *
 from extras.choices import *
-from extras.models import (
-    ConfigContext, CustomField, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag,
-)
+from extras.models import *
 from extras.utils import FeatureQuery
 from extras.utils import FeatureQuery
-from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer
+from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.exceptions import SerializerNotFound
 from netbox.api.exceptions import SerializerNotFound
+from netbox.api.serializers import BaseModelSerializer, ValidatedModelSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from users.api.nested_serializers import NestedUserSerializer
 from users.api.nested_serializers import NestedUserSerializer
@@ -23,6 +22,46 @@ from virtualization.api.nested_serializers import NestedClusterGroupSerializer,
 from virtualization.models import Cluster, ClusterGroup
 from virtualization.models import Cluster, ClusterGroup
 from .nested_serializers import *
 from .nested_serializers import *
 
 
+__all__ = (
+    'ConfigContextSerializer',
+    'ContentTypeSerializer',
+    'CustomFieldSerializer',
+    'CustomLinkSerializer',
+    'ExportTemplateSerializer',
+    'ImageAttachmentSerializer',
+    'JobResultSerializer',
+    'ObjectChangeSerializer',
+    'ReportDetailSerializer',
+    'ReportSerializer',
+    'ScriptDetailSerializer',
+    'ScriptInputSerializer',
+    'ScriptLogMessageSerializer',
+    'ScriptOutputSerializer',
+    'ScriptSerializer',
+    'TagSerializer',
+    'WebhookSerializer',
+)
+
+
+#
+# Webhooks
+#
+
+class WebhookSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
+    content_types = ContentTypeField(
+        queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
+        many=True
+    )
+
+    class Meta:
+        model = Webhook
+        fields = [
+            'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url',
+            'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
+            'ssl_verification', 'ca_file_path',
+        ]
+
 
 
 #
 #
 # Custom fields
 # Custom fields
@@ -40,11 +79,29 @@ class CustomFieldSerializer(ValidatedModelSerializer):
     class Meta:
     class Meta:
         model = CustomField
         model = CustomField
         fields = [
         fields = [
-            'id', 'url', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic',
+            'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic',
             'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
             'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
         ]
         ]
 
 
 
 
+#
+# Custom links
+#
+
+class CustomLinkSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
+    content_type = ContentTypeField(
+        queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query())
+    )
+
+    class Meta:
+        model = CustomLink
+        fields = [
+            'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name',
+            'button_class', 'new_window',
+        ]
+
+
 #
 #
 # Export templates
 # Export templates
 #
 #
@@ -57,7 +114,10 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = ExportTemplate
         model = ExportTemplate
-        fields = ['id', 'url', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension']
+        fields = [
+            'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type',
+            'file_extension',
+        ]
 
 
 
 
 #
 #
@@ -70,39 +130,7 @@ class TagSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Tag
         model = Tag
-        fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'tagged_items']
-
-
-class TaggedObjectSerializer(serializers.Serializer):
-    tags = NestedTagSerializer(many=True, required=False)
-
-    def create(self, validated_data):
-        tags = validated_data.pop('tags', None)
-        instance = super().create(validated_data)
-
-        if tags is not None:
-            return self._save_tags(instance, tags)
-        return instance
-
-    def update(self, instance, validated_data):
-        tags = validated_data.pop('tags', None)
-
-        # Cache tags on instance for change logging
-        instance._tags = tags or []
-
-        instance = super().update(instance, validated_data)
-
-        if tags is not None:
-            return self._save_tags(instance, tags)
-        return instance
-
-    def _save_tags(self, instance, tags):
-        if tags:
-            instance.tags.set(*[t.name for t in tags])
-        else:
-            instance.tags.clear()
-
-        return instance
+        fields = ['id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items']
 
 
 
 
 #
 #
@@ -119,8 +147,8 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
     class Meta:
     class Meta:
         model = ImageAttachment
         model = ImageAttachment
         fields = [
         fields = [
-            'id', 'url', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'image_width',
-            'created',
+            'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height',
+            'image_width', 'created',
         ]
         ]
 
 
     def validate(self, data):
     def validate(self, data):
@@ -154,6 +182,51 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
         return serializer(obj.parent, context={'request': self.context['request']}).data
         return serializer(obj.parent, context={'request': self.context['request']}).data
 
 
 
 
+#
+# Journal entries
+#
+
+class JournalEntrySerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail')
+    assigned_object_type = ContentTypeField(
+        queryset=ContentType.objects.all()
+    )
+    assigned_object = serializers.SerializerMethodField(read_only=True)
+    kind = ChoiceField(
+        choices=JournalEntryKindChoices,
+        required=False
+    )
+
+    class Meta:
+        model = JournalEntry
+        fields = [
+            'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
+            'created_by', 'kind', 'comments',
+        ]
+
+    def validate(self, data):
+
+        # Validate that the parent object exists
+        if 'assigned_object_type' in data and 'assigned_object_id' in data:
+            try:
+                data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id'])
+            except ObjectDoesNotExist:
+                raise serializers.ValidationError(
+                    f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}"
+                )
+
+        # Enforce model validation
+        super().validate(data)
+
+        return data
+
+    @swagger_serializer_method(serializer_or_field=serializers.DictField)
+    def get_assigned_object(self, instance):
+        serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix='Nested')
+        context = {'request': self.context['request']}
+        return serializer(instance.assigned_object, context=context).data
+
+
 #
 #
 # Config contexts
 # Config contexts
 #
 #
@@ -166,6 +239,12 @@ class ConfigContextSerializer(ValidatedModelSerializer):
         required=False,
         required=False,
         many=True
         many=True
     )
     )
+    site_groups = SerializedPKRelatedField(
+        queryset=SiteGroup.objects.all(),
+        serializer=NestedSiteGroupSerializer,
+        required=False,
+        many=True
+    )
     sites = SerializedPKRelatedField(
     sites = SerializedPKRelatedField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         serializer=NestedSiteSerializer,
         serializer=NestedSiteSerializer,
@@ -218,8 +297,9 @@ class ConfigContextSerializer(ValidatedModelSerializer):
     class Meta:
     class Meta:
         model = ConfigContext
         model = ConfigContext
         fields = [
         fields = [
-            'id', 'url', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms',
-            'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated',
+            'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
+            'roles', 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', 'created',
+            'last_updated',
         ]
         ]
 
 
 
 
@@ -227,7 +307,7 @@ class ConfigContextSerializer(ValidatedModelSerializer):
 # Job Results
 # Job Results
 #
 #
 
 
-class JobResultSerializer(serializers.ModelSerializer):
+class JobResultSerializer(BaseModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
     user = NestedUserSerializer(
     user = NestedUserSerializer(
         read_only=True
         read_only=True
@@ -240,7 +320,7 @@ class JobResultSerializer(serializers.ModelSerializer):
     class Meta:
     class Meta:
         model = JobResult
         model = JobResult
         fields = [
         fields = [
-            'id', 'url', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
+            'id', 'url', 'display', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
         ]
         ]
 
 
 
 
@@ -318,7 +398,7 @@ class ScriptOutputSerializer(serializers.Serializer):
 # Change logging
 # Change logging
 #
 #
 
 
-class ObjectChangeSerializer(serializers.ModelSerializer):
+class ObjectChangeSerializer(BaseModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail')
     user = NestedUserSerializer(
     user = NestedUserSerializer(
         read_only=True
         read_only=True
@@ -337,8 +417,8 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
     class Meta:
     class Meta:
         model = ObjectChange
         model = ObjectChange
         fields = [
         fields = [
-            'id', 'url', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type',
-            'changed_object_id', 'changed_object', 'object_data',
+            'id', 'url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type',
+            'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data',
         ]
         ]
 
 
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
@@ -365,13 +445,13 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
 # ContentTypes
 # ContentTypes
 #
 #
 
 
-class ContentTypeSerializer(serializers.ModelSerializer):
+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()
     display_name = serializers.SerializerMethodField()
 
 
     class Meta:
     class Meta:
         model = ContentType
         model = ContentType
-        fields = ['id', 'url', 'app_label', 'model', 'display_name']
+        fields = ['id', 'url', 'display', 'app_label', 'model', 'display_name']
 
 
     @swagger_serializer_method(serializer_or_field=serializers.CharField)
     @swagger_serializer_method(serializer_or_field=serializers.CharField)
     def get_display_name(self, obj):
     def get_display_name(self, obj):

+ 9 - 0
netbox/extras/api/urls.py

@@ -5,9 +5,15 @@ from . import views
 router = OrderedDefaultRouter()
 router = OrderedDefaultRouter()
 router.APIRootView = views.ExtrasRootView
 router.APIRootView = views.ExtrasRootView
 
 
+# Webhooks
+router.register('webhooks', views.WebhookViewSet)
+
 # Custom fields
 # Custom fields
 router.register('custom-fields', views.CustomFieldViewSet)
 router.register('custom-fields', views.CustomFieldViewSet)
 
 
+# Custom links
+router.register('custom-links', views.CustomLinkViewSet)
+
 # Export templates
 # Export templates
 router.register('export-templates', views.ExportTemplateViewSet)
 router.register('export-templates', views.ExportTemplateViewSet)
 
 
@@ -17,6 +23,9 @@ router.register('tags', views.TagViewSet)
 # Image attachments
 # Image attachments
 router.register('image-attachments', views.ImageAttachmentViewSet)
 router.register('image-attachments', views.ImageAttachmentViewSet)
 
 
+# Journal entries
+router.register('journal-entries', views.JournalEntryViewSet)
+
 # Config contexts
 # Config contexts
 router.register('config-contexts', views.ConfigContextViewSet)
 router.register('config-contexts', views.ConfigContextViewSet)
 
 

+ 35 - 4
netbox/extras/api/views.py

@@ -11,9 +11,7 @@ from rq import Worker
 
 
 from extras import filters
 from extras import filters
 from extras.choices import JobResultStatusChoices
 from extras.choices import JobResultStatusChoices
-from extras.models import (
-    ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem,
-)
+from extras.models import *
 from extras.models import CustomField
 from extras.models import CustomField
 from extras.reports import get_report, get_reports, run_report
 from extras.reports import get_report, get_reports, run_report
 from extras.scripts import get_script, get_scripts, run_script
 from extras.scripts import get_script, get_scripts, run_script
@@ -55,6 +53,17 @@ class ConfigContextQuerySetMixin:
         return queryset.annotate_config_context_data()
         return queryset.annotate_config_context_data()
 
 
 
 
+#
+# Webhooks
+#
+
+class WebhookViewSet(ModelViewSet):
+    metadata_class = ContentTypeMetadata
+    queryset = Webhook.objects.all()
+    serializer_class = serializers.WebhookSerializer
+    filterset_class = filters.WebhookFilterSet
+
+
 #
 #
 # Custom fields
 # Custom fields
 #
 #
@@ -84,6 +93,17 @@ class CustomFieldModelViewSet(ModelViewSet):
         return context
         return context
 
 
 
 
+#
+# Custom links
+#
+
+class CustomLinkViewSet(ModelViewSet):
+    metadata_class = ContentTypeMetadata
+    queryset = CustomLink.objects.all()
+    serializer_class = serializers.CustomLinkSerializer
+    filterset_class = filters.CustomLinkFilterSet
+
+
 #
 #
 # Export templates
 # Export templates
 #
 #
@@ -118,13 +138,24 @@ class ImageAttachmentViewSet(ModelViewSet):
     filterset_class = filters.ImageAttachmentFilterSet
     filterset_class = filters.ImageAttachmentFilterSet
 
 
 
 
+#
+# Journal entries
+#
+
+class JournalEntryViewSet(ModelViewSet):
+    metadata_class = ContentTypeMetadata
+    queryset = JournalEntry.objects.all()
+    serializer_class = serializers.JournalEntrySerializer
+    filterset_class = filters.JournalEntryFilterSet
+
+
 #
 #
 # Config contexts
 # Config contexts
 #
 #
 
 
 class ConfigContextViewSet(ModelViewSet):
 class ConfigContextViewSet(ModelViewSet):
     queryset = ConfigContext.objects.prefetch_related(
     queryset = ConfigContext.objects.prefetch_related(
-        'regions', '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 = filters.ConfigContextFilterSet

+ 26 - 0
netbox/extras/choices.py

@@ -87,6 +87,32 @@ class ObjectChangeActionChoices(ChoiceSet):
     }
     }
 
 
 
 
+#
+# Jounral entries
+#
+
+class JournalEntryKindChoices(ChoiceSet):
+
+    KIND_INFO = 'info'
+    KIND_SUCCESS = 'success'
+    KIND_WARNING = 'warning'
+    KIND_DANGER = 'danger'
+
+    CHOICES = (
+        (KIND_INFO, 'Info'),
+        (KIND_SUCCESS, 'Success'),
+        (KIND_WARNING, 'Warning'),
+        (KIND_DANGER, 'Danger'),
+    )
+
+    CSS_CLASSES = {
+        KIND_INFO: 'default',
+        KIND_SUCCESS: 'success',
+        KIND_WARNING: 'warning',
+        KIND_DANGER: 'danger',
+    }
+
+
 #
 #
 # Log Levels for Reports and Scripts
 # Log Levels for Reports and Scripts
 #
 #

+ 68 - 2
netbox/extras/filters.py

@@ -4,12 +4,12 @@ from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 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, Platform, Region, Site
+from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.filters import BaseFilterSet, ContentTypeFilter
 from utilities.filters import BaseFilterSet, ContentTypeFilter
 from virtualization.models import Cluster, ClusterGroup
 from virtualization.models import Cluster, ClusterGroup
 from .choices import *
 from .choices import *
-from .models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, JobResult, ObjectChange, Tag
+from .models import *
 
 
 
 
 __all__ = (
 __all__ = (
@@ -17,12 +17,15 @@ __all__ = (
     'ContentTypeFilterSet',
     'ContentTypeFilterSet',
     'CreatedUpdatedFilterSet',
     'CreatedUpdatedFilterSet',
     'CustomFieldFilter',
     'CustomFieldFilter',
+    'CustomLinkFilterSet',
     'CustomFieldModelFilterSet',
     'CustomFieldModelFilterSet',
     'ExportTemplateFilterSet',
     'ExportTemplateFilterSet',
     'ImageAttachmentFilterSet',
     'ImageAttachmentFilterSet',
+    'JournalEntryFilterSet',
     'LocalConfigContextFilterSet',
     'LocalConfigContextFilterSet',
     'ObjectChangeFilterSet',
     'ObjectChangeFilterSet',
     'TagFilterSet',
     'TagFilterSet',
+    'WebhookFilterSet',
 )
 )
 
 
 EXACT_FILTER_TYPES = (
 EXACT_FILTER_TYPES = (
@@ -33,6 +36,20 @@ EXACT_FILTER_TYPES = (
 )
 )
 
 
 
 
+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.
@@ -79,6 +96,13 @@ class CustomFieldFilterSet(django_filters.FilterSet):
         fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
         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 ExportTemplateFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
@@ -94,6 +118,37 @@ class ImageAttachmentFilterSet(BaseFilterSet):
         fields = ['id', 'content_type_id', 'object_id', 'name']
         fields = ['id', 'content_type_id', 'object_id', 'name']
 
 
 
 
+class JournalEntryFilterSet(BaseFilterSet):
+    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(BaseFilterSet):
 class TagFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
@@ -129,6 +184,17 @@ class ConfigContextFilterSet(BaseFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Region (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(
     site_id = django_filters.ModelMultipleChoiceFilter(
         field_name='sites',
         field_name='sites',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),

+ 88 - 9
netbox/extras/forms.py

@@ -4,16 +4,16 @@ from django.contrib.contenttypes.models import ContentType
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from dcim.models import DeviceRole, Platform, Region, Site
+from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
-    CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2,
+    CommentField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2,
     BOOLEAN_WITH_BLANK_CHOICES,
     BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
 from virtualization.models import Cluster, ClusterGroup
 from virtualization.models import Cluster, ClusterGroup
 from .choices import *
 from .choices import *
-from .models import ConfigContext, CustomField, ImageAttachment, ObjectChange, Tag
+from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag
 
 
 
 
 #
 #
@@ -210,6 +210,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False
         required=False
     )
     )
+    site_groups = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False
+    )
     sites = DynamicModelMultipleChoiceField(
     sites = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False
         required=False
@@ -249,8 +253,8 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = ConfigContext
         model = ConfigContext
         fields = (
         fields = (
-            'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups',
-            'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
+            'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'platforms',
+            'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
         )
         )
 
 
 
 
@@ -280,8 +284,8 @@ class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
 
 
 class ConfigContextFilterForm(BootstrapMixin, forms.Form):
 class ConfigContextFilterForm(BootstrapMixin, forms.Form):
     field_order = [
     field_order = [
-        'q', 'region_id', 'site_id', 'role_id', 'platform_id', 'cluster_group_id', 'cluster_id', 'tenant_group_id',
-        'tenant_id',
+        'q', 'region_id', 'site_group_id', 'site_id', 'role_id', 'platform_id', 'cluster_group_id', 'cluster_id',
+        'tenant_group_id', 'tenant_id',
     ]
     ]
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
@@ -292,6 +296,11 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
         required=False,
         required=False,
         label=_('Regions')
         label=_('Regions')
     )
     )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site groups')
+    )
     site_id = DynamicModelMultipleChoiceField(
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
@@ -362,6 +371,78 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
         ]
         ]
 
 
 
 
+#
+# Journal entries
+#
+
+class JournalEntryForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = JournalEntry
+        fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments']
+        widgets = {
+            'assigned_object_type': forms.HiddenInput,
+            'assigned_object_id': forms.HiddenInput,
+        }
+
+
+class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=JournalEntry.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    kind = forms.ChoiceField(
+        choices=JournalEntryKindChoices,
+        required=False
+    )
+    comments = forms.CharField(
+        required=False,
+        widget=forms.Textarea()
+    )
+
+    class Meta:
+        nullable_fields = []
+
+
+class JournalEntryFilterForm(BootstrapMixin, forms.Form):
+    model = JournalEntry
+    q = forms.CharField(
+        required=False,
+        label=_('Search')
+    )
+    created_after = forms.DateTimeField(
+        required=False,
+        label=_('After'),
+        widget=DateTimePicker()
+    )
+    created_before = forms.DateTimeField(
+        required=False,
+        label=_('Before'),
+        widget=DateTimePicker()
+    )
+    created_by_id = DynamicModelMultipleChoiceField(
+        queryset=User.objects.all(),
+        required=False,
+        label=_('User'),
+        widget=APISelectMultiple(
+            api_url='/api/users/users/',
+        )
+    )
+    assigned_object_type_id = DynamicModelMultipleChoiceField(
+        queryset=ContentType.objects.all(),
+        required=False,
+        label=_('Object Type'),
+        widget=APISelectMultiple(
+            api_url='/api/extras/content-types/',
+        )
+    )
+    kind = forms.ChoiceField(
+        choices=add_blank_choice(JournalEntryKindChoices),
+        required=False,
+        widget=StaticSelect2()
+    )
+
+
 #
 #
 # Change logging
 # Change logging
 #
 #
@@ -390,7 +471,6 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
     user_id = DynamicModelMultipleChoiceField(
     user_id = DynamicModelMultipleChoiceField(
         queryset=User.objects.all(),
         queryset=User.objects.all(),
         required=False,
         required=False,
-        display_field='username',
         label=_('User'),
         label=_('User'),
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url='/api/users/users/',
             api_url='/api/users/users/',
@@ -399,7 +479,6 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
     changed_object_type_id = DynamicModelMultipleChoiceField(
     changed_object_type_id = DynamicModelMultipleChoiceField(
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),
         required=False,
         required=False,
-        display_field='display_name',
         label=_('Object Type'),
         label=_('Object Type'),
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url='/api/extras/content-types/',
             api_url='/api/extras/content-types/',

+ 14 - 10
netbox/extras/management/commands/renaturalize.py

@@ -1,3 +1,4 @@
+from cacheops import invalidate_model
 from django.apps import apps
 from django.apps import apps
 from django.core.management.base import BaseCommand, CommandError
 from django.core.management.base import BaseCommand, CommandError
 
 
@@ -27,7 +28,7 @@ class Command(BaseCommand):
                     app_label, model_name = name.split('.')
                     app_label, model_name = name.split('.')
                 except ValueError:
                 except ValueError:
                     raise CommandError(
                     raise CommandError(
-                        "Invalid format: {}. Models must be specified in the form app_label.ModelName.".format(name)
+                        f"Invalid format: {name}. Models must be specified in the form app_label.ModelName."
                     )
                     )
                 try:
                 try:
                     app_config = apps.get_app_config(app_label)
                     app_config = apps.get_app_config(app_label)
@@ -36,13 +37,13 @@ class Command(BaseCommand):
                 try:
                 try:
                     model = app_config.get_model(model_name)
                     model = app_config.get_model(model_name)
                 except LookupError:
                 except LookupError:
-                    raise CommandError("Unknown model: {}.{}".format(app_label, model_name))
+                    raise CommandError(f"Unknown model: {app_label}.{model_name}")
                 fields = [
                 fields = [
                     field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField
                     field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField
                 ]
                 ]
                 if not fields:
                 if not fields:
                     raise CommandError(
                     raise CommandError(
-                        "Invalid model: {}.{} does not employ natural ordering".format(app_label, model_name)
+                        f"Invalid model: {app_label}.{model_name} does not employ natural ordering"
                     )
                     )
                 models.append(
                 models.append(
                     (model, fields)
                     (model, fields)
@@ -67,7 +68,7 @@ class Command(BaseCommand):
         models = self._get_models(args)
         models = self._get_models(args)
 
 
         if options['verbosity']:
         if options['verbosity']:
-            self.stdout.write("Renaturalizing {} models.".format(len(models)))
+            self.stdout.write(f"Renaturalizing {len(models)} models.")
 
 
         for model, fields in models:
         for model, fields in models:
             for field in fields:
             for field in fields:
@@ -78,7 +79,7 @@ class Command(BaseCommand):
                 # Print the model and field name
                 # Print the model and field name
                 if options['verbosity']:
                 if options['verbosity']:
                     self.stdout.write(
                     self.stdout.write(
-                        "{}.{} ({})... ".format(model._meta.label, field.target_field, field.name),
+                        f"{model._meta.label}.{field.target_field} ({field.name})... ",
                         ending='\n' if options['verbosity'] >= 2 else ''
                         ending='\n' if options['verbosity'] >= 2 else ''
                     )
                     )
                     self.stdout.flush()
                     self.stdout.flush()
@@ -89,23 +90,26 @@ class Command(BaseCommand):
                     naturalized_value = naturalize(value, max_length=field.max_length)
                     naturalized_value = naturalize(value, max_length=field.max_length)
 
 
                     if options['verbosity'] >= 2:
                     if options['verbosity'] >= 2:
-                        self.stdout.write("  {} -> {}".format(value, naturalized_value), ending='')
+                        self.stdout.write(f"  {value} -> {naturalized_value}", ending='')
                         self.stdout.flush()
                         self.stdout.flush()
 
 
                     # Update each unique field value in bulk
                     # Update each unique field value in bulk
                     changed = model.objects.filter(name=value).update(**{field.name: naturalized_value})
                     changed = model.objects.filter(name=value).update(**{field.name: naturalized_value})
 
 
                     if options['verbosity'] >= 2:
                     if options['verbosity'] >= 2:
-                        self.stdout.write(" ({})".format(changed))
+                        self.stdout.write(f" ({changed})")
                     count += changed
                     count += changed
 
 
                 # Print the total count of alterations for the field
                 # Print the total count of alterations for the field
                 if options['verbosity'] >= 2:
                 if options['verbosity'] >= 2:
-                    self.stdout.write(self.style.SUCCESS("{} {} updated ({} unique values)".format(
-                        count, model._meta.verbose_name_plural, queryset.count()
-                    )))
+                    self.stdout.write(self.style.SUCCESS(
+                        f"{count} {model._meta.verbose_name_plural} updated ({queryset.count()} unique values)"
+                    ))
                 elif options['verbosity']:
                 elif options['verbosity']:
                     self.stdout.write(self.style.SUCCESS(str(count)))
                     self.stdout.write(self.style.SUCCESS(str(count)))
 
 
+            # Invalidate cached queries
+            invalidate_model(model)
+
         if options['verbosity']:
         if options['verbosity']:
             self.stdout.write(self.style.SUCCESS("Done."))
             self.stdout.write(self.style.SUCCESS("Done."))

+ 5 - 2
netbox/extras/management/commands/webhook_receiver.py

@@ -1,3 +1,4 @@
+import json
 import sys
 import sys
 from http.server import HTTPServer, BaseHTTPRequestHandler
 from http.server import HTTPServer, BaseHTTPRequestHandler
 
 
@@ -47,8 +48,10 @@ class WebhookHandler(BaseHTTPRequestHandler):
         # Print the request body (if any)
         # Print the request body (if any)
         content_length = self.headers.get('Content-Length')
         content_length = self.headers.get('Content-Length')
         if content_length is not None:
         if content_length is not None:
-            body = self.rfile.read(int(content_length))
-            print(body.decode('utf-8'))
+            body = self.rfile.read(int(content_length)).decode('utf-8')
+            if self.headers.get('Content-Type') == 'application/json':
+                body = json.loads(body)
+                print(json.dumps(body, indent=4))
         else:
         else:
             print('(No body)')
             print('(No body)')
 
 

+ 28 - 0
netbox/extras/migrations/0055_objectchange_data.py

@@ -0,0 +1,28 @@
+# Generated by Django 3.2b1 on 2021-03-03 20:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0054_standardize_models'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='objectchange',
+            old_name='object_data',
+            new_name='postchange_data',
+        ),
+        migrations.AlterField(
+            model_name='objectchange',
+            name='postchange_data',
+            field=models.JSONField(blank=True, editable=False, null=True),
+        ),
+        migrations.AddField(
+            model_name='objectchange',
+            name='prechange_data',
+            field=models.JSONField(blank=True, editable=False, null=True),
+        ),
+    ]

+ 17 - 0
netbox/extras/migrations/0056_sitegroup.py

@@ -0,0 +1,17 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0130_sitegroup'),
+        ('extras', '0055_objectchange_data'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='configcontext',
+            name='site_groups',
+            field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_site_groups_+', to='dcim.SiteGroup'),
+        ),
+    ]

+ 28 - 0
netbox/extras/migrations/0057_customlink_rename_fields.py

@@ -0,0 +1,28 @@
+# Generated by Django 3.2b1 on 2021-03-09 01:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0056_sitegroup'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='customlink',
+            old_name='text',
+            new_name='link_text',
+        ),
+        migrations.RenameField(
+            model_name='customlink',
+            old_name='url',
+            new_name='link_url',
+        ),
+        migrations.AlterField(
+            model_name='customlink',
+            name='new_window',
+            field=models.BooleanField(default=False),
+        ),
+    ]

+ 31 - 0
netbox/extras/migrations/0058_journalentry.py

@@ -0,0 +1,31 @@
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('extras', '0057_customlink_rename_fields'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='JournalEntry',
+            fields=[
+                ('id', models.BigAutoField(primary_key=True, serialize=False)),
+                ('assigned_object_id', models.PositiveIntegerField()),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('kind', models.CharField(default='info', max_length=30)),
+                ('comments', models.TextField()),
+                ('assigned_object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
+                ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'verbose_name_plural': 'journal entries',
+                'ordering': ('-created',),
+            },
+        ),
+    ]

+ 3 - 4
netbox/extras/models/__init__.py

@@ -1,9 +1,7 @@
 from .change_logging import ObjectChange
 from .change_logging import ObjectChange
+from .configcontexts import ConfigContext, ConfigContextModel
 from .customfields import CustomField
 from .customfields import CustomField
-from .models import (
-    ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script,
-    Webhook,
-)
+from .models import CustomLink, ExportTemplate, ImageAttachment, JobResult, JournalEntry, Report, Script, Webhook
 from .tags import Tag, TaggedItem
 from .tags import Tag, TaggedItem
 
 
 __all__ = (
 __all__ = (
@@ -14,6 +12,7 @@ __all__ = (
     'ExportTemplate',
     'ExportTemplate',
     'ImageAttachment',
     'ImageAttachment',
     'JobResult',
     'JobResult',
+    'JournalEntry',
     'ObjectChange',
     'ObjectChange',
     'Report',
     'Report',
     'Script',
     'Script',

+ 12 - 4
netbox/extras/models/change_logging.py

@@ -67,15 +67,22 @@ class ObjectChange(BigIDModel):
         max_length=200,
         max_length=200,
         editable=False
         editable=False
     )
     )
-    object_data = models.JSONField(
-        editable=False
+    prechange_data = models.JSONField(
+        editable=False,
+        blank=True,
+        null=True
+    )
+    postchange_data = models.JSONField(
+        editable=False,
+        blank=True,
+        null=True
     )
     )
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
     csv_headers = [
     csv_headers = [
         'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
         'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
-        'related_object_type', 'related_object_id', 'object_repr', 'object_data',
+        'related_object_type', 'related_object_id', 'object_repr', 'prechange_data', 'postchange_data',
     ]
     ]
 
 
     class Meta:
     class Meta:
@@ -114,7 +121,8 @@ class ObjectChange(BigIDModel):
             self.related_object_type,
             self.related_object_type,
             self.related_object_id,
             self.related_object_id,
             self.object_repr,
             self.object_repr,
-            self.object_data,
+            self.prechange_data,
+            self.postchange_data,
         )
         )
 
 
     def get_action_class(self):
     def get_action_class(self):

+ 161 - 0
netbox/extras/models/configcontexts.py

@@ -0,0 +1,161 @@
+from collections import OrderedDict
+
+from django.core.validators import ValidationError
+from django.db import models
+from django.urls import reverse
+
+from extras.querysets import ConfigContextQuerySet
+from extras.utils import extras_features
+from netbox.models import ChangeLoggedModel
+from utilities.utils import deepmerge
+
+
+__all__ = (
+    'ConfigContext',
+    'ConfigContextModel',
+)
+
+
+#
+# Config contexts
+#
+
+@extras_features('webhooks')
+class ConfigContext(ChangeLoggedModel):
+    """
+    A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
+    qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
+    will be available to a Device in site A assigned to tenant B. Data is stored in JSON format.
+    """
+    name = models.CharField(
+        max_length=100,
+        unique=True
+    )
+    weight = models.PositiveSmallIntegerField(
+        default=1000
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True
+    )
+    is_active = models.BooleanField(
+        default=True,
+    )
+    regions = models.ManyToManyField(
+        to='dcim.Region',
+        related_name='+',
+        blank=True
+    )
+    site_groups = models.ManyToManyField(
+        to='dcim.SiteGroup',
+        related_name='+',
+        blank=True
+    )
+    sites = models.ManyToManyField(
+        to='dcim.Site',
+        related_name='+',
+        blank=True
+    )
+    roles = models.ManyToManyField(
+        to='dcim.DeviceRole',
+        related_name='+',
+        blank=True
+    )
+    platforms = models.ManyToManyField(
+        to='dcim.Platform',
+        related_name='+',
+        blank=True
+    )
+    cluster_groups = models.ManyToManyField(
+        to='virtualization.ClusterGroup',
+        related_name='+',
+        blank=True
+    )
+    clusters = models.ManyToManyField(
+        to='virtualization.Cluster',
+        related_name='+',
+        blank=True
+    )
+    tenant_groups = models.ManyToManyField(
+        to='tenancy.TenantGroup',
+        related_name='+',
+        blank=True
+    )
+    tenants = models.ManyToManyField(
+        to='tenancy.Tenant',
+        related_name='+',
+        blank=True
+    )
+    tags = models.ManyToManyField(
+        to='extras.Tag',
+        related_name='+',
+        blank=True
+    )
+    data = models.JSONField()
+
+    objects = ConfigContextQuerySet.as_manager()
+
+    class Meta:
+        ordering = ['weight', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('extras:configcontext', kwargs={'pk': self.pk})
+
+    def clean(self):
+        super().clean()
+
+        # Verify that JSON data is provided as an object
+        if type(self.data) is not dict:
+            raise ValidationError(
+                {'data': 'JSON data must be in object form. Example: {"foo": 123}'}
+            )
+
+
+class ConfigContextModel(models.Model):
+    """
+    A model which includes local configuration context data. This local data will override any inherited data from
+    ConfigContexts.
+    """
+    local_context_data = models.JSONField(
+        blank=True,
+        null=True,
+    )
+
+    class Meta:
+        abstract = True
+
+    def get_config_context(self):
+        """
+        Return the rendered configuration context for a device or VM.
+        """
+
+        # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
+        data = OrderedDict()
+
+        if not hasattr(self, 'config_context_data'):
+            # The annotation is not available, so we fall back to manually querying for the config context objects
+            config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True)
+        else:
+            # The attribute may exist, but the annotated value could be None if there is no config context data
+            config_context_data = self.config_context_data or []
+
+        for context in config_context_data:
+            data = deepmerge(data, context)
+
+        # If the object has local config context data defined, merge it last
+        if self.local_context_data:
+            data = deepmerge(data, self.local_context_data)
+
+        return data
+
+    def clean(self):
+        super().clean()
+
+        # Verify that JSON data is provided as an object
+        if self.local_context_data and type(self.local_context_data) is not dict:
+            raise ValidationError(
+                {'local_context_data': 'JSON data must be in object form. Example: {"foo": 123}'}
+            )

+ 52 - 129
netbox/extras/models/models.py

@@ -1,6 +1,5 @@
 import json
 import json
 import uuid
 import uuid
-from collections import OrderedDict
 
 
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
@@ -8,17 +7,27 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
 from django.http import HttpResponse
 from django.http import HttpResponse
-from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 from rest_framework.utils.encoders import JSONEncoder
 from rest_framework.utils.encoders import JSONEncoder
 
 
 from extras.choices import *
 from extras.choices import *
 from extras.constants import *
 from extras.constants import *
-from extras.querysets import ConfigContextQuerySet
 from extras.utils import extras_features, FeatureQuery, image_upload
 from extras.utils import extras_features, FeatureQuery, image_upload
-from netbox.models import BigIDModel, ChangeLoggingMixin
+from netbox.models import BigIDModel
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
-from utilities.utils import deepmerge, render_jinja2
+from utilities.utils import render_jinja2
+
+
+__all__ = (
+    'CustomLink',
+    'ExportTemplate',
+    'ImageAttachment',
+    'JobResult',
+    'JournalEntry',
+    'Report',
+    'Script',
+    'Webhook',
+)
 
 
 
 
 #
 #
@@ -109,6 +118,8 @@ class Webhook(BigIDModel):
                   'Leave blank to use the system defaults.'
                   'Leave blank to use the system defaults.'
     )
     )
 
 
+    objects = RestrictedQuerySet.as_manager()
+
     class Meta:
     class Meta:
         ordering = ('name',)
         ordering = ('name',)
         unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',)
         unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',)
@@ -172,13 +183,13 @@ class CustomLink(BigIDModel):
         max_length=100,
         max_length=100,
         unique=True
         unique=True
     )
     )
-    text = models.CharField(
+    link_text = models.CharField(
         max_length=500,
         max_length=500,
         help_text="Jinja2 template code for link text"
         help_text="Jinja2 template code for link text"
     )
     )
-    url = models.CharField(
+    link_url = models.CharField(
         max_length=500,
         max_length=500,
-        verbose_name='URL',
+        verbose_name='Link URL',
         help_text="Jinja2 template code for link URL"
         help_text="Jinja2 template code for link URL"
     )
     )
     weight = models.PositiveSmallIntegerField(
     weight = models.PositiveSmallIntegerField(
@@ -196,9 +207,12 @@ class CustomLink(BigIDModel):
         help_text="The class of the first link in a group will be used for the dropdown button"
         help_text="The class of the first link in a group will be used for the dropdown button"
     )
     )
     new_window = models.BooleanField(
     new_window = models.BooleanField(
+        default=False,
         help_text="Force link to open in a new window"
         help_text="Force link to open in a new window"
     )
     )
 
 
+    objects = RestrictedQuerySet.as_manager()
+
     class Meta:
     class Meta:
         ordering = ['group_name', 'weight', 'name']
         ordering = ['group_name', 'weight', 'name']
 
 
@@ -358,142 +372,51 @@ class ImageAttachment(BigIDModel):
 
 
 
 
 #
 #
-# Config contexts
+# Journal entries
 #
 #
 
 
-class ConfigContext(ChangeLoggingMixin, BigIDModel):
+class JournalEntry(BigIDModel):
     """
     """
-    A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
-    qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
-    will be available to a Device in site A assigned to tenant B. Data is stored in JSON format.
+    A historical remark concerning an object; collectively, these form an object's journal. The journal is used to
+    preserve historical context around an object, and complements NetBox's built-in change logging. For example, you
+    might record a new journal entry when a device undergoes maintenance, or when a prefix is expanded.
     """
     """
-    name = models.CharField(
-        max_length=100,
-        unique=True
-    )
-    weight = models.PositiveSmallIntegerField(
-        default=1000
-    )
-    description = models.CharField(
-        max_length=200,
-        blank=True
-    )
-    is_active = models.BooleanField(
-        default=True,
-    )
-    regions = models.ManyToManyField(
-        to='dcim.Region',
-        related_name='+',
-        blank=True
-    )
-    sites = models.ManyToManyField(
-        to='dcim.Site',
-        related_name='+',
-        blank=True
-    )
-    roles = models.ManyToManyField(
-        to='dcim.DeviceRole',
-        related_name='+',
-        blank=True
-    )
-    platforms = models.ManyToManyField(
-        to='dcim.Platform',
-        related_name='+',
-        blank=True
-    )
-    cluster_groups = models.ManyToManyField(
-        to='virtualization.ClusterGroup',
-        related_name='+',
-        blank=True
+    assigned_object_type = models.ForeignKey(
+        to=ContentType,
+        on_delete=models.CASCADE
     )
     )
-    clusters = models.ManyToManyField(
-        to='virtualization.Cluster',
-        related_name='+',
-        blank=True
+    assigned_object_id = models.PositiveIntegerField()
+    assigned_object = GenericForeignKey(
+        ct_field='assigned_object_type',
+        fk_field='assigned_object_id'
     )
     )
-    tenant_groups = models.ManyToManyField(
-        to='tenancy.TenantGroup',
-        related_name='+',
-        blank=True
+    created = models.DateTimeField(
+        auto_now_add=True
     )
     )
-    tenants = models.ManyToManyField(
-        to='tenancy.Tenant',
-        related_name='+',
-        blank=True
+    created_by = models.ForeignKey(
+        to=User,
+        on_delete=models.SET_NULL,
+        blank=True,
+        null=True
     )
     )
-    tags = models.ManyToManyField(
-        to='extras.Tag',
-        related_name='+',
-        blank=True
+    kind = models.CharField(
+        max_length=30,
+        choices=JournalEntryKindChoices,
+        default=JournalEntryKindChoices.KIND_INFO
     )
     )
-    data = models.JSONField()
+    comments = models.TextField()
 
 
-    objects = ConfigContextQuerySet.as_manager()
+    objects = RestrictedQuerySet.as_manager()
 
 
     class Meta:
     class Meta:
-        ordering = ['weight', 'name']
+        ordering = ('-created',)
+        verbose_name_plural = 'journal entries'
 
 
     def __str__(self):
     def __str__(self):
-        return self.name
+        return f"{self.created} - {self.get_kind_display()}"
 
 
-    def get_absolute_url(self):
-        return reverse('extras:configcontext', kwargs={'pk': self.pk})
-
-    def clean(self):
-        super().clean()
-
-        # Verify that JSON data is provided as an object
-        if type(self.data) is not dict:
-            raise ValidationError(
-                {'data': 'JSON data must be in object form. Example: {"foo": 123}'}
-            )
-
-
-class ConfigContextModel(models.Model):
-    """
-    A model which includes local configuration context data. This local data will override any inherited data from
-    ConfigContexts.
-    """
-    local_context_data = models.JSONField(
-        blank=True,
-        null=True,
-    )
-
-    class Meta:
-        abstract = True
-
-    def get_config_context(self):
-        """
-        Return the rendered configuration context for a device or VM.
-        """
-
-        # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
-        data = OrderedDict()
-
-        if not hasattr(self, 'config_context_data'):
-            # The annotation is not available, so we fall back to manually querying for the config context objects
-            config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True)
-        else:
-            # The attribute may exist, but the annotated value could be None if there is no config context data
-            config_context_data = self.config_context_data or []
-
-        for context in config_context_data:
-            data = deepmerge(data, context)
-
-        # If the object has local config context data defined, merge it last
-        if self.local_context_data:
-            data = deepmerge(data, self.local_context_data)
-
-        return data
-
-    def clean(self):
-        super().clean()
-
-        # Verify that JSON data is provided as an object
-        if self.local_context_data and type(self.local_context_data) is not dict:
-            raise ValidationError(
-                {'local_context_data': 'JSON data must be in object form. Example: {"foo": 123}'}
-            )
+    def get_kind_class(self):
+        return JournalEntryKindChoices.CSS_CLASSES.get(self.kind)
 
 
 
 
 #
 #

+ 4 - 2
netbox/extras/models/tags.py

@@ -2,7 +2,8 @@ from django.db import models
 from django.utils.text import slugify
 from django.utils.text import slugify
 from taggit.models import TagBase, GenericTaggedItemBase
 from taggit.models import TagBase, GenericTaggedItemBase
 
 
-from netbox.models import BigIDModel, ChangeLoggingMixin
+from extras.utils import extras_features
+from netbox.models import BigIDModel, ChangeLoggedModel
 from utilities.choices import ColorChoices
 from utilities.choices import ColorChoices
 from utilities.fields import ColorField
 from utilities.fields import ColorField
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
@@ -12,7 +13,8 @@ from utilities.querysets import RestrictedQuerySet
 # Tags
 # Tags
 #
 #
 
 
-class Tag(ChangeLoggingMixin, BigIDModel, TagBase):
+@extras_features('webhooks')
+class Tag(ChangeLoggedModel, TagBase):
     color = ColorField(
     color = ColorField(
         default=ColorChoices.COLOR_GREY
         default=ColorChoices.COLOR_GREY
     )
     )

+ 10 - 12
netbox/extras/scripts.py

@@ -180,28 +180,26 @@ class ObjectVar(ScriptVariable):
     A single object within NetBox.
     A single object within NetBox.
 
 
     :param model: The NetBox model being referenced
     :param model: The NetBox model being referenced
-    :param display_field: The attribute of the returned object to display in the selection list (default: 'name')
+    :param display_field: The attribute of the returned object to display in the selection list (DEPRECATED)
     :param query_params: A dictionary of additional query parameters to attach when making REST API requests (optional)
     :param query_params: A dictionary of additional query parameters to attach when making REST API requests (optional)
     :param null_option: The label to use as a "null" selection option (optional)
     :param null_option: The label to use as a "null" selection option (optional)
     """
     """
     form_field = DynamicModelChoiceField
     form_field = DynamicModelChoiceField
 
 
-    def __init__(self, model=None, queryset=None, display_field='name', query_params=None, null_option=None, *args,
-                 **kwargs):
-        super().__init__(*args, **kwargs)
+    def __init__(self, model, query_params=None, null_option=None, *args, **kwargs):
 
 
-        # Set the form field's queryset. Support backward compatibility for the "queryset" argument for now.
-        if model is not None:
-            self.field_attrs['queryset'] = model.objects.all()
-        elif queryset is not None:
+        # TODO: Remove display_field in v2.12
+        if 'display_field' in kwargs:
             warnings.warn(
             warnings.warn(
-                f'{self}: Specifying a queryset for ObjectVar is no longer supported. Please use "model" instead.'
+                "The 'display_field' parameter has been deprecated, and will be removed in NetBox v2.12. Object "
+                "variables will now reference the 'display' attribute available on all model serializers by default."
             )
             )
-            self.field_attrs['queryset'] = queryset
-        else:
-            raise TypeError('ObjectVar must specify a model')
+        display_field = kwargs.pop('display_field', 'display')
+
+        super().__init__(*args, **kwargs)
 
 
         self.field_attrs.update({
         self.field_attrs.update({
+            'queryset': model.objects.all(),
             'display_field': display_field,
             'display_field': display_field,
             'query_params': query_params,
             'query_params': query_params,
             'null_option': null_option,
             'null_option': null_option,

+ 6 - 0
netbox/extras/signals.py

@@ -36,6 +36,9 @@ def _handle_changed_object(request, sender, instance, **kwargs):
     # Record an ObjectChange if applicable
     # Record an ObjectChange if applicable
     if hasattr(instance, 'to_objectchange'):
     if hasattr(instance, 'to_objectchange'):
         objectchange = instance.to_objectchange(action)
         objectchange = instance.to_objectchange(action)
+        # TODO: Move this to to_objectchange()
+        if hasattr(instance, '_prechange_snapshot'):
+            objectchange.prechange_data = instance._prechange_snapshot
         objectchange.user = request.user
         objectchange.user = request.user
         objectchange.request_id = request.id
         objectchange.request_id = request.id
         objectchange.save()
         objectchange.save()
@@ -62,6 +65,9 @@ def _handle_deleted_object(request, sender, instance, **kwargs):
     # Record an ObjectChange if applicable
     # Record an ObjectChange if applicable
     if hasattr(instance, 'to_objectchange'):
     if hasattr(instance, 'to_objectchange'):
         objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
         objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
+        # TODO: Move this to to_objectchange()
+        if hasattr(instance, '_prechange_snapshot'):
+            objectchange.prechange_data = instance._prechange_snapshot
         objectchange.user = request.user
         objectchange.user = request.user
         objectchange.request_id = request.id
         objectchange.request_id = request.id
         objectchange.save()
         objectchange.save()

+ 45 - 1
netbox/extras/tables.py

@@ -2,7 +2,7 @@ import django_tables2 as tables
 from django.conf import settings
 from django.conf import settings
 
 
 from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ToggleColumn
 from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ToggleColumn
-from .models import ConfigContext, ObjectChange, Tag, TaggedItem
+from .models import ConfigContext, JournalEntry, ObjectChange, Tag, TaggedItem
 
 
 TAGGED_ITEM = """
 TAGGED_ITEM = """
 {% if value.get_absolute_url %}
 {% if value.get_absolute_url %}
@@ -96,3 +96,47 @@ class ObjectChangeTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ObjectChange
         model = ObjectChange
         fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
         fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
+
+
+class JournalEntryTable(BaseTable):
+    pk = ToggleColumn()
+    created = tables.DateTimeColumn(
+        format=settings.SHORT_DATETIME_FORMAT
+    )
+    assigned_object_type = tables.Column(
+        verbose_name='Object type'
+    )
+    assigned_object = tables.Column(
+        linkify=True,
+        orderable=False,
+        verbose_name='Object'
+    )
+    kind = ChoiceFieldColumn()
+    actions = ButtonsColumn(
+        model=JournalEntry,
+        buttons=('edit', 'delete')
+    )
+
+    class Meta(BaseTable.Meta):
+        model = JournalEntry
+        fields = (
+            'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments', 'actions'
+        )
+
+
+class ObjectJournalTable(BaseTable):
+    """
+    Used for displaying a set of JournalEntries within the context of a single object.
+    """
+    created = tables.DateTimeColumn(
+        format=settings.SHORT_DATETIME_FORMAT
+    )
+    kind = ChoiceFieldColumn()
+    actions = ButtonsColumn(
+        model=JournalEntry,
+        buttons=('edit', 'delete')
+    )
+
+    class Meta(BaseTable.Meta):
+        model = JournalEntry
+        fields = ('created', 'created_by', 'kind', 'comments', 'actions')

+ 4 - 4
netbox/extras/templatetags/custom_links.py

@@ -52,9 +52,9 @@ def custom_links(context, obj):
         # Add non-grouped links
         # Add non-grouped links
         else:
         else:
             try:
             try:
-                text_rendered = render_jinja2(cl.text, link_context)
+                text_rendered = render_jinja2(cl.link_text, link_context)
                 if text_rendered:
                 if text_rendered:
-                    link_rendered = render_jinja2(cl.url, link_context)
+                    link_rendered = render_jinja2(cl.link_url, link_context)
                     link_target = ' target="_blank"' if cl.new_window else ''
                     link_target = ' target="_blank"' if cl.new_window else ''
                     template_code += LINK_BUTTON.format(
                     template_code += LINK_BUTTON.format(
                         link_rendered, link_target, cl.button_class, text_rendered
                         link_rendered, link_target, cl.button_class, text_rendered
@@ -70,10 +70,10 @@ def custom_links(context, obj):
 
 
         for cl in links:
         for cl in links:
             try:
             try:
-                text_rendered = render_jinja2(cl.text, link_context)
+                text_rendered = render_jinja2(cl.link_text, link_context)
                 if text_rendered:
                 if text_rendered:
                     link_target = ' target="_blank"' if cl.new_window else ''
                     link_target = ' target="_blank"' if cl.new_window else ''
-                    link_rendered = render_jinja2(cl.url, link_context)
+                    link_rendered = render_jinja2(cl.link_url, link_context)
                     links_rendered.append(
                     links_rendered.append(
                         GROUP_LINK.format(link_rendered, link_target, text_rendered)
                         GROUP_LINK.format(link_rendered, link_target, text_rendered)
                     )
                     )

+ 169 - 10
netbox/extras/tests/test_api.py

@@ -1,6 +1,7 @@
 import datetime
 import datetime
 from unittest import skipIf
 from unittest import skipIf
 
 
+from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.test import override_settings
 from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
@@ -9,9 +10,9 @@ from django_rq.queues import get_connection
 from rest_framework import status
 from rest_framework import status
 from rq import Worker
 from rq import Worker
 
 
-from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, RackGroup, RackRole, Site
+from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
 from extras.api.views import ReportViewSet, ScriptViewSet
 from extras.api.views import ReportViewSet, ScriptViewSet
-from extras.models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, Tag
+from extras.models import *
 from extras.reports import Report
 from extras.reports import Report
 from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
 from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
@@ -30,9 +31,63 @@ class AppTest(APITestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
 
 
+class WebhookTest(APIViewTestCases.APIViewTestCase):
+    model = Webhook
+    brief_fields = ['display', 'id', 'name', 'url']
+    create_data = [
+        {
+            'content_types': ['dcim.device', 'dcim.devicetype'],
+            'name': 'Webhook 4',
+            'type_create': True,
+            'payload_url': 'http://example.com/?4',
+        },
+        {
+            'content_types': ['dcim.device', 'dcim.devicetype'],
+            'name': 'Webhook 5',
+            'type_update': True,
+            'payload_url': 'http://example.com/?5',
+        },
+        {
+            'content_types': ['dcim.device', 'dcim.devicetype'],
+            'name': 'Webhook 6',
+            'type_delete': True,
+            'payload_url': 'http://example.com/?6',
+        },
+    ]
+    bulk_update_data = {
+        'ssl_verification': False,
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        site_ct = ContentType.objects.get_for_model(Site)
+        rack_ct = ContentType.objects.get_for_model(Rack)
+
+        webhooks = (
+            Webhook(
+                name='Webhook 1',
+                type_create=True,
+                payload_url='http://example.com/?1',
+            ),
+            Webhook(
+                name='Webhook 2',
+                type_update=True,
+                payload_url='http://example.com/?1',
+            ),
+            Webhook(
+                name='Webhook 3',
+                type_delete=True,
+                payload_url='http://example.com/?1',
+            ),
+        )
+        Webhook.objects.bulk_create(webhooks)
+        for webhook in webhooks:
+            webhook.content_types.add(site_ct, rack_ct)
+
+
 class CustomFieldTest(APIViewTestCases.APIViewTestCase):
 class CustomFieldTest(APIViewTestCases.APIViewTestCase):
     model = CustomField
     model = CustomField
-    brief_fields = ['id', 'name', 'url']
+    brief_fields = ['display', 'id', 'name', 'url']
     create_data = [
     create_data = [
         {
         {
             'content_types': ['dcim.site'],
             'content_types': ['dcim.site'],
@@ -77,9 +132,63 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
             cf.content_types.add(site_ct)
             cf.content_types.add(site_ct)
 
 
 
 
+class CustomLinkTest(APIViewTestCases.APIViewTestCase):
+    model = CustomLink
+    brief_fields = ['display', 'id', 'name', 'url']
+    create_data = [
+        {
+            'content_type': 'dcim.site',
+            'name': 'Custom Link 4',
+            'link_text': 'Link 4',
+            'link_url': 'http://example.com/?4',
+        },
+        {
+            'content_type': 'dcim.site',
+            'name': 'Custom Link 5',
+            'link_text': 'Link 5',
+            'link_url': 'http://example.com/?5',
+        },
+        {
+            'content_type': 'dcim.site',
+            'name': 'Custom Link 6',
+            'link_text': 'Link 6',
+            'link_url': 'http://example.com/?6',
+        },
+    ]
+    bulk_update_data = {
+        'new_window': True,
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        site_ct = ContentType.objects.get_for_model(Site)
+
+        custom_links = (
+            CustomLink(
+                content_type=site_ct,
+                name='Custom Link 1',
+                link_text='Link 1',
+                link_url='http://example.com/?1',
+            ),
+            CustomLink(
+                content_type=site_ct,
+                name='Custom Link 2',
+                link_text='Link 2',
+                link_url='http://example.com/?2',
+            ),
+            CustomLink(
+                content_type=site_ct,
+                name='Custom Link 3',
+                link_text='Link 3',
+                link_url='http://example.com/?3',
+            ),
+        )
+        CustomLink.objects.bulk_create(custom_links)
+
+
 class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
 class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ExportTemplate
     model = ExportTemplate
-    brief_fields = ['id', 'name', 'url']
+    brief_fields = ['display', 'id', 'name', 'url']
     create_data = [
     create_data = [
         {
         {
             'content_type': 'dcim.device',
             'content_type': 'dcim.device',
@@ -127,7 +236,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
 
 
 class TagTest(APIViewTestCases.APIViewTestCase):
 class TagTest(APIViewTestCases.APIViewTestCase):
     model = Tag
     model = Tag
-    brief_fields = ['color', 'id', 'name', 'slug', 'url']
+    brief_fields = ['color', 'display', 'id', 'name', 'slug', 'url']
     create_data = [
     create_data = [
         {
         {
             'name': 'Tag 4',
             'name': 'Tag 4',
@@ -164,7 +273,7 @@ class ImageAttachmentTest(
     APIViewTestCases.DeleteObjectViewTestCase
     APIViewTestCases.DeleteObjectViewTestCase
 ):
 ):
     model = ImageAttachment
     model = ImageAttachment
-    brief_fields = ['id', 'image', 'name', 'url']
+    brief_fields = ['display', 'id', 'image', 'name', 'url']
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -201,9 +310,59 @@ class ImageAttachmentTest(
         ImageAttachment.objects.bulk_create(image_attachments)
         ImageAttachment.objects.bulk_create(image_attachments)
 
 
 
 
+class JournalEntryTest(APIViewTestCases.APIViewTestCase):
+    model = JournalEntry
+    brief_fields = ['created', 'display', 'id', 'url']
+    bulk_update_data = {
+        'comments': 'Overwritten',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        user = User.objects.first()
+        site = Site.objects.create(name='Site 1', slug='site-1')
+
+        journal_entries = (
+            JournalEntry(
+                created_by=user,
+                assigned_object=site,
+                comments='Fourth entry',
+            ),
+            JournalEntry(
+                created_by=user,
+                assigned_object=site,
+                comments='Fifth entry',
+            ),
+            JournalEntry(
+                created_by=user,
+                assigned_object=site,
+                comments='Sixth entry',
+            ),
+        )
+        JournalEntry.objects.bulk_create(journal_entries)
+
+        cls.create_data = [
+            {
+                'assigned_object_type': 'dcim.site',
+                'assigned_object_id': site.pk,
+                'comments': 'First entry',
+            },
+            {
+                'assigned_object_type': 'dcim.site',
+                'assigned_object_id': site.pk,
+                'comments': 'Second entry',
+            },
+            {
+                'assigned_object_type': 'dcim.site',
+                'assigned_object_id': site.pk,
+                'comments': 'Third entry',
+            },
+        ]
+
+
 class ConfigContextTest(APIViewTestCases.APIViewTestCase):
 class ConfigContextTest(APIViewTestCases.APIViewTestCase):
     model = ConfigContext
     model = ConfigContext
-    brief_fields = ['id', 'name', 'url']
+    brief_fields = ['display', 'id', 'name', 'url']
     create_data = [
     create_data = [
         {
         {
             'name': 'Config Context 4',
             'name': 'Config Context 4',
@@ -382,13 +541,13 @@ class CreatedUpdatedFilterTest(APITestCase):
         super().setUp()
         super().setUp()
 
 
         self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
         self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
-        self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1')
+        self.location1 = Location.objects.create(site=self.site1, name='Test Location 1', slug='test-location-1')
         self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000')
         self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000')
         self.rack1 = Rack.objects.create(
         self.rack1 = Rack.objects.create(
-            site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', u_height=42,
+            site=self.site1, location=self.location1, role=self.rackrole1, name='Test Rack 1', u_height=42,
         )
         )
         self.rack2 = Rack.objects.create(
         self.rack2 = Rack.objects.create(
-            site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2', u_height=42,
+            site=self.site1, location=self.location1, role=self.rackrole1, name='Test Rack 2', u_height=42,
         )
         )
 
 
         # change the created and last_updated of one
         # change the created and last_updated of one

+ 222 - 33
netbox/extras/tests/test_changelog.py

@@ -40,8 +40,8 @@ class ChangeLogViewTest(ModelViewTestCase):
     def test_create_object(self):
     def test_create_object(self):
         tags = self.create_tags('Tag 1', 'Tag 2')
         tags = self.create_tags('Tag 1', 'Tag 2')
         form_data = {
         form_data = {
-            'name': 'Test Site 1',
-            'slug': 'test-site-1',
+            'name': 'Site 1',
+            'slug': 'site-1',
             'status': SiteStatusChoices.STATUS_ACTIVE,
             'status': SiteStatusChoices.STATUS_ACTIVE,
             'cf_my_field': 'ABC',
             'cf_my_field': 'ABC',
             'cf_my_field_select': 'Bar',
             'cf_my_field_select': 'Bar',
@@ -56,7 +56,7 @@ class ChangeLogViewTest(ModelViewTestCase):
         response = self.client.post(**request)
         response = self.client.post(**request)
         self.assertHttpStatus(response, 302)
         self.assertHttpStatus(response, 302)
 
 
-        site = Site.objects.get(name='Test Site 1')
+        site = Site.objects.get(name='Site 1')
         # First OC is the creation; second is the tags update
         # First OC is the creation; second is the tags update
         oc_list = ObjectChange.objects.filter(
         oc_list = ObjectChange.objects.filter(
             changed_object_type=ContentType.objects.get_for_model(Site),
             changed_object_type=ContentType.objects.get_for_model(Site),
@@ -64,20 +64,21 @@ class ChangeLogViewTest(ModelViewTestCase):
         ).order_by('pk')
         ).order_by('pk')
         self.assertEqual(oc_list[0].changed_object, site)
         self.assertEqual(oc_list[0].changed_object, site)
         self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
         self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
-        self.assertEqual(oc_list[0].object_data['custom_fields']['my_field'], form_data['cf_my_field'])
-        self.assertEqual(oc_list[0].object_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
+        self.assertEqual(oc_list[0].prechange_data, None)
+        self.assertEqual(oc_list[0].postchange_data['custom_fields']['my_field'], form_data['cf_my_field'])
+        self.assertEqual(oc_list[0].postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
         self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
         self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
-        self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2'])
+        self.assertEqual(oc_list[1].postchange_data['tags'], ['Tag 1', 'Tag 2'])
 
 
     def test_update_object(self):
     def test_update_object(self):
-        site = Site(name='Test Site 1', slug='test-site-1')
+        site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
         tags = self.create_tags('Tag 1', 'Tag 2', 'Tag 3')
         tags = self.create_tags('Tag 1', 'Tag 2', 'Tag 3')
         site.tags.set('Tag 1', 'Tag 2')
         site.tags.set('Tag 1', 'Tag 2')
 
 
         form_data = {
         form_data = {
-            'name': 'Test Site X',
-            'slug': 'test-site-x',
+            'name': 'Site X',
+            'slug': 'site-x',
             'status': SiteStatusChoices.STATUS_PLANNED,
             'status': SiteStatusChoices.STATUS_PLANNED,
             'cf_my_field': 'DEF',
             'cf_my_field': 'DEF',
             'cf_my_field_select': 'Foo',
             'cf_my_field_select': 'Foo',
@@ -100,14 +101,16 @@ class ChangeLogViewTest(ModelViewTestCase):
         ).first()
         ).first()
         self.assertEqual(oc.changed_object, site)
         self.assertEqual(oc.changed_object, site)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
-        self.assertEqual(oc.object_data['custom_fields']['my_field'], form_data['cf_my_field'])
-        self.assertEqual(oc.object_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
-        self.assertEqual(oc.object_data['tags'], ['Tag 3'])
+        self.assertEqual(oc.prechange_data['name'], 'Site 1')
+        self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
+        self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field'])
+        self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
+        self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
 
 
     def test_delete_object(self):
     def test_delete_object(self):
         site = Site(
         site = Site(
-            name='Test Site 1',
-            slug='test-site-1',
+            name='Site 1',
+            slug='site-1',
             custom_field_data={
             custom_field_data={
                 'my_field': 'ABC',
                 'my_field': 'ABC',
                 'my_field_select': 'Bar'
                 'my_field_select': 'Bar'
@@ -129,15 +132,83 @@ class ChangeLogViewTest(ModelViewTestCase):
         self.assertEqual(oc.changed_object, None)
         self.assertEqual(oc.changed_object, None)
         self.assertEqual(oc.object_repr, site.name)
         self.assertEqual(oc.object_repr, site.name)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
-        self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
-        self.assertEqual(oc.object_data['custom_fields']['my_field_select'], 'Bar')
-        self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
+        self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC')
+        self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar')
+        self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
+        self.assertEqual(oc.postchange_data, None)
+
+    def test_bulk_update_objects(self):
+        sites = (
+            Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE),
+            Site(name='Site 2', slug='site-2', status=SiteStatusChoices.STATUS_ACTIVE),
+            Site(name='Site 3', slug='site-3', status=SiteStatusChoices.STATUS_ACTIVE),
+        )
+        Site.objects.bulk_create(sites)
+
+        form_data = {
+            'pk': [site.pk for site in sites],
+            '_apply': True,
+            'status': SiteStatusChoices.STATUS_PLANNED,
+            'description': 'New description',
+        }
+
+        request = {
+            'path': self._get_url('bulk_edit'),
+            'data': post_data(form_data),
+        }
+        self.add_permissions('dcim.view_site', 'dcim.change_site')
+        response = self.client.post(**request)
+        self.assertHttpStatus(response, 302)
+
+        objectchange = ObjectChange.objects.get(
+            changed_object_type=ContentType.objects.get_for_model(Site),
+            changed_object_id=sites[0].pk
+        )
+        self.assertEqual(objectchange.changed_object, sites[0])
+        self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_UPDATE)
+        self.assertEqual(objectchange.prechange_data['status'], SiteStatusChoices.STATUS_ACTIVE)
+        self.assertEqual(objectchange.prechange_data['description'], '')
+        self.assertEqual(objectchange.postchange_data['status'], form_data['status'])
+        self.assertEqual(objectchange.postchange_data['description'], form_data['description'])
+
+    def test_bulk_delete_objects(self):
+        sites = (
+            Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE),
+            Site(name='Site 2', slug='site-2', status=SiteStatusChoices.STATUS_ACTIVE),
+            Site(name='Site 3', slug='site-3', status=SiteStatusChoices.STATUS_ACTIVE),
+        )
+        Site.objects.bulk_create(sites)
+
+        form_data = {
+            'pk': [site.pk for site in sites],
+            'confirm': True,
+            '_confirm': True,
+        }
+
+        request = {
+            'path': self._get_url('bulk_delete'),
+            'data': post_data(form_data),
+        }
+        self.add_permissions('dcim.delete_site')
+        response = self.client.post(**request)
+        self.assertHttpStatus(response, 302)
+
+        objectchange = ObjectChange.objects.get(
+            changed_object_type=ContentType.objects.get_for_model(Site),
+            changed_object_id=sites[0].pk
+        )
+        self.assertEqual(objectchange.changed_object_type, ContentType.objects.get_for_model(Site))
+        self.assertEqual(objectchange.changed_object_id, sites[0].pk)
+        self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_DELETE)
+        self.assertEqual(objectchange.prechange_data['name'], sites[0].name)
+        self.assertEqual(objectchange.prechange_data['slug'], sites[0].slug)
+        self.assertEqual(objectchange.postchange_data, None)
 
 
 
 
 class ChangeLogAPITest(APITestCase):
 class ChangeLogAPITest(APITestCase):
 
 
-    def setUp(self):
-        super().setUp()
+    @classmethod
+    def setUpTestData(cls):
 
 
         # Create a custom field on the Site model
         # Create a custom field on the Site model
         ct = ContentType.objects.get_for_model(Site)
         ct = ContentType.objects.get_for_model(Site)
@@ -169,8 +240,8 @@ class ChangeLogAPITest(APITestCase):
 
 
     def test_create_object(self):
     def test_create_object(self):
         data = {
         data = {
-            'name': 'Test Site 1',
-            'slug': 'test-site-1',
+            'name': 'Site 1',
+            'slug': 'site-1',
             'custom_fields': {
             'custom_fields': {
                 'my_field': 'ABC',
                 'my_field': 'ABC',
                 'my_field_select': 'Bar',
                 'my_field_select': 'Bar',
@@ -195,17 +266,18 @@ class ChangeLogAPITest(APITestCase):
         ).order_by('pk')
         ).order_by('pk')
         self.assertEqual(oc_list[0].changed_object, site)
         self.assertEqual(oc_list[0].changed_object, site)
         self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
         self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
-        self.assertEqual(oc_list[0].object_data['custom_fields'], data['custom_fields'])
+        self.assertEqual(oc_list[0].prechange_data, None)
+        self.assertEqual(oc_list[0].postchange_data['custom_fields'], data['custom_fields'])
         self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
         self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
-        self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2'])
+        self.assertEqual(oc_list[1].postchange_data['tags'], ['Tag 1', 'Tag 2'])
 
 
     def test_update_object(self):
     def test_update_object(self):
-        site = Site(name='Test Site 1', slug='test-site-1')
+        site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
 
 
         data = {
         data = {
-            'name': 'Test Site X',
-            'slug': 'test-site-x',
+            'name': 'Site X',
+            'slug': 'site-x',
             'custom_fields': {
             'custom_fields': {
                 'my_field': 'DEF',
                 'my_field': 'DEF',
                 'my_field_select': 'Foo',
                 'my_field_select': 'Foo',
@@ -229,13 +301,13 @@ class ChangeLogAPITest(APITestCase):
         ).first()
         ).first()
         self.assertEqual(oc.changed_object, site)
         self.assertEqual(oc.changed_object, site)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
-        self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
-        self.assertEqual(oc.object_data['tags'], ['Tag 3'])
+        self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
+        self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
 
 
     def test_delete_object(self):
     def test_delete_object(self):
         site = Site(
         site = Site(
-            name='Test Site 1',
-            slug='test-site-1',
+            name='Site 1',
+            slug='site-1',
             custom_field_data={
             custom_field_data={
                 'my_field': 'ABC',
                 'my_field': 'ABC',
                 'my_field_select': 'Bar'
                 'my_field_select': 'Bar'
@@ -255,6 +327,123 @@ class ChangeLogAPITest(APITestCase):
         self.assertEqual(oc.changed_object, None)
         self.assertEqual(oc.changed_object, None)
         self.assertEqual(oc.object_repr, site.name)
         self.assertEqual(oc.object_repr, site.name)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
-        self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
-        self.assertEqual(oc.object_data['custom_fields']['my_field_select'], 'Bar')
-        self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
+        self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC')
+        self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar')
+        self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
+        self.assertEqual(oc.postchange_data, None)
+
+    def test_bulk_create_objects(self):
+        data = (
+            {
+                'name': 'Site 1',
+                'slug': 'site-1',
+            },
+            {
+                'name': 'Site 2',
+                'slug': 'site-2',
+            },
+            {
+                'name': 'Site 3',
+                'slug': 'site-3',
+            },
+        )
+        self.assertEqual(ObjectChange.objects.count(), 0)
+        url = reverse('dcim-api:site-list')
+        self.add_permissions('dcim.add_site')
+
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(ObjectChange.objects.count(), 3)
+
+        site1 = Site.objects.get(pk=response.data[0]['id'])
+        objectchange = ObjectChange.objects.get(
+            changed_object_type=ContentType.objects.get_for_model(Site),
+            changed_object_id=site1.pk
+        )
+        self.assertEqual(objectchange.changed_object, site1)
+        self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_CREATE)
+        self.assertEqual(objectchange.prechange_data, None)
+        self.assertEqual(objectchange.postchange_data['name'], data[0]['name'])
+        self.assertEqual(objectchange.postchange_data['slug'], data[0]['slug'])
+
+    def test_bulk_edit_objects(self):
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3'),
+        )
+        Site.objects.bulk_create(sites)
+
+        data = (
+            {
+                'id': sites[0].pk,
+                'name': 'Site A',
+                'slug': 'site-A',
+            },
+            {
+                'id': sites[1].pk,
+                'name': 'Site B',
+                'slug': 'site-b',
+            },
+            {
+                'id': sites[2].pk,
+                'name': 'Site C',
+                'slug': 'site-c',
+            },
+        )
+        self.assertEqual(ObjectChange.objects.count(), 0)
+        url = reverse('dcim-api:site-list')
+        self.add_permissions('dcim.change_site')
+
+        response = self.client.patch(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(ObjectChange.objects.count(), 3)
+
+        objectchange = ObjectChange.objects.get(
+            changed_object_type=ContentType.objects.get_for_model(Site),
+            changed_object_id=sites[0].pk
+        )
+        self.assertEqual(objectchange.changed_object, sites[0])
+        self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_UPDATE)
+        self.assertEqual(objectchange.prechange_data['name'], 'Site 1')
+        self.assertEqual(objectchange.prechange_data['slug'], 'site-1')
+        self.assertEqual(objectchange.postchange_data['name'], data[0]['name'])
+        self.assertEqual(objectchange.postchange_data['slug'], data[0]['slug'])
+
+    def test_bulk_delete_objects(self):
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3'),
+        )
+        Site.objects.bulk_create(sites)
+
+        data = (
+            {
+                'id': sites[0].pk,
+            },
+            {
+                'id': sites[1].pk,
+            },
+            {
+                'id': sites[2].pk,
+            },
+        )
+        self.assertEqual(ObjectChange.objects.count(), 0)
+        url = reverse('dcim-api:site-list')
+        self.add_permissions('dcim.delete_site')
+
+        response = self.client.delete(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+        self.assertEqual(ObjectChange.objects.count(), 3)
+
+        objectchange = ObjectChange.objects.get(
+            changed_object_type=ContentType.objects.get_for_model(Site),
+            changed_object_id=sites[0].pk
+        )
+        self.assertEqual(objectchange.changed_object_type, ContentType.objects.get_for_model(Site))
+        self.assertEqual(objectchange.changed_object_id, sites[0].pk)
+        self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_DELETE)
+        self.assertEqual(objectchange.prechange_data['name'], 'Site 1')
+        self.assertEqual(objectchange.prechange_data['slug'], 'site-1')
+        self.assertEqual(objectchange.postchange_data, None)

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