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

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

Daniel Sheppard 5 месяцев назад
Родитель
Сommit
90d277610c
100 измененных файлов с 2190 добавлено и 377 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 3 0
      .github/codeql/codeql-config.yml
  4. 42 0
      .github/workflows/codeql.yml
  5. 7 2
      base_requirements.txt
  6. 9 0
      contrib/generated_schema.json
  7. 1 1
      docs/administration/error-reporting.md
  8. 3 18
      docs/development/application-registry.md
  9. 20 13
      docs/development/models.md
  10. 3 17
      docs/development/release-checklist.md
  11. 15 36
      docs/installation/upgrading.md
  12. 9 1
      docs/models/dcim/platform.md
  13. 7 0
      docs/models/dcim/rackreservation.md
  14. 4 0
      docs/models/extras/configcontext.md
  15. 33 0
      docs/models/extras/configcontextprofile.md
  16. 22 14
      docs/plugins/development/models.md
  17. 4 0
      docs/plugins/development/tables.md
  18. 1 1
      docs/reference/conditions.md
  19. 2 1
      docs/release-notes/index.md
  20. 60 0
      docs/release-notes/version-4.3.md
  21. 29 3
      docs/release-notes/version-4.4.md
  22. 3 0
      mkdocs.yml
  23. 1 5
      netbox/circuits/urls.py
  24. 6 1
      netbox/circuits/views.py
  25. 10 10
      netbox/core/api/serializers_/object_types.py
  26. 12 2
      netbox/core/api/serializers_/tasks.py
  27. 1 1
      netbox/core/api/urls.py
  28. 3 1
      netbox/core/api/views.py
  29. 8 2
      netbox/core/filtersets.py
  30. 8 0
      netbox/core/graphql/mixins.py
  31. 35 37
      netbox/core/jobs.py
  32. 2 2
      netbox/core/management/commands/nbshell.py
  33. 11 5
      netbox/core/models/object_types.py
  34. 12 0
      netbox/core/signals.py
  35. 45 0
      netbox/core/tests/test_filtersets.py
  36. 6 0
      netbox/core/tests/test_views.py
  37. 33 7
      netbox/core/views.py
  38. 9 0
      netbox/dcim/api/serializers_/nested.py
  39. 14 8
      netbox/dcim/api/serializers_/platforms.py
  40. 18 6
      netbox/dcim/api/serializers_/racks.py
  41. 4 4
      netbox/dcim/api/serializers_/roles.py
  42. 28 3
      netbox/dcim/api/views.py
  43. 22 0
      netbox/dcim/choices.py
  44. 60 14
      netbox/dcim/filtersets.py
  45. 5 2
      netbox/dcim/forms/bulk_create.py
  46. 15 3
      netbox/dcim/forms/bulk_edit.py
  47. 23 2
      netbox/dcim/forms/bulk_import.py
  48. 6 1
      netbox/dcim/forms/connections.py
  49. 11 1
      netbox/dcim/forms/filtersets.py
  50. 14 4
      netbox/dcim/forms/model_forms.py
  51. 2 0
      netbox/dcim/graphql/types.py
  52. 2 0
      netbox/dcim/management/commands/buildschema.py
  53. 2 1
      netbox/dcim/migrations/0205_moduletypeprofile.py
  54. 287 0
      netbox/dcim/migrations/0209_device_component_denorm_site_location.py
  55. 19 0
      netbox/dcim/migrations/0210_macaddress_ordering.py
  56. 1 1
      netbox/dcim/migrations/0211_platform_manufacturer_uniqueness.py
  57. 1 1
      netbox/dcim/migrations/0212_interface_tx_power_negative.py
  58. 55 0
      netbox/dcim/migrations/0213_platform_parent.py
  59. 29 0
      netbox/dcim/migrations/0214_platform_rebuild.py
  60. 16 0
      netbox/dcim/migrations/0215_rackreservation_status.py
  61. 10 0
      netbox/dcim/models/cables.py
  62. 31 0
      netbox/dcim/models/device_components.py
  63. 10 13
      netbox/dcim/models/devices.py
  64. 2 13
      netbox/dcim/models/modules.py
  65. 9 0
      netbox/dcim/models/racks.py
  66. 0 3
      netbox/dcim/object_actions.py
  67. 31 2
      netbox/dcim/signals.py
  68. 5 1
      netbox/dcim/tables/cables.py
  69. 7 3
      netbox/dcim/tables/devices.py
  70. 5 2
      netbox/dcim/tables/racks.py
  71. 27 6
      netbox/dcim/tests/test_api.py
  72. 285 27
      netbox/dcim/tests/test_filtersets.py
  73. 15 10
      netbox/dcim/tests/test_views.py
  74. 24 6
      netbox/dcim/views.py
  75. 36 4
      netbox/extras/api/serializers_/configcontexts.py
  76. 1 0
      netbox/extras/api/urls.py
  77. 6 0
      netbox/extras/api/views.py
  78. 15 10
      netbox/extras/dashboard/widgets.py
  79. 41 0
      netbox/extras/filtersets.py
  80. 41 1
      netbox/extras/forms/bulk_edit.py
  81. 10 0
      netbox/extras/forms/bulk_import.py
  82. 28 0
      netbox/extras/forms/filtersets.py
  83. 34 4
      netbox/extras/forms/model_forms.py
  84. 9 1
      netbox/extras/graphql/filters.py
  85. 3 0
      netbox/extras/graphql/schema.py
  86. 18 12
      netbox/extras/graphql/types.py
  87. 5 0
      netbox/extras/jobs.py
  88. 27 1
      netbox/extras/lookups.py
  89. 75 0
      netbox/extras/migrations/0132_configcontextprofile.py
  90. 61 4
      netbox/extras/models/configs.py
  91. 9 1
      netbox/extras/models/customfields.py
  92. 3 0
      netbox/extras/models/models.py
  93. 8 5
      netbox/extras/models/notifications.py
  94. 4 4
      netbox/extras/scripts.py
  95. 11 0
      netbox/extras/search.py
  96. 43 8
      netbox/extras/tables/tables.py
  97. 64 0
      netbox/extras/tests/test_api.py
  98. 11 1
      netbox/extras/tests/test_customfields.py
  99. 1 1
      netbox/extras/tests/test_dashboard.py
  100. 50 1
      netbox/extras/tests/test_filtersets.py

+ 1 - 1
.github/ISSUE_TEMPLATE/01-feature_request.yaml

@@ -15,7 +15,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v4.3.5
+      placeholder: v4.4.0
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/02-bug_report.yaml

@@ -27,7 +27,7 @@ body:
     attributes:
       label: NetBox Version
       description: What version of NetBox are you currently running?
-      placeholder: v4.3.5
+      placeholder: v4.4.0
     validations:
       required: true
   - type: dropdown

+ 3 - 0
.github/codeql/codeql-config.yml

@@ -0,0 +1,3 @@
+paths-ignore:
+  # Ignore compiled JS
+  - netbox/project-static/dist

+ 42 - 0
.github/workflows/codeql.yml

@@ -0,0 +1,42 @@
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ "main", "feature" ]
+  pull_request:
+    branches: [ "main", "feature" ]
+  schedule:
+    - cron: '38 16 * * 4'
+
+jobs:
+  analyze:
+    name: Analyze (${{ matrix.language }})
+    runs-on: ubuntu-latest
+    permissions:
+      security-events: write
+
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+        - language: actions
+          build-mode: none
+        - language: javascript-typescript
+          build-mode: none
+        - language: python
+          build-mode: none
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v4
+
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v3
+      with:
+        languages: ${{ matrix.language }}
+        build-mode: ${{ matrix.build-mode }}
+        config-file: .github/codeql/codeql-config.yml
+
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v3
+      with:
+        category: "/language:${{matrix.language}}"

+ 7 - 2
base_requirements.txt

@@ -106,7 +106,11 @@ mkdocs-material
 
 # Introspection for embedded code
 # https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
-mkdocstrings[python]
+mkdocstrings
+
+# Python handler for mkdocstrings
+# https://github.com/mkdocstrings/python/blob/main/CHANGELOG.md
+mkdocstrings-python
 
 # Library for manipulating IP prefixes and addresses
 # https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
@@ -135,7 +139,8 @@ requests
 
 # rq
 # https://github.com/rq/rq/blob/master/CHANGES.md
-rq
+# RQ v2.5 drops support for Redis < 5.0
+rq==2.4.1
 
 # Django app for social-auth-core
 # https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md

+ 9 - 0
contrib/generated_schema.json

@@ -95,6 +95,7 @@
                         "iec-60320-c8",
                         "iec-60320-c14",
                         "iec-60320-c16",
+                        "iec-60320-c18",
                         "iec-60320-c20",
                         "iec-60320-c22",
                         "iec-60309-p-n-e-4h",
@@ -209,6 +210,7 @@
                         "iec-60320-c7",
                         "iec-60320-c13",
                         "iec-60320-c15",
+                        "iec-60320-c17",
                         "iec-60320-c19",
                         "iec-60320-c21",
                         "iec-60309-p-n-e-4h",
@@ -474,6 +476,13 @@
                         "passive-48v-2pair",
                         "passive-48v-4pair"
                     ]
+                },
+                "rf_role": {
+                    "type": "string",
+                    "enum": [
+                        "ap",
+                        "station"
+                    ]
                 }
             }
         },

+ 1 - 1
docs/administration/error-reporting.md

@@ -4,7 +4,7 @@
 
 ### Enabling Error Reporting
 
-NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to True and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py`.
+NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to `True` and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py`.
 
 ```python
 SENTRY_ENABLED = True

+ 3 - 18
docs/development/application-registry.md

@@ -22,24 +22,9 @@ Stores registration made using `netbox.denormalized.register()`. For each model,
 
 ### `model_features`
 
-A dictionary of particular features (e.g. custom fields) mapped to the NetBox models which support them, arranged by app. For example:
-
-```python
-{
-    'custom_fields': {
-        'circuits': ['provider', 'circuit'],
-        'dcim': ['site', 'rack', 'devicetype', ...],
-        ...
-    },
-    'event_rules': {
-        'extras': ['configcontext', 'tag', ...],
-        'dcim': ['site', 'rack', 'devicetype', ...],
-    },
-    ...
-}
-```
-
-Supported model features are listed in the [features matrix](./models.md#features-matrix).
+A dictionary of model features (e.g. custom fields, tags, etc.) mapped to the functions used to qualify a model as supporting each feature. Model features are registered using the `register_model_feature()` function in `netbox.utils`.
+
+Core model features are listed in the [features matrix](./models.md#features-matrix).
 
 ### `models`
 

+ 20 - 13
docs/development/models.md

@@ -10,19 +10,26 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
 
 Depending on its classification, each NetBox model may support various features which enhance its operation. Each feature is enabled by inheriting from its designated mixin class, and some features also make use of the [application registry](./application-registry.md#model_features).
 
-| Feature                                                    | Feature Mixin           | Registry Key       | Description                                                                             |
-|------------------------------------------------------------|-------------------------|--------------------|-----------------------------------------------------------------------------------------|
-| [Change logging](../features/change-logging.md)            | `ChangeLoggingMixin`    | -                  | Changes to these objects are automatically recorded in the change log                   |
-| Cloning                                                    | `CloningMixin`          | -                  | Provides the `clone()` method to prepare a copy                                         |
-| [Custom fields](../customization/custom-fields.md)         | `CustomFieldsMixin`     | `custom_fields`    | These models support the addition of user-defined fields                                |
-| [Custom links](../customization/custom-links.md)           | `CustomLinksMixin`      | `custom_links`     | These models support the assignment of custom links                                     |
-| [Custom validation](../customization/custom-validation.md) | `CustomValidationMixin` | -                  | Supports the enforcement of custom validation rules                                     |
-| [Export templates](../customization/export-templates.md)   | `ExportTemplatesMixin`  | `export_templates` | Users can create custom export templates for these models                               |
-| [Job results](../features/background-jobs.md)              | `JobsMixin`             | `jobs`             | Background jobs can be scheduled for these models                                       |
-| [Journaling](../features/journaling.md)                    | `JournalingMixin`       | `journaling`       | These models support persistent historical commentary                                   |
-| [Synchronized data](../integrations/synchronized-data.md)  | `SyncedDataMixin`       | `synced_data`      | Certain model data can be automatically synchronized from a remote data source          |
-| [Tagging](../models/extras/tag.md)                         | `TagsMixin`             | `tags`             | The models can be tagged with user-defined tags                                         |
-| [Event rules](../features/event-rules.md)                  | `EventRulesMixin`       | `event_rules`      | Event rules can send webhooks or run custom scripts automatically in response to events |
+| Feature                                                    | Feature Mixin           | Registry Key        | Description                                                                             |
+|------------------------------------------------------------|-------------------------|---------------------|-----------------------------------------------------------------------------------------|
+| [Bookmarks](../features/customization.md#bookmarks)        | `BookmarksMixin`        | `bookmarks`         | These models can be bookmarked natively in the user interface                           |
+| [Change logging](../features/change-logging.md)            | `ChangeLoggingMixin`    | `change_logging`    | Changes to these objects are automatically recorded in the change log                   |
+| Cloning                                                    | `CloningMixin`          | `cloning`           | Provides the `clone()` method to prepare a copy                                         |
+| [Contacts](../features/contacts.md)                        | `ContactsMixin`         | `contacts`          | Contacts can be associated with these models                                            |
+| [Custom fields](../customization/custom-fields.md)         | `CustomFieldsMixin`     | `custom_fields`     | These models support the addition of user-defined fields                                |
+| [Custom links](../customization/custom-links.md)           | `CustomLinksMixin`      | `custom_links`      | These models support the assignment of custom links                                     |
+| [Custom validation](../customization/custom-validation.md) | `CustomValidationMixin` | -                   | Supports the enforcement of custom validation rules                                     |
+| [Event rules](../features/event-rules.md)                  | `EventRulesMixin`       | `event_rules`       | Event rules can send webhooks or run custom scripts automatically in response to events |
+| [Export templates](../customization/export-templates.md)   | `ExportTemplatesMixin`  | `export_templates`  | Users can create custom export templates for these models                               |
+| [Image attachments](../models/extras/imageattachment.md)   | `ImageAttachmentsMixin` | `image_attachments` | Image uploads can be attached to these models                                           |
+| [Jobs](../features/background-jobs.md)                     | `JobsMixin`             | `jobs`              | Background jobs can be scheduled for these models                                       |
+| [Journaling](../features/journaling.md)                    | `JournalingMixin`       | `journaling`        | These models support persistent historical commentary                                   |
+| [Notifications](../features/notifications.md)              | `NotificationsMixin`    | `notifications`     | These models support user notifications                                                 |
+| [Synchronized data](../integrations/synchronized-data.md)  | `SyncedDataMixin`       | `synced_data`       | Certain model data can be automatically synchronized from a remote data source          |
+| [Tagging](../models/extras/tag.md)                         | `TagsMixin`             | `tags`              | The models can be tagged with user-defined tags                                         |
+
+!!! note
+    The above listed features are supported natively by NetBox. Beginning with NetBox v4.4.0, plugins can register their own model features as well.
 
 ## Models Index
 

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

@@ -31,28 +31,14 @@ Close the [release milestone](https://github.com/netbox-community/netbox/milesto
 
 Check that a link to the release notes for the new version is present in the navigation menu (defined in `mkdocs.yml`), and that a summary of all major new features has been added to `docs/index.md`.
 
-### Update the Dependency Requirements Matrix
-
-For every minor release, update the dependency requirements matrix in `docs/installation/upgrading.md` ("All versions") to reflect the supported versions of Python, PostgreSQL, and Redis:
-
-1. Add a new row with the supported dependency versions.
-2. Include a documentation link using the release tag format: `https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md`
-3. Bold any version changes for clarity.
-
-**Example Update:**  
-
-```markdown
-| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation                                                                                     |
-|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:|
-|      4.2       |    3.10    |    3.12    |     **13**     |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md)         |
-```
-
 ### Update System Requirements
 
 If a new Django release is adopted or other major dependencies (Python, PostgreSQL, Redis) change:
 
 * Update the installation guide (`docs/installation/index.md`) with the new minimum versions.
-* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version accordingly.
+* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version.
+    * Update the minimum versions for each dependency.
+    * Add a new row to the release history table. Bold any version changes for clarity.
 * Update the minimum PostgreSQL version in the programming error template (`netbox/templates/exceptions/programming_error.html`).
 * Update the minimum and supported Python versions in the project metadata file (`pyproject.toml`)
 

+ 15 - 36
docs/installation/upgrading.md

@@ -25,42 +25,21 @@ NetBox requires the following dependencies:
 
 ### Version History
 
-| NetBox Version | Python min | Python max | PostgreSQL min | Redis min |                                           Documentation                                           |
-|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:|
-|      4.3       |    3.10    |    3.12    |       14       |    4.0    |     [Link](https://github.com/netbox-community/netbox/blob/v4.3.0/docs/installation/index.md)     |
-|      4.2       |    3.10    |    3.12    |       13       |    4.0    |     [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md)     |
-|      4.1       |    3.10    |    3.12    |       12       |    4.0    |     [Link](https://github.com/netbox-community/netbox/blob/v4.1.0/docs/installation/index.md)     |
-|      4.0       |    3.10    |    3.12    |       12       |    4.0    |     [Link](https://github.com/netbox-community/netbox/blob/v4.0.0/docs/installation/index.md)     |
-|      3.7       |    3.8     |    3.11    |       12       |    4.0    |     [Link](https://github.com/netbox-community/netbox/blob/v3.7.0/docs/installation/index.md)     |
-|      3.6       |    3.8     |    3.11    |       12       |    4.0    |     [Link](https://github.com/netbox-community/netbox/blob/v3.6.0/docs/installation/index.md)     |
-|      3.5       |    3.8     |    3.10    |       11       |    4.0    |     [Link](https://github.com/netbox-community/netbox/blob/v3.5.0/docs/installation/index.md)     |
-|      3.4       |    3.8     |    3.10    |       11       |    4.0    |     [Link](https://github.com/netbox-community/netbox/blob/v3.4.0/docs/installation/index.md)     |
-|      3.3       |    3.8     |    3.10    |       10       |    4.0    |     [Link](https://github.com/netbox-community/netbox/blob/v3.3.0/docs/installation/index.md)     |
-|      3.2       |    3.8     |    3.10    |       10       |    4.0    |     [Link](https://github.com/netbox-community/netbox/blob/v3.2.0/docs/installation/index.md)     |
-|      3.1       |    3.7     |    3.9     |       10       |    4.0    |     [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md)     |
-|      3.0       |    3.7     |    3.9     |      9.6       |    4.0    |     [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md)     |
-|      2.11      |    3.6     |    3.9     |      9.6       |    4.0    |    [Link](https://github.com/netbox-community/netbox/blob/v2.11.0/docs/installation/index.md)     |
-|      2.10      |    3.6     |    3.8     |      9.6       |    4.0    |    [Link](https://github.com/netbox-community/netbox/blob/v2.10.0/docs/installation/index.md)     |
-|      2.9       |    3.6     |    3.8     |      9.5       |    4.0    |     [Link](https://github.com/netbox-community/netbox/blob/v2.9.0/docs/installation/index.md)     |
-|      2.8       |    3.6     |    3.8     |      9.5       |    3.4    |     [Link](https://github.com/netbox-community/netbox/blob/v2.8.0/docs/installation/index.md)     |
-|      2.7       |    3.5     |    3.7     |      9.4       |     -     |     [Link](https://github.com/netbox-community/netbox/blob/v2.7.0/docs/installation/index.md)     |
-|      2.6       |    3.5     |    3.7     |      9.4       |     -     |     [Link](https://github.com/netbox-community/netbox/blob/v2.6.0/docs/installation/index.md)     |
-|      2.5       |    3.5     |    3.7     |      9.4       |     -     |     [Link](https://github.com/netbox-community/netbox/blob/v2.5.0/docs/installation/index.md)     |
-|      2.4       |    3.4     |    3.7     |      9.4       |     -     |     [Link](https://github.com/netbox-community/netbox/blob/v2.4.0/docs/installation/index.md)     |
-|      2.3       |    2.7     |    3.6     |      9.4       |     -     |  [Link](https://github.com/netbox-community/netbox/blob/v2.3.0/docs/installation/postgresql.md)   |
-|      2.2       |    2.7     |    3.6     |      9.4       |     -     |  [Link](https://github.com/netbox-community/netbox/blob/v2.2.0/docs/installation/postgresql.md)   |
-|      2.1       |    2.7     |    3.6     |      9.3       |     -     |  [Link](https://github.com/netbox-community/netbox/blob/v2.1.0/docs/installation/postgresql.md)   |
-|      2.0       |    2.7     |    3.6     |      9.3       |     -     |  [Link](https://github.com/netbox-community/netbox/blob/v2.0.0/docs/installation/postgresql.md)   |
-|      1.9       |    2.7     |    3.5     |      9.2       |     -     | [Link](https://github.com/netbox-community/netbox/blob/v1.9.0-r1/docs/installation/postgresql.md) |
-|      1.8       |    2.7     |    3.5     |      9.2       |     -     |  [Link](https://github.com/netbox-community/netbox/blob/v1.8.0/docs/installation/postgresql.md)   |
-|      1.7       |    2.7     |    3.5     |      9.2       |     -     |  [Link](https://github.com/netbox-community/netbox/blob/v1.7.0/docs/installation/postgresql.md)   |
-|      1.6       |    2.7     |    3.5     |      9.2       |     -     |  [Link](https://github.com/netbox-community/netbox/blob/v1.6.0/docs/installation/postgresql.md)   |
-|      1.5       |    2.7     |    3.5     |      9.2       |     -     |  [Link](https://github.com/netbox-community/netbox/blob/v1.5.0/docs/installation/postgresql.md)   |
-|      1.4       |    2.7     |    3.5     |      9.1       |     -     |  [Link](https://github.com/netbox-community/netbox/blob/v1.4.0/docs/installation/postgresql.md)   |
-|      1.3       |    2.7     |    3.5     |      9.1       |     -     |  [Link](https://github.com/netbox-community/netbox/blob/v1.3.0/docs/installation/postgresql.md)   |
-|      1.2       |    2.7     |    3.5     |      9.1       |     -     |  [Link](https://github.com/netbox-community/netbox/blob/v1.2.0/docs/installation/postgresql.md)   |
-|      1.1       |    2.7     |    3.5     |      9.1       |     -     |      [Link](https://github.com/netbox-community/netbox/blob/v1.1.0/docs/getting-started.md)       |
-|      1.0       |    2.7     |    3.5     |      9.1       |     -     |       [Link](https://github.com/netbox-community/netbox/blob/1.0.0/docs/getting-started.md)       |
+| NetBox Version | Python min | Python max | PostgreSQL min | Redis min |                                       Documentation                                       |
+|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-----------------------------------------------------------------------------------------:|
+|      4.4       |    3.10    |    3.12    |       14       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.4.0/docs/installation/index.md) |
+|      4.3       |    3.10    |    3.12    |       14       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.3.0/docs/installation/index.md) |
+|      4.2       |    3.10    |    3.12    |       13       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
+|      4.1       |    3.10    |    3.12    |       12       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.1.0/docs/installation/index.md) |
+|      4.0       |    3.10    |    3.12    |       12       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.0.0/docs/installation/index.md) |
+|      3.7       |    3.8     |    3.11    |       12       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v3.7.0/docs/installation/index.md) |
+|      3.6       |    3.8     |    3.11    |       12       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v3.6.0/docs/installation/index.md) |
+|      3.5       |    3.8     |    3.10    |       11       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v3.5.0/docs/installation/index.md) |
+|      3.4       |    3.8     |    3.10    |       11       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v3.4.0/docs/installation/index.md) |
+|      3.3       |    3.8     |    3.10    |       10       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v3.3.0/docs/installation/index.md) |
+|      3.2       |    3.8     |    3.10    |       10       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v3.2.0/docs/installation/index.md) |
+|      3.1       |    3.7     |    3.9     |       10       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md) |
+|      3.0       |    3.7     |    3.9     |      9.6       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md) |
 
 ## 3. Install the Latest Release
 

+ 9 - 1
docs/models/dcim/platform.md

@@ -2,12 +2,20 @@
 
 A platform defines the type of software running on a [device](./device.md) or [virtual machine](../virtualization/virtualmachine.md). This can be helpful to model when it is necessary to distinguish between different versions or feature sets. Note that two devices of the same type may be assigned different platforms: For example, one Juniper MX240 might run Junos 14 while another runs Junos 15.
 
+Platforms may be nested under parents to form a hierarchy. For example, platforms named "Debian" and "RHEL" might both be created under a generic "Linux" parent.
+
 Platforms may optionally be limited by [manufacturer](./manufacturer.md): If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer.
 
-The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.
+The assignment of platforms to devices and virtual machines is optional.
 
 ## Fields
 
+## Parent
+
+!!! "This field was introduced in NetBox v4.4."
+
+The parent platform class to which this platform belongs (optional).
+
 ### Name
 
 A human-friendly name for the platform. Must be unique per manufacturer.

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

@@ -12,6 +12,13 @@ The [rack](./rack.md) being reserved.
 
 The rack unit or units being reserved. Multiple units can be expressed using commas and/or hyphens. For example, `1,3,5-7` specifies units 1, 3, 5, 6, and 7.
 
+### Status
+
+The current status of the reservation. (This is for documentation only: The status of a reservation has no impact on the installation of devices within a reserved rack unit.)
+
+!!! tip
+    Additional statuses may be defined by setting `RackReservation.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
+
 ### User
 
 The NetBox user account associated with the reservation. Note that users with sufficient permission can make rack reservations for other users.

+ 4 - 0
docs/models/extras/configcontext.md

@@ -14,6 +14,10 @@ A unique human-friendly name.
 
 A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight.
 
+### Profile
+
+The [profile](./configcontextprofile.md) to which the config context is assigned (optional). Profiles can be used to enforce structure in their data.
+
 ### Data
 
 The context data expressed in JSON format.

+ 33 - 0
docs/models/extras/configcontextprofile.md

@@ -0,0 +1,33 @@
+# Config Context Profiles
+
+Profiles can be used to organize [configuration contexts](./configcontext.md) and to enforce a desired structure for their data. The later is achieved by defining a [JSON schema](https://json-schema.org/) to which all config context with this profile assigned must comply.
+
+For example, the following schema defines two keys, `size` and `priority`, of which the former is required:
+
+```json
+{
+    "properties": {
+        "size": {
+            "type": "integer"
+        },
+        "priority": {
+            "type": "string",
+            "enum": ["high", "medium", "low"],
+            "default": "medium"
+        }
+    },
+    "required": [
+        "size"
+    ]
+}
+```
+
+## Fields
+
+### Name
+
+A unique human-friendly name.
+
+### Schema
+
+The JSON schema to be enforced for all assigned config contexts (optional).

+ 22 - 14
docs/plugins/development/models.md

@@ -24,20 +24,7 @@ Every model includes by default a numeric primary key. This value is generated a
 
 ## Enabling NetBox Features
 
-Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:
-
-* Bookmarks
-* Change logging
-* Cloning
-* Custom fields
-* Custom links
-* Custom validation
-* Export templates
-* Journaling
-* Tags
-* Webhooks
-
-This class performs two crucial functions:
+Plugin models can leverage certain [model features](../../development/models.md#features-matrix) (such as tags, custom fields, event rules, etc.) by inheriting from NetBox's `NetBoxModel` class. This class performs two crucial functions:
 
 1. Apply any fields, methods, and/or attributes necessary to the operation of these features
 2. Register the model with NetBox as utilizing these features
@@ -135,6 +122,27 @@ For more information about database migrations, see the [Django documentation](h
 
 ::: netbox.models.features.TagsMixin
 
+## Custom Model Features
+
+In addition to utilizing the model features provided natively by NetBox (listed above), plugins can register their own model features. This is done using the `register_model_feature()` function from `netbox.utils`. This function takes two arguments: a feature name, and a callable which accepts a model class. The callable must return a boolean value indicting whether the given model supports the named feature.
+
+This function can be used as a decorator:
+
+```python
+@register_model_feature('foo')
+def supports_foo(model):
+    # Your logic here
+```
+
+Or it can be called directly:
+
+```python
+register_model_feature('foo', supports_foo)
+```
+
+!!! tip
+    Consider performing feature registration inside your PluginConfig's `ready()` method.
+
 ## Choice Sets
 
 For model fields which support the selection of one or more values from a predefined list of choices, NetBox provides the `ChoiceSet` utility class. This can be used in place of a regular choices tuple to provide enhanced functionality, namely dynamic configuration and colorization. (See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/models/fields/#choices) on the `choices` parameter for supported model fields.)

+ 4 - 0
docs/plugins/development/tables.md

@@ -51,6 +51,10 @@ This will automatically apply any user-specific preferences for the table. (If u
 
 The table column classes listed below are supported for use in plugins. These classes can be imported from `netbox.tables.columns`.
 
+::: netbox.tables.ArrayColumn
+    options:
+      members: false
+
 ::: netbox.tables.BooleanColumn
     options:
       members: false

+ 1 - 1
docs/reference/conditions.md

@@ -89,7 +89,7 @@ The following condition will evaluate as true:
 ```
 
 !!! note "Evaluating static choice fields"
-    Pay close attention when evaluating static choice fields, such as the `status` field above. These fields typically render as a dictionary specifying both the field's raw value (`value`) and its human-friendly label (`label`). be sure to specify on which of these you want to match.
+    Pay close attention when evaluating static choice fields, such as the `status` field above. These fields typically render as a dictionary specifying both the field's raw value (`value`) and its human-friendly label (`label`). Be sure to specify on which of these you want to match.
 
 ## Condition Sets
 

+ 2 - 1
docs/release-notes/index.md

@@ -13,8 +13,9 @@ This page contains a history of all major and minor releases since NetBox v2.0.
 #### [Version 4.4](./version-4.4.md) (September 2025)
 
 * Background Jobs for Bulk Operations ([#19589](https://github.com/netbox-community/netbox/issues/19589), [#19891](https://github.com/netbox-community/netbox/issues/19891))
-* Logging Mechanism for Background Jobs ([#19891](https://github.com/netbox-community/netbox/issues/19816))
+* Logging Mechanism for Background Jobs ([#19816](https://github.com/netbox-community/netbox/issues/19816))
 * Changelog Comments ([#19713](https://github.com/netbox-community/netbox/issues/19713))
+* Config Context Data Validation ([#19377](https://github.com/netbox-community/netbox/issues/19377))
 
 #### [Version 4.3](./version-4.3.md) (May 2025)
 

+ 60 - 0
docs/release-notes/version-4.3.md

@@ -1,5 +1,60 @@
 # NetBox v4.3
 
+## v4.3.7 (2025-08-26)
+
+### Enhancements
+
+* [#18147](https://github.com/netbox-community/netbox/issues/18147) - Add device & VM interface counts under related objects for VRFs
+* [#19990](https://github.com/netbox-community/netbox/issues/19990) - Button to add a missing prerequisite now includes a return URL
+* [#20122](https://github.com/netbox-community/netbox/issues/20122) - Improve color contrast of highlighted data under changelog diff view
+* [#20131](https://github.com/netbox-community/netbox/issues/20131) - Add object selector for interface to the MAC address edit form
+
+### Bug Fixes
+
+* [#18916](https://github.com/netbox-community/netbox/issues/18916) - Fix dynamic dropdown selection styling for required fields when no selection is made
+* [#19645](https://github.com/netbox-community/netbox/issues/19645) - Fix interface selection when adding a cable for a virtual chassis master
+* [#19669](https://github.com/netbox-community/netbox/issues/19669) - Restore token authentication support for fetching media assets
+* [#19970](https://github.com/netbox-community/netbox/issues/19970) - Device role child device counts should be cumulative
+* [#20012](https://github.com/netbox-community/netbox/issues/20012) - Fix support for `empty` filter lookup on custom fields
+* [#20043](https://github.com/netbox-community/netbox/issues/20043) - Fix page styling when rack elevations are embedded
+* [#20098](https://github.com/netbox-community/netbox/issues/20098) - Fix `AttributeError` exception when assigning tags during bulk import
+* [#20120](https://github.com/netbox-community/netbox/issues/20120) - Fix REST API serialization of jobs under `/api/core/background-tasks/`
+* [#20157](https://github.com/netbox-community/netbox/issues/20157) - Fix `IntegrityError` exception when a duplicate notification is triggered
+* [#20164](https://github.com/netbox-community/netbox/issues/20164) - Fix `ValueError` exception when attempting to add power outlets to devices in bulk
+
+---
+
+## v4.3.6 (2025-08-12)
+
+### Enhancements
+
+* [#17222](https://github.com/netbox-community/netbox/issues/17222) - Made unread notifications more visible with improved styling and positioning
+* [#18843](https://github.com/netbox-community/netbox/issues/18843) - Include color name when exporting cables
+* [#18873](https://github.com/netbox-community/netbox/issues/18873) - Add a request timeout parameter to the RSS feed dashboard widget
+* [#19622](https://github.com/netbox-community/netbox/issues/19622) - Allow sharing GraphQL queries as links
+* [#19728](https://github.com/netbox-community/netbox/issues/19728) - Added C18 power port type for audio devices
+* [#19968](https://github.com/netbox-community/netbox/issues/19968) - Improve object type selection form field when editing permissions
+* [#19977](https://github.com/netbox-community/netbox/issues/19977) - Improve performance when filtering device components by site, location, or rack
+
+### Bug Fixes
+
+* [#19321](https://github.com/netbox-community/netbox/issues/19321) - Reduce redundant database queries when bulk importing devices
+* [#19379](https://github.com/netbox-community/netbox/issues/19379) - Support singular VLAN IDs in list when editing a VLAN group
+* [#19812](https://github.com/netbox-community/netbox/issues/19812) - Implement `contains` GraphQL filter for IPAM prefixes and IP ranges
+* [#19917](https://github.com/netbox-community/netbox/issues/19917) - Ensure deterministic ordering of duplicate MAC addresses
+* [#19996](https://github.com/netbox-community/netbox/issues/19996) - Correct dynamic query parameters for IP Address field in Add/Edit Service form
+* [#19998](https://github.com/netbox-community/netbox/issues/19998) - Fix missing changelog records for deleted tags
+* [#19999](https://github.com/netbox-community/netbox/issues/19999) - Corrected excessive whitespace in script list dashboard widget
+* [#20001](https://github.com/netbox-community/netbox/issues/20001) - `is_api_request()` should not evaluate a request's content type
+* [#20009](https://github.com/netbox-community/netbox/issues/20009) - Ensure search parameter is escaped for export links under object list views
+* [#20017](https://github.com/netbox-community/netbox/issues/20017) - Fix highlighting of changed lines in changelog data
+* [#20023](https://github.com/netbox-community/netbox/issues/20023) - Add GiST index on prefixes table to vastly improve bulk deletion time
+* [#20030](https://github.com/netbox-community/netbox/issues/20030) - Fix height of object list action buttons & others
+* [#20033](https://github.com/netbox-community/netbox/issues/20033) - Fix `TypeError` exception when bulk deleting bookmarks
+* [#20056](https://github.com/netbox-community/netbox/issues/20056) - Fixed missing RF role options in device type schema validation
+
+---
+
 ## v4.3.5 (2025-07-29)
 
 ### Enhancements
@@ -16,6 +71,11 @@
 * [#19934](https://github.com/netbox-community/netbox/issues/19934) - Added missing description field to tenant bulk edit form
 * [#19956](https://github.com/netbox-community/netbox/issues/19956) - Prevent duplicate deletion records in changelog from cascading deletions
 
+!!! note "Plugin Developer Advisory"
+    The fix for bug [#18900](https://github.com/netbox-community/netbox/issues/18900) now raises explicit exceptions when API endpoints attempt to paginate unordered querysets. Plugin maintainers should review their API viewsets to ensure proper queryset ordering is applied before pagination, either by using `.order_by()` on querysets or by setting `ordering` in model Meta classes. Previously silent pagination issues in plugin code will now raise `QuerySetNotOrdered` exceptions and may require updates to maintain compatibility.
+
+---
+
 ## v4.3.4 (2025-07-15)
 
 ### Enhancements

+ 29 - 3
docs/release-notes/version-4.4.md

@@ -1,6 +1,6 @@
 # NetBox v4.4
 
-## v4.4.0 (FUTURE)
+## v4.4.0 (2025-09-02)
 
 ### New Features
 
@@ -8,7 +8,7 @@
 
 Most bulk operations, such as the import, modification, or deletion of objects can now be executed as a background job. This frees the user to continue working in NetBox while the bulk operation is processed. Once completed, the user will be notified of the job's result.
 
-#### Logging Mechanism for Background Jobs ([#19891](https://github.com/netbox-community/netbox/issues/19816))
+#### Logging Mechanism for Background Jobs ([#19816](https://github.com/netbox-community/netbox/issues/19816))
 
 A dedicated logging mechanism has been implemented for background jobs. Jobs can now easily record log messages by calling e.g. `self.logger.info("Log message")` under the `run()` method. These messages are displayed along with the job's resulting data. Supported log levels include `DEBUG`, `INFO`, `WARNING`, and `ERROR`.
 
@@ -16,25 +16,41 @@ A dedicated logging mechanism has been implemented for background jobs. Jobs can
 
 When creating, editing, or deleting objects in NetBox, users now have the option of providing a short message explaining the change. This message will be recorded on the resulting changelog records for all affected objects.
 
+#### Config Context Data Validation ([#19377](https://github.com/netbox-community/netbox/issues/19377))
+
+A new ConfigContextProfile model has been introduced to support JSON schema validation for config context data. If a validation schema has been defined for a profile, all config contexts assigned to it will have their data validated against the schema whenever a change is made. (The assignment of a config context to a profile is optional.)
+
 ### Enhancements
 
 * [#17413](https://github.com/netbox-community/netbox/issues/17413) - Platforms belonging to different manufacturers may now have identical names
 * [#18204](https://github.com/netbox-community/netbox/issues/18204) - Improved layout of the image attachments view & tables
 * [#18528](https://github.com/netbox-community/netbox/issues/18528) - Introduced the `HOSTNAME` configuration parameter to override the system hostname reported by NetBox
+* [#18984](https://github.com/netbox-community/netbox/issues/18984) - Added a `status` field for rack reservations
 * [#18990](https://github.com/netbox-community/netbox/issues/18990) - Image attachments now include an optional description field
 * [#19134](https://github.com/netbox-community/netbox/issues/19134) - Interface transmit power now accepts negative values
 * [#19231](https://github.com/netbox-community/netbox/issues/19231) - Bulk renaming support has been implemented in the UI for most object types
 * [#19591](https://github.com/netbox-community/netbox/issues/19591) - Thumbnails for all images attached to an object are now displayed under a dedicated tab
 * [#19722](https://github.com/netbox-community/netbox/issues/19722) - The REST API endpoint for object types has been extended to include additional details
 * [#19739](https://github.com/netbox-community/netbox/issues/19739) - Introduced a user preference for CSV delimiter
+* [#19740](https://github.com/netbox-community/netbox/issues/19740) - Enable nesting of platforms within a hierarchy for improved organization
+* [#19773](https://github.com/netbox-community/netbox/issues/19773) - Extend the system UI view with additional information
 * [#19893](https://github.com/netbox-community/netbox/issues/19893) - The `/api/status/` REST API endpoint now includes the system hostname
 * [#19920](https://github.com/netbox-community/netbox/issues/19920) - Contacts can now be assigned to ASNs
 * [#19945](https://github.com/netbox-community/netbox/issues/19945) - Introduce a new custom script variable to represent decimal values
 * [#19965](https://github.com/netbox-community/netbox/issues/19965) - Add REST & GraphQL API request counters to the Prometheus metrics exporter
+* [#20029](https://github.com/netbox-community/netbox/issues/20029) - Include complete representation of object type in webhook payload data
 
 ### Plugins
 
+* [#18006](https://github.com/netbox-community/netbox/issues/18006) - A Javascript is now triggered when UI is toggled between light and dark mode
 * [#19735](https://github.com/netbox-community/netbox/issues/19735) - Custom individual and bulk operations can now be registered under individual views using `ObjectAction`
+* [#20003](https://github.com/netbox-community/netbox/issues/20003) - Enable registration of callbacks to provide supplementary webhook payload data
+* [#20115](https://github.com/netbox-community/netbox/issues/20115) - Support the use of ArrayColumn for plugin tables
+* [#20129](https://github.com/netbox-community/netbox/issues/20129) - Enable plugins to register custom model features
+
+### Deprecations
+
+* [#19738](https://github.com/netbox-community/netbox/issues/19738) - The direct assignment of VLANs to sites is now discouraged in favor of VLAN groups
 
 ### Other Changes
 
@@ -42,10 +58,11 @@ When creating, editing, or deleting objects in NetBox, users now have the option
 * [#18588](https://github.com/netbox-community/netbox/issues/18588) - The "Service" model has been renamed to "Application Service" for clarity (UI change only)
 * [#19829](https://github.com/netbox-community/netbox/issues/19829) - The REST API endpoint for object types is now available under `/api/core/`
 * [#19924](https://github.com/netbox-community/netbox/issues/19924) - ObjectTypes are now tracked as concrete objects in the database (alongside ContentTypes)
-* [#19973](https://github.com/netbox-community/netbox/issues/19973) - Miscellaneous improvements to the `nbhshell` management command
+* [#19973](https://github.com/netbox-community/netbox/issues/19973) - Miscellaneous improvements to the `nbshell` management command
 
 ### REST API Changes
 
+* All object types which support change logging now support the inclusion of a `changelog_message` for write operations. If provided, this message will be attached to the changelog record resulting from the change (if successful).
 * The `/api/status/` endpoint now includes the system hostname.
 * The `/api/extras/object-types/` endpoint is now available at `/api/core/object-types/`. (The original endpoint will be removed in NetBox v4.5.)
 * The `/api/core/object-types/` endpoint has been expanded to include the following read-only fields:
@@ -55,7 +72,16 @@ When creating, editing, or deleting objects in NetBox, users now have the option
     * `is_plugin_model`
     * `rest_api_endpoint`
     * `description`
+* Introduced the `/api/extras/config-context-profiles/` endpoint
+* core.Job
+    * Added the read-only `log_entries` array field
 * dcim.Interface
     * The `tx_power` field now accepts negative values
+* dcim.RackReservation
+    * Added the `status` choice field
+* dcim.Platform
+    * Add an optional `parent` foreign key field to support nesting
+* extras.ConfigContext
+    * Added the optional `profile` foreign key field
 * extras.ImageAttachment
     * Added an optional `description` field

+ 3 - 0
mkdocs.yml

@@ -30,6 +30,8 @@ plugins:
         python:
           paths: ["netbox"]
           options:
+            docstring_options:
+              warn_missing_types: false
             heading_level: 3
             members_order: source
             show_root_heading: true
@@ -226,6 +228,7 @@ nav:
         - Extras:
             - Bookmark: 'models/extras/bookmark.md'
             - ConfigContext: 'models/extras/configcontext.md'
+            - ConfigContextProfile: 'models/extras/configcontextprofile.md'
             - ConfigTemplate: 'models/extras/configtemplate.md'
             - CustomField: 'models/extras/customfield.md'
             - CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.md'

+ 1 - 5
netbox/circuits/urls.py

@@ -35,11 +35,7 @@ urlpatterns = [
     path('circuit-group-assignments/<int:pk>/', include(get_model_urls('circuits', 'circuitgroupassignment'))),
 
     # Virtual circuits
-    path('virtual-circuits/', views.VirtualCircuitListView.as_view(), name='virtualcircuit_list'),
-    path('virtual-circuits/add/', views.VirtualCircuitEditView.as_view(), name='virtualcircuit_add'),
-    path('virtual-circuits/import/', views.VirtualCircuitBulkImportView.as_view(), name='virtualcircuit_bulk_import'),
-    path('virtual-circuits/edit/', views.VirtualCircuitBulkEditView.as_view(), name='virtualcircuit_bulk_edit'),
-    path('virtual-circuits/delete/', views.VirtualCircuitBulkDeleteView.as_view(), name='virtualcircuit_bulk_delete'),
+    path('virtual-circuits/', include(get_model_urls('circuits', 'virtualcircuit', detail=False))),
     path('virtual-circuits/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuit'))),
 
     path('virtual-circuit-types/', include(get_model_urls('circuits', 'virtualcircuittype', detail=False))),

+ 6 - 1
netbox/circuits/views.py

@@ -687,6 +687,7 @@ class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
 # Virtual circuits
 #
 
+@register_model_view(VirtualCircuit, 'list', path='', detail=False)
 class VirtualCircuitListView(generic.ObjectListView):
     queryset = VirtualCircuit.objects.annotate(
         termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
@@ -701,6 +702,7 @@ class VirtualCircuitView(generic.ObjectView):
     queryset = VirtualCircuit.objects.all()
 
 
+@register_model_view(VirtualCircuit, 'add', detail=False)
 @register_model_view(VirtualCircuit, 'edit')
 class VirtualCircuitEditView(generic.ObjectEditView):
     queryset = VirtualCircuit.objects.all()
@@ -712,6 +714,7 @@ class VirtualCircuitDeleteView(generic.ObjectDeleteView):
     queryset = VirtualCircuit.objects.all()
 
 
+@register_model_view(VirtualCircuit, 'bulk_import', path='import', detail=False)
 class VirtualCircuitBulkImportView(generic.BulkImportView):
     queryset = VirtualCircuit.objects.all()
     model_form = forms.VirtualCircuitImportForm
@@ -727,6 +730,7 @@ class VirtualCircuitBulkImportView(generic.BulkImportView):
         return data
 
 
+@register_model_view(VirtualCircuit, 'bulk_edit', path='edit', detail=False)
 class VirtualCircuitBulkEditView(generic.BulkEditView):
     queryset = VirtualCircuit.objects.annotate(
         termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
@@ -737,11 +741,12 @@ class VirtualCircuitBulkEditView(generic.BulkEditView):
 
 
 @register_model_view(VirtualCircuit, 'bulk_rename', path='rename', detail=False)
-class VirtualCircuitulkRenameView(generic.BulkRenameView):
+class VirtualCircuitBulkRenameView(generic.BulkRenameView):
     queryset = VirtualCircuit.objects.all()
     field_name = 'cid'
 
 
+@register_model_view(VirtualCircuit, 'bulk_delete', path='delete', detail=False)
 class VirtualCircuitBulkDeleteView(generic.BulkDeleteView):
     queryset = VirtualCircuit.objects.annotate(
         termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')

+ 10 - 10
netbox/core/api/serializers_/object_types.py

@@ -1,13 +1,13 @@
 import inspect
 
-from django.urls import NoReverseMatch, reverse
+from django.urls import NoReverseMatch
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from core.models import ObjectType
 from netbox.api.serializers import BaseModelSerializer
-from utilities.views import get_viewname
+from utilities.views import get_action_url
 
 __all__ = (
     'ObjectTypeSerializer',
@@ -15,7 +15,7 @@ __all__ = (
 
 
 class ObjectTypeSerializer(BaseModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:objecttype-detail')
+    url = serializers.HyperlinkedIdentityField(view_name='core-api:objecttype-detail')
     app_name = serializers.CharField(source='app_verbose_name', read_only=True)
     model_name = serializers.CharField(source='model_verbose_name', read_only=True)
     model_name_plural = serializers.CharField(source='model_verbose_name_plural', read_only=True)
@@ -26,19 +26,19 @@ class ObjectTypeSerializer(BaseModelSerializer):
     class Meta:
         model = ObjectType
         fields = [
-            'id', 'url', 'display', 'app_label', 'app_name', 'model', 'model_name', 'model_name_plural',
-            'is_plugin_model', 'rest_api_endpoint', 'description',
+            'id', 'url', 'display', 'app_label', 'app_name', 'model', 'model_name', 'model_name_plural', 'public',
+            'features', 'is_plugin_model', 'rest_api_endpoint', 'description',
         ]
+        read_only_fields = ['public', 'features']
 
     @extend_schema_field(OpenApiTypes.STR)
     def get_rest_api_endpoint(self, obj):
         if not (model := obj.model_class()):
             return
-        if viewname := get_viewname(model, action='list', rest_api=True):
-            try:
-                return reverse(viewname)
-            except NoReverseMatch:
-                return
+        try:
+            return get_action_url(model, action='list', rest_api=True)
+        except NoReverseMatch:
+            return
 
     @extend_schema_field(OpenApiTypes.STR)
     def get_description(self, obj):

+ 12 - 2
netbox/core/api/serializers_/tasks.py

@@ -18,8 +18,8 @@ class BackgroundTaskSerializer(serializers.Serializer):
     description = serializers.CharField()
     origin = serializers.CharField()
     func_name = serializers.CharField()
-    args = serializers.ListField(child=serializers.CharField())
-    kwargs = serializers.DictField()
+    args = serializers.SerializerMethodField()
+    kwargs = serializers.SerializerMethodField()
     result = serializers.CharField()
     timeout = serializers.IntegerField()
     result_ttl = serializers.IntegerField()
@@ -42,6 +42,16 @@ class BackgroundTaskSerializer(serializers.Serializer):
     is_scheduled = serializers.BooleanField()
     is_stopped = serializers.BooleanField()
 
+    def get_args(self, obj) -> list:
+        return [
+            str(arg) for arg in obj.args
+        ]
+
+    def get_kwargs(self, obj) -> dict:
+        return {
+            key: str(value) for key, value in obj.kwargs.items()
+        }
+
     def get_position(self, obj) -> int:
         return obj.get_position()
 

+ 1 - 1
netbox/core/api/urls.py

@@ -9,7 +9,7 @@ router.APIRootView = views.CoreRootView
 router.register('data-sources', views.DataSourceViewSet)
 router.register('data-files', views.DataFileViewSet)
 router.register('jobs', views.JobViewSet)
-router.register('object-changes', views.ObjectChangeViewSet)
+router.register('object-changes', views.ObjectChangeViewSet, basename='objectchange')
 router.register('object-types', views.ObjectTypeViewSet)
 router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue')
 router.register('background-workers', views.BackgroundWorkerViewSet, basename='rqworker')

+ 3 - 1
netbox/core/api/views.py

@@ -78,10 +78,12 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
     Retrieve a list of recent changes.
     """
     metadata_class = ContentTypeMetadata
-    queryset = ObjectChange.objects.valid_models()
     serializer_class = serializers.ObjectChangeSerializer
     filterset_class = filtersets.ObjectChangeFilterSet
 
+    def get_queryset(self):
+        return ObjectChange.objects.valid_models()
+
 
 class ObjectTypeViewSet(ReadOnlyModelViewSet):
     """

+ 8 - 2
netbox/core/filtersets.py

@@ -134,15 +134,18 @@ class JobFilterSet(BaseFilterSet):
         )
 
 
-class ObjectTypeFilterSet(django_filters.FilterSet):
+class ObjectTypeFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label=_('Search'),
     )
+    features = django_filters.CharFilter(
+        method='filter_features'
+    )
 
     class Meta:
         model = ObjectType
-        fields = ('id', 'app_label', 'model')
+        fields = ('id', 'app_label', 'model', 'public')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -152,6 +155,9 @@ class ObjectTypeFilterSet(django_filters.FilterSet):
             Q(model__icontains=value)
         )
 
+    def filter_features(self, queryset, name, value):
+        return queryset.filter(features__icontains=value)
+
 
 class ObjectChangeFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(

+ 8 - 0
netbox/core/graphql/mixins.py

@@ -7,10 +7,12 @@ from django.contrib.contenttypes.models import ContentType
 from core.models import ObjectChange
 
 if TYPE_CHECKING:
+    from core.graphql.types import DataFileType, DataSourceType
     from netbox.core.graphql.types import ObjectChangeType
 
 __all__ = (
     'ChangelogMixin',
+    'SyncedDataMixin',
 )
 
 
@@ -25,3 +27,9 @@ class ChangelogMixin:
             changed_object_id=self.pk
         )
         return object_changes.restrict(info.context.request.user, 'view')
+
+
+@strawberry.type
+class SyncedDataMixin:
+    data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
+    data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None

+ 35 - 37
netbox/core/jobs.py

@@ -1,4 +1,3 @@
-import logging
 import sys
 from datetime import timedelta
 from importlib import import_module
@@ -17,8 +16,6 @@ from utilities.proxy import resolve_proxies
 from .choices import DataSourceStatusChoices, JobIntervalChoices
 from .models import DataSource
 
-logger = logging.getLogger(__name__)
-
 
 class SyncDataSourceJob(JobRunner):
     """
@@ -69,7 +66,11 @@ class SystemHousekeepingJob(JobRunner):
 
     def run(self, *args, **kwargs):
         # Skip if running in development or test mode
-        if settings.DEBUG or 'test' in sys.argv:
+        if settings.DEBUG:
+            self.logger.warning("Aborting execution: Debug is enabled")
+            return
+        if 'test' in sys.argv:
+            self.logger.warning("Aborting execution: Tests are running")
             return
 
         self.send_census_report()
@@ -78,17 +79,16 @@ class SystemHousekeepingJob(JobRunner):
         self.delete_expired_jobs()
         self.check_for_new_releases()
 
-    @staticmethod
-    def send_census_report():
+    def send_census_report(self):
         """
         Send a census report (if enabled).
         """
-        logging.info("Reporting census data...")
+        self.logger.info("Reporting census data...")
         if settings.ISOLATED_DEPLOYMENT:
-            logging.info("ISOLATED_DEPLOYMENT is enabled; skipping")
+            self.logger.info("ISOLATED_DEPLOYMENT is enabled; skipping")
             return
         if not settings.CENSUS_REPORTING_ENABLED:
-            logging.info("CENSUS_REPORTING_ENABLED is disabled; skipping")
+            self.logger.info("CENSUS_REPORTING_ENABLED is disabled; skipping")
             return
 
         census_data = {
@@ -106,73 +106,71 @@ class SystemHousekeepingJob(JobRunner):
         except requests.exceptions.RequestException:
             pass
 
-    @staticmethod
-    def clear_expired_sessions():
+    def clear_expired_sessions(self):
         """
         Clear any expired sessions from the database.
         """
-        logging.info("Clearing expired sessions...")
+        self.logger.info("Clearing expired sessions...")
         engine = import_module(settings.SESSION_ENGINE)
         try:
             engine.SessionStore.clear_expired()
-            logging.info("Sessions cleared.")
+            self.logger.info("Sessions cleared.")
         except NotImplementedError:
-            logging.warning(
+            self.logger.warning(
                 f"The configured session engine ({settings.SESSION_ENGINE}) does not support "
                 f"clearing sessions; skipping."
             )
 
-    @staticmethod
-    def prune_changelog():
+    def prune_changelog(self):
         """
         Delete any ObjectChange records older than the configured changelog retention time (if any).
         """
-        logging.info("Pruning old changelog entries...")
+        self.logger.info("Pruning old changelog entries...")
         config = Config()
         if not config.CHANGELOG_RETENTION:
-            logging.info("No retention period specified; skipping.")
+            self.logger.info("No retention period specified; skipping.")
             return
 
         cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION)
-        logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days")
-        logging.debug(f"Cut-off time: {cutoff}")
+        self.logger.debug(
+            f"Changelog retention period: {config.CHANGELOG_RETENTION} days ({cutoff:%Y-%m-%d %H:%M:%S})"
+        )
 
         count = ObjectChange.objects.filter(time__lt=cutoff).delete()[0]
-        logging.info(f"Deleted {count} expired records")
+        self.logger.info(f"Deleted {count} expired changelog records")
 
-    @staticmethod
-    def delete_expired_jobs():
+    def delete_expired_jobs(self):
         """
         Delete any jobs older than the configured retention period (if any).
         """
-        logging.info("Deleting expired jobs...")
+        self.logger.info("Deleting expired jobs...")
         config = Config()
         if not config.JOB_RETENTION:
-            logging.info("No retention period specified; skipping.")
+            self.logger.info("No retention period specified; skipping.")
             return
 
         cutoff = timezone.now() - timedelta(days=config.JOB_RETENTION)
-        logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days")
-        logging.debug(f"Cut-off time: {cutoff}")
+        self.logger.debug(
+            f"Job retention period: {config.JOB_RETENTION} days ({cutoff:%Y-%m-%d %H:%M:%S})"
+        )
 
         count = Job.objects.filter(created__lt=cutoff).delete()[0]
-        logging.info(f"Deleted {count} expired records")
+        self.logger.info(f"Deleted {count} expired jobs")
 
-    @staticmethod
-    def check_for_new_releases():
+    def check_for_new_releases(self):
         """
         Check for new releases and cache the latest release.
         """
-        logging.info("Checking for new releases...")
+        self.logger.info("Checking for new releases...")
         if settings.ISOLATED_DEPLOYMENT:
-            logging.info("ISOLATED_DEPLOYMENT is enabled; skipping")
+            self.logger.info("ISOLATED_DEPLOYMENT is enabled; skipping")
             return
         if not settings.RELEASE_CHECK_URL:
-            logging.info("RELEASE_CHECK_URL is not set; skipping")
+            self.logger.info("RELEASE_CHECK_URL is not set; skipping")
             return
 
         # Fetch the latest releases
-        logging.debug(f"Release check URL: {settings.RELEASE_CHECK_URL}")
+        self.logger.debug(f"Release check URL: {settings.RELEASE_CHECK_URL}")
         try:
             response = requests.get(
                 url=settings.RELEASE_CHECK_URL,
@@ -181,7 +179,7 @@ class SystemHousekeepingJob(JobRunner):
             )
             response.raise_for_status()
         except requests.exceptions.RequestException as exc:
-            logging.error(f"Error fetching release: {exc}")
+            self.logger.error(f"Error fetching release: {exc}")
             return
 
         # Determine the most recent stable release
@@ -191,8 +189,8 @@ class SystemHousekeepingJob(JobRunner):
                 continue
             releases.append((version.parse(release['tag_name']), release.get('html_url')))
         latest_release = max(releases)
-        logging.debug(f"Found {len(response.json())} releases; {len(releases)} usable")
-        logging.info(f"Latest release: {latest_release[0]}")
+        self.logger.debug(f"Found {len(response.json())} releases; {len(releases)} usable")
+        self.logger.info(f"Latest release: {latest_release[0]}")
 
         # Cache the most recent release
         cache.set('latest_release', latest_release, None)

+ 2 - 2
netbox/core/management/commands/nbshell.py

@@ -78,8 +78,8 @@ class Command(BaseCommand):
         for app_label in app_labels:
             app_name = apps.get_app_config(app_label).verbose_name
             print(f'{app_name}:')
-            for m in self.django_models[app_label]:
-                print(f'  {m}')
+            for model in self.django_models[app_label]:
+                print(f'  {app_label}.{model}')
 
     def get_namespace(self):
         namespace = defaultdict(SimpleNamespace)

+ 11 - 5
netbox/core/models/object_types.py

@@ -1,3 +1,4 @@
+import inspect
 from collections import defaultdict
 
 from django.contrib.contenttypes.models import ContentType
@@ -64,6 +65,9 @@ class ObjectTypeManager(models.Manager):
         Retrieve or create and return the ObjectType for a model.
         """
         from netbox.models.features import get_model_features, model_is_public
+
+        if not inspect.isclass(model):
+            model = model.__class__
         opts = self._get_opts(model, for_concrete_model)
 
         try:
@@ -75,7 +79,7 @@ class ObjectTypeManager(models.Manager):
                 app_label=opts.app_label,
                 model=opts.model_name,
                 public=model_is_public(model),
-                features=get_model_features(model.__class__),
+                features=get_model_features(model),
             )[0]
 
         return ot
@@ -93,6 +97,8 @@ class ObjectTypeManager(models.Manager):
         needed_models = defaultdict(set)
         needed_opts = defaultdict(list)
         for model in models:
+            if not inspect.isclass(model):
+                model = model.__class__
             opts = self._get_opts(model, for_concrete_models)
             needed_models[opts.app_label].add(opts.model_name)
             needed_opts[(opts.app_label, opts.model_name)].append(model)
@@ -117,7 +123,7 @@ class ObjectTypeManager(models.Manager):
                     app_label=app_label,
                     model=model_name,
                     public=model_is_public(model),
-                    features=get_model_features(model.__class__),
+                    features=get_model_features(model),
                 )
 
         return results
@@ -135,9 +141,9 @@ class ObjectTypeManager(models.Manager):
         """
         Return ObjectTypes only for models which support the given feature.
 
-        Only ObjectTypes which list the specified feature will be included. Supported features are declared in
-        netbox.models.features.FEATURES_MAP. For example, we can find all ObjectTypes for models which support event
-        rules with:
+        Only ObjectTypes which list the specified feature will be included. Supported features are declared in the
+        application registry under `registry["model_features"]`. For example, we can find all ObjectTypes for models
+        which support event rules with:
 
             ObjectType.objects.with_feature('event_rules')
         """

+ 12 - 0
netbox/core/signals.py

@@ -14,6 +14,7 @@ from core.choices import JobStatusChoices, ObjectChangeActionChoices
 from core.events import *
 from core.models import ObjectType
 from extras.events import enqueue_event
+from extras.models import Tag
 from extras.utils import run_validators
 from netbox.config import get_config
 from netbox.context import current_request, events_queue
@@ -104,6 +105,17 @@ def handle_changed_object(sender, instance, **kwargs):
         # m2m_changed with objects added or removed
         m2m_changed = True
         event_type = OBJECT_UPDATED
+    elif kwargs.get('action') == 'post_clear':
+        # Handle clearing of an M2M field
+        if kwargs.get('model') == Tag and getattr(instance, '_prechange_snapshot', {}).get('tags'):
+            # Handle generation of M2M changes for Tags which have a previous value (ignoring changes where the
+            # prechange snapshot is empty)
+            m2m_changed = True
+            event_type = OBJECT_UPDATED
+        else:
+            # Other endpoints are unimpacted as they send post_add and post_remove
+            # This will impact changes that utilize clear() however so we may want to give consideration for this branch
+            return
     else:
         return
 

+ 45 - 0
netbox/core/tests/test_filtersets.py

@@ -241,3 +241,48 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+
+class ObjectTypeTestCase(TestCase, BaseFilterSetTests):
+    queryset = ObjectType.objects.all()
+    filterset = ObjectTypeFilterSet
+    ignore_fields = (
+        'custom_fields',
+        'custom_links',
+        'event_rules',
+        'export_templates',
+        'object_permissions',
+        'saved_filters',
+    )
+
+    def test_q(self):
+        params = {'q': 'vrf'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_app_label(self):
+        self.assertEqual(
+            self.filterset({'app_label': ['dcim']}, self.queryset).qs.count(),
+            ObjectType.objects.filter(app_label='dcim').count(),
+        )
+
+    def test_model(self):
+        self.assertEqual(
+            self.filterset({'model': ['site']}, self.queryset).qs.count(),
+            ObjectType.objects.filter(model='site').count(),
+        )
+
+    def test_public(self):
+        self.assertEqual(
+            self.filterset({'public': True}, self.queryset).qs.count(),
+            ObjectType.objects.filter(public=True).count(),
+        )
+        self.assertEqual(
+            self.filterset({'public': False}, self.queryset).qs.count(),
+            ObjectType.objects.filter(public=False).count(),
+        )
+
+    def test_feature(self):
+        self.assertEqual(
+            self.filterset({'features': 'tags'}, self.queryset).qs.count(),
+            ObjectType.objects.filter(features__contains=['tags']).count(),
+        )

+ 6 - 0
netbox/core/tests/test_views.py

@@ -1,3 +1,4 @@
+import json
 import urllib.parse
 import uuid
 from datetime import datetime
@@ -366,6 +367,11 @@ class SystemTestCase(TestCase):
         # Test export
         response = self.client.get(f"{reverse('core:system')}?export=true")
         self.assertEqual(response.status_code, 200)
+        data = json.loads(response.content)
+        self.assertIn('netbox_release', data)
+        self.assertIn('plugins', data)
+        self.assertIn('config', data)
+        self.assertIn('objects', data)
 
     def test_system_view_with_config_revision(self):
         ConfigRevision.objects.create()

+ 33 - 7
netbox/core/views.py

@@ -1,7 +1,7 @@
 import json
 import platform
 
-from django import __version__ as DJANGO_VERSION
+from django import __version__ as django_version
 from django.conf import settings
 from django.contrib import messages
 from django.contrib.auth.mixins import UserPassesTestMixin
@@ -23,10 +23,11 @@ from rq.worker_registration import clean_worker_registry
 from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
 from netbox.config import get_config, PARAMS
 from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
-from netbox.registry import registry
+from netbox.plugins.utils import get_installed_plugins
 from netbox.views import generic
 from netbox.views.generic.base import BaseObjectView
 from netbox.views.generic.mixins import TableMixin
+from utilities.apps import get_installed_apps
 from utilities.data import shallow_compare_dict
 from utilities.forms import ConfirmationForm
 from utilities.htmx import htmx_partial
@@ -216,17 +217,23 @@ class JobBulkDeleteView(generic.BulkDeleteView):
 
 @register_model_view(ObjectChange, 'list', path='', detail=False)
 class ObjectChangeListView(generic.ObjectListView):
-    queryset = ObjectChange.objects.valid_models()
+    queryset = None
     filterset = filtersets.ObjectChangeFilterSet
     filterset_form = forms.ObjectChangeFilterForm
     table = tables.ObjectChangeTable
     template_name = 'core/objectchange_list.html'
     actions = (BulkExport,)
 
+    def get_queryset(self, request):
+        return ObjectChange.objects.valid_models()
+
 
 @register_model_view(ObjectChange)
 class ObjectChangeView(generic.ObjectView):
-    queryset = ObjectChange.objects.valid_models()
+    queryset = None
+
+    def get_queryset(self, request):
+        return ObjectChange.objects.valid_models()
 
     def get_extra_context(self, request, instance):
         related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
@@ -546,7 +553,7 @@ class SystemView(UserPassesTestMixin, View):
 
     def get(self, request):
 
-        # System stats
+        # System status
         psql_version = db_name = db_size = None
         try:
             with connection.cursor() as cursor:
@@ -561,7 +568,7 @@ class SystemView(UserPassesTestMixin, View):
             pass
         stats = {
             'netbox_release': settings.RELEASE,
-            'django_version': DJANGO_VERSION,
+            'django_version': django_version,
             'python_version': platform.python_version(),
             'postgresql_version': psql_version,
             'database_name': db_name,
@@ -569,19 +576,35 @@ class SystemView(UserPassesTestMixin, View):
             'rq_worker_count': Worker.count(get_connection('default')),
         }
 
+        # Django apps
+        django_apps = get_installed_apps()
+
         # Configuration
         config = get_config()
 
+        # Plugins
+        plugins = get_installed_plugins()
+
+        # Object counts
+        objects = {}
+        for ot in ObjectType.objects.public().order_by('app_label', 'model'):
+            if model := ot.model_class():
+                objects[ot] = model.objects.count()
+
         # Raw data export
         if 'export' in request.GET:
             stats['netbox_release'] = stats['netbox_release'].asdict()
             params = [param.name for param in PARAMS]
             data = {
                 **stats,
-                'plugins': registry['plugins']['installed'],
+                'django_apps': django_apps,
+                'plugins': plugins,
                 'config': {
                     k: getattr(config, k) for k in sorted(params)
                 },
+                'objects': {
+                    f'{ot.app_label}.{ot.model}': count for ot, count in objects.items()
+                },
             }
             response = HttpResponse(json.dumps(data, cls=ConfigJSONEncoder, indent=4), content_type='text/json')
             response['Content-Disposition'] = 'attachment; filename="netbox.json"'
@@ -594,7 +617,10 @@ class SystemView(UserPassesTestMixin, View):
 
         return render(request, 'core/system.html', {
             'stats': stats,
+            'django_apps': django_apps,
             'config': config,
+            'plugins': plugins,
+            'objects': objects,
         })
 
 

+ 9 - 0
netbox/dcim/api/serializers_/nested.py

@@ -6,11 +6,13 @@ from dcim import models
 
 __all__ = (
     'NestedDeviceBaySerializer',
+    'NestedDeviceRoleSerializer',
     'NestedDeviceSerializer',
     'NestedInterfaceSerializer',
     'NestedInterfaceTemplateSerializer',
     'NestedLocationSerializer',
     'NestedModuleBaySerializer',
+    'NestedPlatformSerializer',
     'NestedRegionSerializer',
     'NestedSiteGroupSerializer',
 )
@@ -102,3 +104,10 @@ class NestedModuleBaySerializer(WritableNestedSerializer):
     class Meta:
         model = models.ModuleBay
         fields = ['id', 'url', 'display_url', 'display', 'name']
+
+
+class NestedPlatformSerializer(WritableNestedSerializer):
+
+    class Meta:
+        model = models.Platform
+        fields = ['id', 'url', 'display_url', 'display', 'name']

+ 14 - 8
netbox/dcim/api/serializers_/platforms.py

@@ -1,26 +1,32 @@
+from rest_framework import serializers
+
 from dcim.models import Platform
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
-from netbox.api.fields import RelatedObjectCountField
-from netbox.api.serializers import NetBoxModelSerializer
+from netbox.api.serializers import NestedGroupModelSerializer
 from .manufacturers import ManufacturerSerializer
+from .nested import NestedPlatformSerializer
 
 __all__ = (
     'PlatformSerializer',
 )
 
 
-class PlatformSerializer(NetBoxModelSerializer):
+class PlatformSerializer(NestedGroupModelSerializer):
+    parent = NestedPlatformSerializer(required=False, allow_null=True, default=None)
     manufacturer = ManufacturerSerializer(nested=True, required=False, allow_null=True)
     config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
 
     # Related object counts
-    device_count = RelatedObjectCountField('devices')
-    virtualmachine_count = RelatedObjectCountField('virtual_machines')
+    device_count = serializers.IntegerField(read_only=True, default=0)
+    virtualmachine_count = serializers.IntegerField(read_only=True, default=0)
 
     class Meta:
         model = Platform
         fields = [
-            'id', 'url', 'display_url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description',
-            'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
+            'id', 'url', 'display_url', 'display', 'parent', 'name', 'slug', 'manufacturer', 'config_template',
+            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
+            'virtualmachine_count', '_depth',
         ]
-        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
+        brief_fields = (
+            'id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count', '_depth',
+        )

+ 18 - 6
netbox/dcim/api/serializers_/racks.py

@@ -137,17 +137,29 @@ class RackSerializer(RackBaseSerializer):
 
 
 class RackReservationSerializer(NetBoxModelSerializer):
-    rack = RackSerializer(nested=True)
-    user = UserSerializer(nested=True)
-    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+    rack = RackSerializer(
+        nested=True,
+    )
+    status = ChoiceField(
+        choices=RackReservationStatusChoices,
+        required=False,
+    )
+    user = UserSerializer(
+        nested=True,
+    )
+    tenant = TenantSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+    )
 
     class Meta:
         model = RackReservation
         fields = [
-            'id', 'url', 'display_url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant',
-            'description', 'comments', 'tags', 'custom_fields',
+            'id', 'url', 'display_url', 'display', 'rack', 'units', 'status', 'created', 'last_updated', 'user',
+            'tenant', 'description', 'comments', 'tags', 'custom_fields',
         ]
-        brief_fields = ('id', 'url', 'display', 'user', 'description', 'units')
+        brief_fields = ('id', 'url', 'display', 'status', 'user', 'description', 'units')
 
 
 class RackElevationDetailFilterSerializer(serializers.Serializer):

+ 4 - 4
netbox/dcim/api/serializers_/roles.py

@@ -1,3 +1,5 @@
+from rest_framework import serializers
+
 from dcim.models import DeviceRole, InventoryItemRole
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from netbox.api.fields import RelatedObjectCountField
@@ -13,10 +15,8 @@ __all__ = (
 class DeviceRoleSerializer(NestedGroupModelSerializer):
     parent = NestedDeviceRoleSerializer(required=False, allow_null=True, default=None)
     config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
-
-    # Related object counts
-    device_count = RelatedObjectCountField('devices')
-    virtualmachine_count = RelatedObjectCountField('virtual_machines')
+    device_count = serializers.IntegerField(read_only=True, default=0)
+    virtualmachine_count = serializers.IntegerField(read_only=True, default=0)
 
     class Meta:
         model = DeviceRole

+ 28 - 3
netbox/dcim/api/views.py

@@ -20,6 +20,7 @@ from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
 from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
 from utilities.api import get_serializer_for_model
 from utilities.query_functions import CollateAsChar
+from virtualization.models import VirtualMachine
 from . import serializers
 from .exceptions import MissingFilterException
 
@@ -351,7 +352,19 @@ class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
 #
 
 class DeviceRoleViewSet(NetBoxModelViewSet):
-    queryset = DeviceRole.objects.all()
+    queryset = DeviceRole.objects.add_related_count(
+        DeviceRole.objects.add_related_count(
+            DeviceRole.objects.all(),
+            VirtualMachine,
+            'role',
+            'virtualmachine_count',
+            cumulative=True
+        ),
+        Device,
+        'role',
+        'device_count',
+        cumulative=True
+    )
     serializer_class = serializers.DeviceRoleSerializer
     filterset_class = filtersets.DeviceRoleFilterSet
 
@@ -360,8 +373,20 @@ class DeviceRoleViewSet(NetBoxModelViewSet):
 # Platforms
 #
 
-class PlatformViewSet(NetBoxModelViewSet):
-    queryset = Platform.objects.all()
+class PlatformViewSet(MPTTLockedMixin, NetBoxModelViewSet):
+    queryset = Platform.objects.add_related_count(
+        Platform.objects.add_related_count(
+            Platform.objects.all(),
+            VirtualMachine,
+            'platform',
+            'virtualmachine_count',
+            cumulative=True
+        ),
+        Device,
+        'platform',
+        'device_count',
+        cumulative=True
+    )
     serializer_class = serializers.PlatformSerializer
     filterset_class = filtersets.PlatformFilterSet
 

+ 22 - 0
netbox/dcim/choices.py

@@ -139,6 +139,24 @@ class RackAirflowChoices(ChoiceSet):
     ]
 
 
+#
+# Rack reservations
+#
+
+class RackReservationStatusChoices(ChoiceSet):
+    key = 'RackReservation.status'
+
+    STATUS_PENDING = 'pending'
+    STATUS_ACTIVE = 'active'
+    STATUS_STALE = 'stale'
+
+    CHOICES = [
+        (STATUS_PENDING, _('Pending'), 'cyan'),
+        (STATUS_ACTIVE, _('Active'), 'green'),
+        (STATUS_STALE, _('Stale'), 'orange'),
+    ]
+
+
 #
 # DeviceTypes
 #
@@ -344,6 +362,7 @@ class PowerPortTypeChoices(ChoiceSet):
     TYPE_IEC_C8 = 'iec-60320-c8'
     TYPE_IEC_C14 = 'iec-60320-c14'
     TYPE_IEC_C16 = 'iec-60320-c16'
+    TYPE_IEC_C18 = 'iec-60320-c18'
     TYPE_IEC_C20 = 'iec-60320-c20'
     TYPE_IEC_C22 = 'iec-60320-c22'
     # IEC 60309
@@ -462,6 +481,7 @@ class PowerPortTypeChoices(ChoiceSet):
             (TYPE_IEC_C8, 'C8'),
             (TYPE_IEC_C14, 'C14'),
             (TYPE_IEC_C16, 'C16'),
+            (TYPE_IEC_C18, 'C18'),
             (TYPE_IEC_C20, 'C20'),
             (TYPE_IEC_C22, 'C22'),
         )),
@@ -599,6 +619,7 @@ class PowerOutletTypeChoices(ChoiceSet):
     TYPE_IEC_C7 = 'iec-60320-c7'
     TYPE_IEC_C13 = 'iec-60320-c13'
     TYPE_IEC_C15 = 'iec-60320-c15'
+    TYPE_IEC_C17 = 'iec-60320-c17'
     TYPE_IEC_C19 = 'iec-60320-c19'
     TYPE_IEC_C21 = 'iec-60320-c21'
     # IEC 60309
@@ -711,6 +732,7 @@ class PowerOutletTypeChoices(ChoiceSet):
             (TYPE_IEC_C7, 'C7'),
             (TYPE_IEC_C13, 'C13'),
             (TYPE_IEC_C15, 'C15'),
+            (TYPE_IEC_C17, 'C17'),
             (TYPE_IEC_C19, 'C19'),
             (TYPE_IEC_C21, 'C21'),
         )),

+ 60 - 14
netbox/dcim/filtersets.py

@@ -499,6 +499,10 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         to_field_name='slug',
         label=_('Location (slug)'),
     )
+    status = django_filters.MultipleChoiceFilter(
+        choices=RackReservationStatusChoices,
+        null_value=None
+    )
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
         label=_('User (ID)'),
@@ -547,14 +551,17 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
         to_field_name='slug',
         label=_('Manufacturer (slug)'),
     )
-    default_platform_id = django_filters.ModelMultipleChoiceFilter(
+    default_platform_id = TreeNodeMultipleChoiceFilter(
         queryset=Platform.objects.all(),
+        field_name='default_platform',
+        lookup_expr='in',
         label=_('Default platform (ID)'),
     )
-    default_platform = django_filters.ModelMultipleChoiceFilter(
-        field_name='default_platform__slug',
+    default_platform = TreeNodeMultipleChoiceFilter(
         queryset=Platform.objects.all(),
+        field_name='default_platform',
         to_field_name='slug',
+        lookup_expr='in',
         label=_('Default platform (slug)'),
     )
     has_front_image = django_filters.BooleanFilter(
@@ -979,6 +986,29 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet):
 
 
 class PlatformFilterSet(OrganizationalModelFilterSet):
+    parent_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Platform.objects.all(),
+        label=_('Immediate parent platform (ID)'),
+    )
+    parent = django_filters.ModelMultipleChoiceFilter(
+        field_name='parent__slug',
+        queryset=Platform.objects.all(),
+        to_field_name='slug',
+        label=_('Immediate parent platform (slug)'),
+    )
+    ancestor_id = TreeNodeMultipleChoiceFilter(
+        queryset=Platform.objects.all(),
+        field_name='parent',
+        lookup_expr='in',
+        label=_('Parent platform (ID)'),
+    )
+    ancestor = TreeNodeMultipleChoiceFilter(
+        queryset=Platform.objects.all(),
+        field_name='parent',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Parent platform (slug)'),
+    )
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         field_name='manufacturer',
         queryset=Manufacturer.objects.all(),
@@ -1058,14 +1088,17 @@ class DeviceFilterSet(
         queryset=Device.objects.all(),
         label=_('Parent Device (ID)'),
     )
-    platform_id = django_filters.ModelMultipleChoiceFilter(
+    platform_id = TreeNodeMultipleChoiceFilter(
         queryset=Platform.objects.all(),
+        field_name='platform',
+        lookup_expr='in',
         label=_('Platform (ID)'),
     )
-    platform = django_filters.ModelMultipleChoiceFilter(
-        field_name='platform__slug',
+    platform = TreeNodeMultipleChoiceFilter(
+        field_name='platform',
         queryset=Platform.objects.all(),
         to_field_name='slug',
+        lookup_expr='in',
         label=_('Platform (slug)'),
     )
     region_id = TreeNodeMultipleChoiceFilter(
@@ -1515,34 +1548,34 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
         label=_('Site group (slug)'),
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__site',
+        field_name='_site',
         queryset=Site.objects.all(),
         label=_('Site (ID)'),
     )
     site = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__site__slug',
+        field_name='_site__slug',
         queryset=Site.objects.all(),
         to_field_name='slug',
         label=_('Site name (slug)'),
     )
     location_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__location',
+        field_name='_location',
         queryset=Location.objects.all(),
         label=_('Location (ID)'),
     )
     location = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__location__slug',
+        field_name='_location__slug',
         queryset=Location.objects.all(),
         to_field_name='slug',
         label=_('Location (slug)'),
     )
     rack_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__rack',
+        field_name='_rack',
         queryset=Rack.objects.all(),
         label=_('Rack (ID)'),
     )
     rack = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__rack__name',
+        field_name='_rack__name',
         queryset=Rack.objects.all(),
         to_field_name='name',
         label=_('Rack (name)'),
@@ -1885,6 +1918,16 @@ class InterfaceFilterSet(
     PathEndpointFilterSet,
     CommonInterfaceFilterSet
 ):
+    virtual_chassis_member_or_master = MultiValueCharFilter(
+        method='filter_virtual_chassis_member_or_master',
+        field_name='name',
+        label=_('Virtual Chassis Interfaces for Device when device is master')
+    )
+    virtual_chassis_member_or_master_id = MultiValueNumberFilter(
+        method='filter_virtual_chassis_member_or_master',
+        field_name='pk',
+        label=_('Virtual Chassis Interfaces for Device when device is master (ID)')
+    )
     virtual_chassis_member = MultiValueCharFilter(
         method='filter_virtual_chassis_member',
         field_name='name',
@@ -1995,11 +2038,14 @@ class InterfaceFilterSet(
             'cable_id', 'cable_end',
         )
 
-    def filter_virtual_chassis_member(self, queryset, name, value):
+    def filter_virtual_chassis_member_or_master(self, queryset, name, value):
+        return self.filter_virtual_chassis_member(queryset, name, value, if_master=True)
+
+    def filter_virtual_chassis_member(self, queryset, name, value, if_master=False):
         try:
             vc_interface_ids = []
             for device in Device.objects.filter(**{f'{name}__in': value}):
-                vc_interface_ids.extend(device.vc_interfaces(if_master=False).values_list('id', flat=True))
+                vc_interface_ids.extend(device.vc_interfaces(if_master=if_master).values_list('id', flat=True))
             return queryset.filter(pk__in=vc_interface_ids)
         except Device.DoesNotExist:
             return queryset.none()

+ 5 - 2
netbox/dcim/forms/bulk_create.py

@@ -69,11 +69,14 @@ class PowerPortBulkCreateForm(
 
 
 class PowerOutletBulkCreateForm(
-    form_from_model(PowerOutlet, ['type', 'color', 'feed_leg', 'mark_connected']),
+    form_from_model(PowerOutlet, ['type', 'status', 'color', 'feed_leg', 'mark_connected']),
     DeviceBulkAddComponentForm
 ):
     model = PowerOutlet
-    field_order = ('name', 'label', 'type', 'feed_leg', 'description', 'tags')
+    field_order = (
+        'name', 'label', 'type', 'status', 'color', 'feed_leg', 'mark_connected',
+        'description', 'tags',
+    )
 
 
 class InterfaceBulkCreateForm(

+ 15 - 3
netbox/dcim/forms/bulk_edit.py

@@ -476,6 +476,12 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
 
 
 class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
+    status = forms.ChoiceField(
+        label=_('Status'),
+        choices=add_blank_choice(RackReservationStatusChoices),
+        required=False,
+        initial=''
+    )
     user = forms.ModelChoiceField(
         label=_('User'),
         queryset=User.objects.order_by('username'),
@@ -495,7 +501,7 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
 
     model = RackReservation
     fieldsets = (
-        FieldSet('user', 'tenant', 'description'),
+        FieldSet('status', 'user', 'tenant', 'description'),
     )
     nullable_fields = ('comments',)
 
@@ -682,6 +688,11 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
 
 
 class PlatformBulkEditForm(NetBoxModelBulkEditForm):
+    parent = DynamicModelChoiceField(
+        label=_('Parent'),
+        queryset=Platform.objects.all(),
+        required=False,
+    )
     manufacturer = DynamicModelChoiceField(
         label=_('Manufacturer'),
         queryset=Manufacturer.objects.all(),
@@ -697,12 +708,13 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
         max_length=200,
         required=False
     )
+    comments = CommentField()
 
     model = Platform
     fieldsets = (
-        FieldSet('manufacturer', 'config_template', 'description'),
+        FieldSet('parent', 'manufacturer', 'config_template', 'description'),
     )
-    nullable_fields = ('manufacturer', 'config_template', 'description')
+    nullable_fields = ('parent', 'manufacturer', 'config_template', 'description', 'comments')
 
 
 class DeviceBulkEditForm(NetBoxModelBulkEditForm):

+ 23 - 2
netbox/dcim/forms/bulk_import.py

@@ -358,6 +358,11 @@ class RackReservationImportForm(NetBoxModelImportForm):
         required=True,
         help_text=_('Comma-separated list of individual unit numbers')
     )
+    status = CSVChoiceField(
+        label=_('Status'),
+        choices=RackReservationStatusChoices,
+        help_text=_('Operational status')
+    )
     tenant = CSVModelChoiceField(
         label=_('Tenant'),
         queryset=Tenant.objects.all(),
@@ -368,7 +373,7 @@ class RackReservationImportForm(NetBoxModelImportForm):
 
     class Meta:
         model = RackReservation
-        fields = ('site', 'location', 'rack', 'units', 'tenant', 'description', 'comments', 'tags')
+        fields = ('site', 'location', 'rack', 'units', 'status', 'tenant', 'description', 'comments', 'tags')
 
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)
@@ -504,6 +509,16 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
 
 class PlatformImportForm(NetBoxModelImportForm):
     slug = SlugField()
+    parent = CSVModelChoiceField(
+        label=_('Parent'),
+        queryset=Platform.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Parent platform'),
+        error_messages={
+            'invalid_choice': _('Platform not found.'),
+        }
+    )
     manufacturer = CSVModelChoiceField(
         label=_('Manufacturer'),
         queryset=Manufacturer.objects.all(),
@@ -522,7 +537,7 @@ class PlatformImportForm(NetBoxModelImportForm):
     class Meta:
         model = Platform
         fields = (
-            'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
+            'name', 'slug', 'parent', 'manufacturer', 'config_template', 'description', 'tags',
         )
 
 
@@ -676,6 +691,12 @@ class DeviceImportForm(BaseDeviceImportForm):
                 })
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
 
+            # Limit platform queryset by manufacturer
+            params = {f"manufacturer__{self.fields['manufacturer'].to_field_name}": data.get('manufacturer')}
+            self.fields['platform'].queryset = self.fields['platform'].queryset.filter(
+                Q(**params) | Q(manufacturer=None)
+            )
+
             # Limit device bay queryset by parent device
             if parent := data.get('parent'):
                 params = {f"device__{self.fields['parent'].to_field_name}": parent}

+ 6 - 1
netbox/dcim/forms/connections.py

@@ -19,6 +19,11 @@ def get_cable_form(a_type, b_type):
                 # Device component
                 if hasattr(term_cls, 'device'):
 
+                    # Dynamically change the param field for interfaces to use virtual_chassis filter
+                    query_param_device_field = 'device_id'
+                    if term_cls == Interface:
+                        query_param_device_field = 'virtual_chassis_member_or_master_id'
+
                     attrs[f'termination_{cable_end}_device'] = DynamicModelMultipleChoiceField(
                         queryset=Device.objects.all(),
                         label=_('Device'),
@@ -36,7 +41,7 @@ def get_cable_form(a_type, b_type):
                             'parent': 'device',
                         },
                         query_params={
-                            'device_id': f'$termination_{cable_end}_device',
+                            query_param_device_field: f'$termination_{cable_end}_device',
                             'kind': 'physical',  # Exclude virtual interfaces
                         }
                     )

+ 11 - 1
netbox/dcim/forms/filtersets.py

@@ -417,7 +417,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = RackReservation
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('user_id', name=_('User')),
+        FieldSet('status', 'user_id', name=_('Reservation')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
     )
@@ -458,6 +458,11 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         },
         label=_('Rack')
     )
+    status = forms.MultipleChoiceField(
+        label=_('Status'),
+        choices=RackReservationStatusChoices,
+        required=False
+    )
     user_id = DynamicModelMultipleChoiceField(
         queryset=User.objects.all(),
         required=False,
@@ -714,6 +719,11 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
 class PlatformFilterForm(NetBoxModelFilterSetForm):
     model = Platform
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
+    parent_id = DynamicModelMultipleChoiceField(
+        queryset=Platform.objects.all(),
+        required=False,
+        label=_('Parent')
+    )
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         required=False,

+ 14 - 4
netbox/dcim/forms/model_forms.py

@@ -336,14 +336,14 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
 
     fieldsets = (
-        FieldSet('rack', 'units', 'user', 'description', 'tags', name=_('Reservation')),
+        FieldSet('rack', 'units', 'status', 'user', 'description', 'tags', name=_('Reservation')),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
     )
 
     class Meta:
         model = RackReservation
         fields = [
-            'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
+            'rack', 'units', 'status', 'user', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
         ]
 
 
@@ -536,6 +536,11 @@ class DeviceRoleForm(NetBoxModelForm):
 
 
 class PlatformForm(NetBoxModelForm):
+    parent = DynamicModelChoiceField(
+        label=_('Parent'),
+        queryset=Platform.objects.all(),
+        required=False,
+    )
     manufacturer = DynamicModelChoiceField(
         label=_('Manufacturer'),
         queryset=Manufacturer.objects.all(),
@@ -551,15 +556,18 @@ class PlatformForm(NetBoxModelForm):
         label=_('Slug'),
         max_length=64
     )
+    comments = CommentField()
 
     fieldsets = (
-        FieldSet('name', 'slug', 'manufacturer', 'config_template', 'description', 'tags', name=_('Platform')),
+        FieldSet(
+            'name', 'slug', 'parent', 'manufacturer', 'config_template', 'description', 'tags', name=_('Platform'),
+        ),
     )
 
     class Meta:
         model = Platform
         fields = [
-            'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
+            'name', 'slug', 'parent', 'manufacturer', 'config_template', 'description', 'comments', 'tags',
         ]
 
 
@@ -1891,6 +1899,7 @@ class MACAddressForm(NetBoxModelForm):
         label=_('Interface'),
         queryset=Interface.objects.all(),
         required=False,
+        selector=True,
         context={
             'parent': 'device',
         },
@@ -1899,6 +1908,7 @@ class MACAddressForm(NetBoxModelForm):
         label=_('VM Interface'),
         queryset=VMInterface.objects.all(),
         required=False,
+        selector=True,
         context={
             'parent': 'virtual_machine',
         },

+ 2 - 0
netbox/dcim/graphql/types.py

@@ -633,6 +633,8 @@ class ModuleTypeType(NetBoxObjectType):
     pagination=True
 )
 class PlatformType(OrganizationalObjectType):
+    parent: Annotated['PlatformType', strawberry.lazy('dcim.graphql.types')] | None
+    children: List[Annotated['PlatformType', strawberry.lazy('dcim.graphql.types')]]
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None
     config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None
 

+ 2 - 0
netbox/dcim/management/commands/buildschema.py

@@ -7,6 +7,7 @@ from jinja2 import FileSystemLoader, Environment
 
 from dcim.choices import *
 from netbox.choices import WeightUnitChoices
+from wireless.choices import WirelessRoleChoices
 
 TEMPLATE_FILENAME = 'devicetype_schema.jinja2'
 OUTPUT_FILENAME = 'contrib/generated_schema.json'
@@ -23,6 +24,7 @@ CHOICES_MAP = {
     'interface_type_choices': InterfaceTypeChoices,
     'interface_poe_mode_choices': InterfacePoEModeChoices,
     'interface_poe_type_choices': InterfacePoETypeChoices,
+    'interface_rf_role_choices': WirelessRoleChoices,
     'front_port_type_choices': PortTypeChoices,
     'rear_port_type_choices': PortTypeChoices,
 }

+ 2 - 1
netbox/dcim/migrations/0205_moduletypeprofile.py

@@ -3,6 +3,7 @@ import taggit.managers
 from django.db import migrations, models
 
 import utilities.json
+import utilities.jsonschema
 
 
 class Migration(migrations.Migration):
@@ -25,7 +26,7 @@ class Migration(migrations.Migration):
                 ('description', models.CharField(blank=True, max_length=200)),
                 ('comments', models.TextField(blank=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
-                ('schema', models.JSONField(blank=True, null=True)),
+                ('schema', models.JSONField(blank=True, null=True, validators=[utilities.jsonschema.validate_schema])),
                 ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
             ],
             options={

+ 287 - 0
netbox/dcim/migrations/0209_device_component_denorm_site_location.py

@@ -0,0 +1,287 @@
+import django.db.models.deletion
+from django.db import migrations, models
+from django.db.models import OuterRef, Subquery
+
+
+def populate_denormalized_data(apps, schema_editor):
+    Device = apps.get_model('dcim', 'Device')
+    component_models = (
+        apps.get_model('dcim', 'ConsolePort'),
+        apps.get_model('dcim', 'ConsoleServerPort'),
+        apps.get_model('dcim', 'PowerPort'),
+        apps.get_model('dcim', 'PowerOutlet'),
+        apps.get_model('dcim', 'Interface'),
+        apps.get_model('dcim', 'FrontPort'),
+        apps.get_model('dcim', 'RearPort'),
+        apps.get_model('dcim', 'DeviceBay'),
+        apps.get_model('dcim', 'ModuleBay'),
+        apps.get_model('dcim', 'InventoryItem'),
+    )
+
+    for model in component_models:
+        subquery = Device.objects.filter(pk=OuterRef('device_id'))
+        model.objects.update(
+            _site=Subquery(subquery.values('site_id')[:1]),
+            _location=Subquery(subquery.values('location_id')[:1]),
+            _rack=Subquery(subquery.values('rack_id')[:1]),
+        )
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0208_devicerole_uniqueness'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='consoleport',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='consoleport',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='consoleport',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='consoleserverport',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='consoleserverport',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='consoleserverport',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='devicebay',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='devicebay',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='devicebay',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='frontport',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='frontport',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='frontport',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='inventoryitem',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='inventoryitem',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='inventoryitem',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='modulebay',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='modulebay',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='modulebay',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='poweroutlet',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='poweroutlet',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='poweroutlet',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='powerport',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='powerport',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='powerport',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.AddField(
+            model_name='rearport',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='rearport',
+            name='_rack',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
+            ),
+        ),
+        migrations.AddField(
+            model_name='rearport',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
+            ),
+        ),
+        migrations.RunPython(populate_denormalized_data),
+    ]

+ 19 - 0
netbox/dcim/migrations/0210_macaddress_ordering.py

@@ -0,0 +1,19 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0209_device_component_denorm_site_location'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='macaddress',
+            options={
+                'ordering': ('mac_address', 'pk'),
+                'verbose_name': 'MAC address',
+                'verbose_name_plural': 'MAC addresses'
+            },
+        ),
+    ]

+ 1 - 1
netbox/dcim/migrations/0209_platform_manufacturer_uniqueness.py → netbox/dcim/migrations/0211_platform_manufacturer_uniqueness.py

@@ -4,7 +4,7 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0208_devicerole_uniqueness'),
+        ('dcim', '0210_macaddress_ordering'),
         ('extras', '0129_fix_script_paths'),
     ]
 

+ 1 - 1
netbox/dcim/migrations/0210_interface_tx_power_negative.py → netbox/dcim/migrations/0212_interface_tx_power_negative.py

@@ -5,7 +5,7 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0209_platform_manufacturer_uniqueness'),
+        ('dcim', '0211_platform_manufacturer_uniqueness'),
     ]
 
     operations = [

+ 55 - 0
netbox/dcim/migrations/0213_platform_parent.py

@@ -0,0 +1,55 @@
+import django.db.models.deletion
+import mptt.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0212_interface_tx_power_negative'),
+    ]
+
+    operations = [
+        # Add parent & MPTT fields
+        migrations.AddField(
+            model_name='platform',
+            name='parent',
+            field=mptt.fields.TreeForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name='children',
+                to='dcim.platform'
+            ),
+        ),
+        migrations.AddField(
+            model_name='platform',
+            name='level',
+            field=models.PositiveIntegerField(default=0, editable=False),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='platform',
+            name='lft',
+            field=models.PositiveIntegerField(default=0, editable=False),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='platform',
+            name='rght',
+            field=models.PositiveIntegerField(default=0, editable=False),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='platform',
+            name='tree_id',
+            field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
+            preserve_default=False,
+        ),
+        # Add comments field
+        migrations.AddField(
+            model_name='platform',
+            name='comments',
+            field=models.TextField(blank=True),
+        ),
+    ]

+ 29 - 0
netbox/dcim/migrations/0214_platform_rebuild.py

@@ -0,0 +1,29 @@
+from django.db import migrations
+import mptt
+import mptt.managers
+
+
+def rebuild_mptt(apps, schema_editor):
+    """
+    Construct the MPTT hierarchy.
+    """
+    Platform = apps.get_model('dcim', 'Platform')
+    manager = mptt.managers.TreeManager()
+    manager.model = Platform
+    mptt.register(Platform)
+    manager.contribute_to_class(Platform, 'objects')
+    manager.rebuild()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0213_platform_parent'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=rebuild_mptt,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 16 - 0
netbox/dcim/migrations/0215_rackreservation_status.py

@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0214_platform_rebuild'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='rackreservation',
+            name='status',
+            field=models.CharField(default='active', max_length=50),
+        ),
+    ]

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

@@ -12,6 +12,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.fields import PathField
 from dcim.utils import decompile_path_node, object_to_path_node
+from netbox.choices import ColorChoices
 from netbox.models import ChangeLoggedModel, PrimaryModel
 from utilities.conversion import to_meters
 from utilities.exceptions import AbortRequest
@@ -156,6 +157,15 @@ class Cable(PrimaryModel):
             self._terminations_modified = True
         self._b_terminations = value
 
+    @property
+    def color_name(self):
+        color_name = ""
+        for hex_code, label in ColorChoices.CHOICES:
+            if hex_code.lower() == self.color.lower():
+                color_name = str(label)
+
+        return color_name
+
     def clean(self):
         super().clean()
 

+ 31 - 0
netbox/dcim/models/device_components.py

@@ -65,6 +65,29 @@ class ComponentModel(NetBoxModel):
         blank=True
     )
 
+    # Denormalized references replicated from the parent Device
+    _site = models.ForeignKey(
+        to='dcim.Site',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True,
+    )
+    _location = models.ForeignKey(
+        to='dcim.Location',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True,
+    )
+    _rack = models.ForeignKey(
+        to='dcim.Rack',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True,
+    )
+
     class Meta:
         abstract = True
         ordering = ('device', 'name')
@@ -100,6 +123,14 @@ class ComponentModel(NetBoxModel):
                 "device": _("Components cannot be moved to a different device.")
             })
 
+    def save(self, *args, **kwargs):
+        # Save denormalized references
+        self._site = self.device.site
+        self._location = self.device.location
+        self._rack = self.device.rack
+
+        super().save(*args, **kwargs)
+
     @property
     def parent_object(self):
         return self.device

+ 10 - 13
netbox/dcim/models/devices.py

@@ -9,7 +9,7 @@ from django.core.exceptions import ValidationError
 from django.core.files.storage import default_storage
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
-from django.db.models import F, ProtectedError
+from django.db.models import F, ProtectedError, prefetch_related_objects
 from django.db.models.functions import Lower
 from django.db.models.signals import post_save
 from django.urls import reverse
@@ -28,6 +28,7 @@ from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models.mixins import WeightMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from utilities.fields import ColorField, CounterCacheField
+from utilities.prefetch import get_prefetchable_fields
 from utilities.tracking import TrackingModelMixin
 from .device_components import *
 from .mixins import RenderConfigMixin
@@ -424,7 +425,7 @@ class DeviceRole(NestedGroupModel):
         verbose_name_plural = _('device roles')
 
 
-class Platform(OrganizationalModel):
+class Platform(NestedGroupModel):
     """
     Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". A
     Platform may optionally be associated with a particular Manufacturer.
@@ -437,15 +438,6 @@ class Platform(OrganizationalModel):
         null=True,
         help_text=_('Optionally limit this platform to devices of a certain manufacturer')
     )
-    # Override name & slug from OrganizationalModel to not enforce uniqueness
-    name = models.CharField(
-        verbose_name=_('name'),
-        max_length=100
-    )
-    slug = models.SlugField(
-        verbose_name=_('slug'),
-        max_length=100
-    )
     config_template = models.ForeignKey(
         to='extras.ConfigTemplate',
         on_delete=models.PROTECT,
@@ -454,6 +446,8 @@ class Platform(OrganizationalModel):
         null=True
     )
 
+    clone_fields = ('parent', 'description')
+
     class Meta:
         ordering = ('name',)
         verbose_name = _('platform')
@@ -955,7 +949,10 @@ class Device(
             if cf_defaults := CustomField.objects.get_defaults_for_model(model):
                 for component in components:
                     component.custom_field_data = cf_defaults
-            model.objects.bulk_create(components)
+            components = model.objects.bulk_create(components)
+            # Prefetch related objects to minimize queries needed during post_save
+            prefetch_fields = get_prefetchable_fields(model)
+            prefetch_related_objects(components, *prefetch_fields)
             # Manually send the post_save signal for each of the newly created components
             for component in components:
                 post_save.send(
@@ -1303,7 +1300,7 @@ class MACAddress(PrimaryModel):
     )
 
     class Meta:
-        ordering = ('mac_address',)
+        ordering = ('mac_address', 'pk',)
         verbose_name = _('MAC address')
         verbose_name_plural = _('MAC addresses')
 

+ 2 - 13
netbox/dcim/models/modules.py

@@ -36,7 +36,8 @@ class ModuleTypeProfile(PrimaryModel):
     schema = models.JSONField(
         blank=True,
         null=True,
-        verbose_name=_('schema')
+        validators=[validate_schema],
+        verbose_name=_('schema'),
     )
 
     clone_fields = ('schema',)
@@ -49,18 +50,6 @@ class ModuleTypeProfile(PrimaryModel):
     def __str__(self):
         return self.name
 
-    def clean(self):
-        super().clean()
-
-        # Validate the schema definition
-        if self.schema is not None:
-            try:
-                validate_schema(self.schema)
-            except ValidationError as e:
-                raise ValidationError({
-                    'schema': e.message,
-                })
-
 
 class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
     """

+ 9 - 0
netbox/dcim/models/racks.py

@@ -673,6 +673,12 @@ class RackReservation(PrimaryModel):
         verbose_name=_('units'),
         base_field=models.PositiveSmallIntegerField()
     )
+    status = models.CharField(
+        verbose_name=_('status'),
+        max_length=50,
+        choices=RackReservationStatusChoices,
+        default=RackReservationStatusChoices.STATUS_ACTIVE
+    )
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         on_delete=models.PROTECT,
@@ -733,6 +739,9 @@ class RackReservation(PrimaryModel):
     def unit_list(self):
         return array_to_string(self.units)
 
+    def get_status_color(self):
+        return RackReservationStatusChoices.colors.get(self.status)
+
     def to_objectchange(self, action):
         objectchange = super().to_objectchange(action)
         objectchange.related_object = self.rack

+ 0 - 3
netbox/dcim/object_actions.py

@@ -20,10 +20,7 @@ class BulkAddComponents(ObjectAction):
     @classmethod
     def get_context(cls, context, obj):
         return {
-            'perms': context.get('perms'),
-            'request': context.get('request'),
             'formaction': context.get('formaction'),
-            'label': cls.label,
         }
 
 

+ 31 - 2
netbox/dcim/signals.py

@@ -3,13 +3,28 @@ import logging
 from django.db.models.signals import post_save, post_delete, pre_delete
 from django.dispatch import receiver
 
-from .choices import CableEndChoices, LinkStatusChoices
+from dcim.choices import CableEndChoices, LinkStatusChoices
 from .models import (
-    Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis,
+    Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface,
+    InventoryItem, ModuleBay, PathEndpoint, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location,
+    VirtualChassis,
 )
 from .models.cables import trace_paths
 from .utils import create_cablepath, rebuild_paths
 
+COMPONENT_MODELS = (
+    ConsolePort,
+    ConsoleServerPort,
+    DeviceBay,
+    FrontPort,
+    Interface,
+    InventoryItem,
+    ModuleBay,
+    PowerOutlet,
+    PowerPort,
+    RearPort,
+)
+
 
 #
 # Location/rack/device assignment
@@ -39,6 +54,20 @@ def handle_rack_site_change(instance, created, **kwargs):
         Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
 
 
+@receiver(post_save, sender=Device)
+def handle_device_site_change(instance, created, **kwargs):
+    """
+    Update child components to update the parent Site, Location, and Rack when a Device is saved.
+    """
+    if not created:
+        for model in COMPONENT_MODELS:
+            model.objects.filter(device=instance).update(
+                _site=instance.site,
+                _location=instance.location,
+                _rack=instance.rack,
+            )
+
+
 #
 # Virtual chassis
 #

+ 5 - 1
netbox/dcim/tables/cables.py

@@ -113,6 +113,10 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
         order_by=('_abs_length')
     )
     color = columns.ColorColumn()
+    color_name = tables.Column(
+        verbose_name=_('Color Name'),
+        orderable=False
+    )
     comments = columns.MarkdownColumn()
     tags = columns.TagColumn(
         url_name='dcim:cable_list'
@@ -123,7 +127,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
         fields = (
             'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
             'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color',
-            'length', 'description', 'comments', 'tags', 'created', 'last_updated',
+            'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',

+ 7 - 3
netbox/dcim/tables/devices.py

@@ -103,10 +103,14 @@ class DeviceRoleTable(NetBoxTable):
 #
 
 class PlatformTable(NetBoxTable):
-    name = tables.Column(
+    name = columns.MPTTColumn(
         verbose_name=_('Name'),
         linkify=True
     )
+    parent = tables.Column(
+        verbose_name=_('Parent'),
+        linkify=True,
+    )
     manufacturer = tables.Column(
         verbose_name=_('Manufacturer'),
         linkify=True
@@ -132,8 +136,8 @@ class PlatformTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = models.Platform
         fields = (
-            'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'description',
-            'tags', 'actions', 'created', 'last_updated',
+            'pk', 'id', 'name', 'parent', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template',
+            'description', 'tags', 'actions', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'description',

+ 5 - 2
netbox/dcim/tables/racks.py

@@ -229,6 +229,9 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
         orderable=False,
         verbose_name=_('Units')
     )
+    status = columns.ChoiceFieldColumn(
+        verbose_name=_('Status'),
+    )
     comments = columns.MarkdownColumn(
         verbose_name=_('Comments'),
     )
@@ -239,7 +242,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = RackReservation
         fields = (
-            'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant',
+            'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'created', 'tenant',
             'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
         )
-        default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description')
+        default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description')

+ 27 - 6
netbox/dcim/tests/test_api.py

@@ -465,7 +465,7 @@ class RackTest(APIViewTestCases.APIViewTestCase):
 
 class RackReservationTest(APIViewTestCases.APIViewTestCase):
     model = RackReservation
-    brief_fields = ['description', 'display', 'id', 'units', 'url', 'user']
+    brief_fields = ['description', 'display', 'id', 'status', 'units', 'url', 'user']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -483,9 +483,24 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
         Rack.objects.bulk_create(racks)
 
         rack_reservations = (
-            RackReservation(rack=racks[0], units=[1, 2, 3], user=user, description='Reservation #1'),
-            RackReservation(rack=racks[0], units=[4, 5, 6], user=user, description='Reservation #2'),
-            RackReservation(rack=racks[0], units=[7, 8, 9], user=user, description='Reservation #3'),
+            RackReservation(
+                rack=racks[0],
+                units=[1, 2, 3],
+                user=user,
+                description='Reservation #1',
+            ),
+            RackReservation(
+                rack=racks[0],
+                units=[4, 5, 6],
+                user=user,
+                description='Reservation #2'
+            ),
+            RackReservation(
+                rack=racks[0],
+                units=[7, 8, 9],
+                user=user,
+                description='Reservation #3',
+            ),
         )
         RackReservation.objects.bulk_create(rack_reservations)
 
@@ -493,18 +508,21 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
             {
                 'rack': racks[1].pk,
                 'units': [10, 11, 12],
+                'status': RackReservationStatusChoices.STATUS_ACTIVE,
                 'user': user.pk,
                 'description': 'Reservation #4',
             },
             {
                 'rack': racks[1].pk,
                 'units': [13, 14, 15],
+                'status': RackReservationStatusChoices.STATUS_PENDING,
                 'user': user.pk,
                 'description': 'Reservation #5',
             },
             {
                 'rack': racks[1].pk,
                 'units': [16, 17, 18],
+                'status': RackReservationStatusChoices.STATUS_STALE,
                 'user': user.pk,
                 'description': 'Reservation #6',
             },
@@ -1247,7 +1265,9 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
 
 class PlatformTest(APIViewTestCases.APIViewTestCase):
     model = Platform
-    brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
+    brief_fields = [
+        '_depth', 'description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count',
+    ]
     create_data = [
         {
             'name': 'Platform 4',
@@ -1274,7 +1294,8 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
             Platform(name='Platform 2', slug='platform-2'),
             Platform(name='Platform 3', slug='platform-3'),
         )
-        Platform.objects.bulk_create(platforms)
+        for platform in platforms:
+            platform.save()
 
 
 class DeviceTest(APIViewTestCases.APIViewTestCase):

+ 285 - 27
netbox/dcim/tests/test_filtersets.py

@@ -1141,9 +1141,30 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
         Tenant.objects.bulk_create(tenants)
 
         reservations = (
-            RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0], description='foobar1'),
-            RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1], tenant=tenants[1], description='foobar2'),
-            RackReservation(rack=racks[2], units=[7, 8, 9], user=users[2], tenant=tenants[2], description='foobar3'),
+            RackReservation(
+                rack=racks[0],
+                units=[1, 2, 3],
+                status=RackReservationStatusChoices.STATUS_ACTIVE,
+                user=users[0],
+                tenant=tenants[0],
+                description='foobar1',
+            ),
+            RackReservation(
+                rack=racks[1],
+                units=[4, 5, 6],
+                status=RackReservationStatusChoices.STATUS_PENDING,
+                user=users[1],
+                tenant=tenants[1],
+                description='foobar2',
+            ),
+            RackReservation(
+                rack=racks[2],
+                units=[7, 8, 9],
+                status=RackReservationStatusChoices.STATUS_STALE,
+                user=users[2],
+                tenant=tenants[2],
+                description='foobar3',
+            ),
         )
         RackReservation.objects.bulk_create(reservations)
 
@@ -1179,6 +1200,10 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'location': [locations[0].slug, locations[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_status(self):
+        params = {'status': [RackReservationStatusChoices.STATUS_ACTIVE, RackReservationStatusChoices.STATUS_PENDING]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_user(self):
         users = User.objects.all()[:2]
         params = {'user_id': [users[0].pk, users[1].pk]}
@@ -1256,7 +1281,8 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
             Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1]),
             Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2]),
         )
-        Platform.objects.bulk_create(platforms)
+        for platform in platforms:
+            platform.save()
 
         device_types = (
             DeviceType(
@@ -2435,7 +2461,37 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
             Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'),
             Platform(name='Platform 4', slug='platform-4'),
         )
-        Platform.objects.bulk_create(platforms)
+        for platform in platforms:
+            platform.save()
+        child_platforms = (
+            Platform(parent=platforms[0], name='Platform 1A', slug='platform-1a', manufacturer=manufacturers[0]),
+            Platform(parent=platforms[1], name='Platform 2A', slug='platform-2a', manufacturer=manufacturers[1]),
+            Platform(parent=platforms[2], name='Platform 3A', slug='platform-3a', manufacturer=manufacturers[2]),
+        )
+        for platform in child_platforms:
+            platform.save()
+        grandchild_platforms = (
+            Platform(
+                parent=child_platforms[0],
+                name='Platform 1A1',
+                slug='platform-1a1',
+                manufacturer=manufacturers[0],
+            ),
+            Platform(
+                parent=child_platforms[1],
+                name='Platform 2A1',
+                slug='platform-2a1',
+                manufacturer=manufacturers[1],
+            ),
+            Platform(
+                parent=child_platforms[2],
+                name='Platform 3A1',
+                slug='platform-3a1',
+                manufacturer=manufacturers[2],
+            ),
+        )
+        for platform in grandchild_platforms:
+            platform.save()
 
     def test_q(self):
         params = {'q': 'foobar1'}
@@ -2453,12 +2509,26 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_parent(self):
+        platforms = Platform.objects.filter(parent__isnull=True)[:2]
+        params = {'parent_id': [platforms[0].pk, platforms[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'parent': [platforms[0].slug, platforms[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_ancestor(self):
+        platforms = Platform.objects.filter(parent__isnull=True)[:2]
+        params = {'ancestor_id': [platforms[0].pk, platforms[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'ancestor': [platforms[0].slug, platforms[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
     def test_manufacturer(self):
         manufacturers = Manufacturer.objects.all()[:2]
         params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
     def test_available_for_device_type(self):
         manufacturers = Manufacturer.objects.all()[:2]
@@ -2469,7 +2539,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
             u_height=1
         )
         params = {'available_for_device_type': device_type.pk}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
 class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -2507,7 +2577,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
             Platform(name='Platform 2', slug='platform-2'),
             Platform(name='Platform 3', slug='platform-3'),
         )
-        Platform.objects.bulk_create(platforms)
+        for platform in platforms:
+            platform.save()
 
         regions = (
             Region(name='Region 1', slug='region-1'),
@@ -2763,7 +2834,7 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'device_type': [device_types[0].slug, device_types[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_devicerole(self):
+    def test_role(self):
         roles = DeviceRole.objects.all()[:2]
         params = {'role_id': [roles[0].pk, roles[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -3367,9 +3438,36 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
         ConsoleServerPort.objects.bulk_create(console_server_ports)
 
         console_ports = (
-            ConsolePort(device=devices[0], module=modules[0], name='Console Port 1', label='A', description='First'),
-            ConsolePort(device=devices[1], module=modules[1], name='Console Port 2', label='B', description='Second'),
-            ConsolePort(device=devices[2], module=modules[2], name='Console Port 3', label='C', description='Third'),
+            ConsolePort(
+                device=devices[0],
+                module=modules[0],
+                name='Console Port 1',
+                label='A',
+                description='First',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
+            ),
+            ConsolePort(
+                device=devices[1],
+                module=modules[1],
+                name='Console Port 2',
+                label='B',
+                description='Second',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
+            ),
+            ConsolePort(
+                device=devices[2],
+                module=modules[2],
+                name='Console Port 3',
+                label='C',
+                description='Third',
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
+            ),
         )
         ConsolePort.objects.bulk_create(console_ports)
 
@@ -3581,13 +3679,34 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
 
         console_server_ports = (
             ConsoleServerPort(
-                device=devices[0], module=modules[0], name='Console Server Port 1', label='A', description='First'
+                device=devices[0],
+                module=modules[0],
+                name='Console Server Port 1',
+                label='A',
+                description='First',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             ConsoleServerPort(
-                device=devices[1], module=modules[1], name='Console Server Port 2', label='B', description='Second'
+                device=devices[1],
+                module=modules[1],
+                name='Console Server Port 2',
+                label='B',
+                description='Second',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             ConsoleServerPort(
-                device=devices[2], module=modules[2], name='Console Server Port 3', label='C', description='Third'
+                device=devices[2],
+                module=modules[2],
+                name='Console Server Port 3',
+                label='C',
+                description='Third',
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
             ),
         )
         ConsoleServerPort.objects.bulk_create(console_server_ports)
@@ -3807,6 +3926,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 maximum_draw=100,
                 allocated_draw=50,
                 description='First',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             PowerPort(
                 device=devices[1],
@@ -3816,6 +3938,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 maximum_draw=200,
                 allocated_draw=100,
                 description='Second',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             PowerPort(
                 device=devices[2],
@@ -3825,6 +3950,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 maximum_draw=300,
                 allocated_draw=150,
                 description='Third',
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
             ),
         )
         PowerPort.objects.bulk_create(power_ports)
@@ -4053,6 +4181,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
                 description='First',
                 color='ff0000',
                 status=PowerOutletStatusChoices.STATUS_ENABLED,
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             PowerOutlet(
                 device=devices[1],
@@ -4063,6 +4194,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
                 description='Second',
                 color='00ff00',
                 status=PowerOutletStatusChoices.STATUS_DISABLED,
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             PowerOutlet(
                 device=devices[2],
@@ -4073,6 +4207,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
                 description='Third',
                 color='0000ff',
                 status=PowerOutletStatusChoices.STATUS_FAULTY,
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
             ),
         )
         PowerOutlet.objects.bulk_create(power_outlets)
@@ -4307,6 +4444,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         )
         Device.objects.bulk_create(devices)
 
+        virtual_chassis.master = devices[0]
+        virtual_chassis.save()
+
         module_bays = (
             ModuleBay(device=devices[0], name='Module Bay 1'),
             ModuleBay(device=devices[1], name='Module Bay 2'),
@@ -4381,13 +4521,19 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 poe_mode=InterfacePoEModeChoices.MODE_PSE,
                 poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
                 vlan_translation_policy=vlan_translation_policies[0],
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             Interface(
                 device=devices[1],
                 module=modules[1],
                 name='VC Chassis Interface',
                 type=InterfaceTypeChoices.TYPE_1GE_SFP,
-                enabled=True
+                enabled=True,
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             Interface(
                 device=devices[2],
@@ -4406,6 +4552,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 poe_mode=InterfacePoEModeChoices.MODE_PD,
                 poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
                 vlan_translation_policy=vlan_translation_policies[0],
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
             ),
             Interface(
                 device=devices[3],
@@ -4424,6 +4573,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 poe_mode=InterfacePoEModeChoices.MODE_PSE,
                 poe_type=InterfacePoETypeChoices.TYPE_2_8023AT,
                 vlan_translation_policy=vlan_translation_policies[1],
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
             ),
             Interface(
                 device=devices[4],
@@ -4440,6 +4592,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 mode=InterfaceModeChoices.MODE_Q_IN_Q,
                 qinq_svlan=vlans[0],
                 vlan_translation_policy=vlan_translation_policies[1],
+                _site=devices[4].site,
+                _location=devices[4].location,
+                _rack=devices[4].rack,
             ),
             Interface(
                 device=devices[4],
@@ -4450,7 +4605,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 mgmt_only=True,
                 tx_power=40,
                 mode=InterfaceModeChoices.MODE_Q_IN_Q,
-                qinq_svlan=vlans[1]
+                qinq_svlan=vlans[1],
+                _site=devices[4].site,
+                _location=devices[4].location,
+                _rack=devices[4].rack,
             ),
             Interface(
                 device=devices[4],
@@ -4461,7 +4619,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 mgmt_only=False,
                 tx_power=40,
                 mode=InterfaceModeChoices.MODE_Q_IN_Q,
-                qinq_svlan=vlans[2]
+                qinq_svlan=vlans[2],
+                _site=devices[4].site,
+                _location=devices[4].location,
+                _rack=devices[4].rack,
             ),
             Interface(
                 device=devices[4],
@@ -4470,7 +4631,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 rf_role=WirelessRoleChoices.ROLE_AP,
                 rf_channel=WirelessChannelChoices.CHANNEL_24G_1,
                 rf_channel_frequency=2412,
-                rf_channel_width=22
+                rf_channel_width=22,
+                _site=devices[4].site,
+                _location=devices[4].location,
+                _rack=devices[4].rack,
             ),
             Interface(
                 device=devices[4],
@@ -4479,7 +4643,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 rf_role=WirelessRoleChoices.ROLE_STATION,
                 rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
                 rf_channel_frequency=5160,
-                rf_channel_width=20
+                rf_channel_width=20,
+                _site=devices[4].site,
+                _location=devices[4].location,
+                _rack=devices[4].rack,
             ),
         )
         Interface.objects.bulk_create(interfaces)
@@ -4666,6 +4833,19 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         params = {'device': [devices[0].name, devices[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_virtual_chassis_member_or_master(self):
+        vc = VirtualChassis.objects.first()
+        master = vc.master
+        member = vc.members.exclude(pk=master.pk).first()
+        params = {'virtual_chassis_member_or_master_id': [master.pk,]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'virtual_chassis_member_or_master_id': [member.pk,]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'virtual_chassis_member_or_master': [master.name,]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'virtual_chassis_member_or_master': [member.name,]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
     def test_virtual_chassis_member(self):
         # Device 1A & 3 have 1 management interface, Device 1B has 1 interfaces
         devices = Device.objects.filter(name__in=['Device 1A', 'Device 3'])
@@ -4906,6 +5086,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 rear_port=rear_ports[0],
                 rear_port_position=1,
                 description='First',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             FrontPort(
                 device=devices[1],
@@ -4917,6 +5100,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 rear_port=rear_ports[1],
                 rear_port_position=2,
                 description='Second',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             FrontPort(
                 device=devices[2],
@@ -4928,6 +5114,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 rear_port=rear_ports[2],
                 rear_port_position=3,
                 description='Third',
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
             ),
             FrontPort(
                 device=devices[3],
@@ -4936,6 +5125,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 type=PortTypeChoices.TYPE_FC,
                 rear_port=rear_ports[3],
                 rear_port_position=1,
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
             ),
             FrontPort(
                 device=devices[3],
@@ -4944,6 +5136,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 type=PortTypeChoices.TYPE_FC,
                 rear_port=rear_ports[4],
                 rear_port_position=1,
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
             ),
             FrontPort(
                 device=devices[3],
@@ -4952,6 +5147,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 type=PortTypeChoices.TYPE_FC,
                 rear_port=rear_ports[5],
                 rear_port_position=1,
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
             ),
         )
         FrontPort.objects.bulk_create(front_ports)
@@ -5168,6 +5366,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
                 color=ColorChoices.COLOR_RED,
                 positions=1,
                 description='First',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             RearPort(
                 device=devices[1],
@@ -5178,6 +5379,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
                 color=ColorChoices.COLOR_GREEN,
                 positions=2,
                 description='Second',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             RearPort(
                 device=devices[2],
@@ -5188,10 +5392,40 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
                 color=ColorChoices.COLOR_BLUE,
                 positions=3,
                 description='Third',
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
+            ),
+            RearPort(
+                device=devices[3],
+                name='Rear Port 4',
+                label='D',
+                type=PortTypeChoices.TYPE_FC,
+                positions=4,
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
+            ),
+            RearPort(
+                device=devices[3],
+                name='Rear Port 5',
+                label='E',
+                type=PortTypeChoices.TYPE_FC,
+                positions=5,
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
+            ),
+            RearPort(
+                device=devices[3],
+                name='Rear Port 6',
+                label='F',
+                type=PortTypeChoices.TYPE_FC,
+                positions=6,
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
             ),
-            RearPort(device=devices[3], name='Rear Port 4', label='D', type=PortTypeChoices.TYPE_FC, positions=4),
-            RearPort(device=devices[3], name='Rear Port 5', label='E', type=PortTypeChoices.TYPE_FC, positions=5),
-            RearPort(device=devices[3], name='Rear Port 6', label='F', type=PortTypeChoices.TYPE_FC, positions=6),
         )
         RearPort.objects.bulk_create(rear_ports)
 
@@ -5550,9 +5784,33 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         Device.objects.bulk_create(devices)
 
         device_bays = (
-            DeviceBay(device=devices[0], name='Device Bay 1', label='A', description='First'),
-            DeviceBay(device=devices[1], name='Device Bay 2', label='B', description='Second'),
-            DeviceBay(device=devices[2], name='Device Bay 3', label='C', description='Third'),
+            DeviceBay(
+                device=devices[0],
+                name='Device Bay 1',
+                label='A',
+                description='First',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
+            ),
+            DeviceBay(
+                device=devices[1],
+                name='Device Bay 2',
+                label='B',
+                description='Second',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
+            ),
+            DeviceBay(
+                device=devices[2],
+                name='Device Bay 3',
+                label='C',
+                description='Third',
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
+            ),
         )
         DeviceBay.objects.bulk_create(device_bays)
 

+ 15 - 10
netbox/dcim/tests/test_views.py

@@ -337,6 +337,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         cls.form_data = {
             'rack': rack.pk,
             'units': "10,11,12",
+            'status': RackReservationStatusChoices.STATUS_PENDING,
             'user': user3.pk,
             'tenant': None,
             'description': 'Rack reservation',
@@ -344,10 +345,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         cls.csv_data = (
-            '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',
+            'site,location,rack,units,status,description',
+            'Site 1,Location 1,Rack 1,"10,11,12",active,Reservation 1',
+            'Site 1,Location 1,Rack 1,"13,14,15",pending,Reservation 2',
+            'Site 1,Location 1,Rack 1,"16,17,18",stale,Reservation 3',
         )
 
         cls.csv_update_data = (
@@ -358,6 +359,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
 
         cls.bulk_edit_data = {
+            'status': RackReservationStatusChoices.STATUS_STALE,
             'user': user3.pk,
             'tenant': None,
             'description': 'New description',
@@ -619,7 +621,8 @@ class DeviceTypeTestCase(
             Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0]),
             Platform(name='Platform 2', slug='platform-3', manufacturer=manufacturers[1]),
         )
-        Platform.objects.bulk_create(platforms)
+        for platform in platforms:
+            platform.save()
 
         DeviceType.objects.bulk_create([
             DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturers[0]),
@@ -1891,7 +1894,8 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturer),
             Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer),
         )
-        Platform.objects.bulk_create(platforms)
+        for platform in platforms:
+            platform.save()
 
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
@@ -1912,9 +1916,9 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 
         cls.csv_update_data = (
             "id,name,description",
-            f"{platforms[0].pk},Platform 7,Fourth platform7",
-            f"{platforms[1].pk},Platform 8,Fifth platform8",
-            f"{platforms[2].pk},Platform 9,Sixth platform9",
+            f"{platforms[0].pk},Foo,New description",
+            f"{platforms[1].pk},Bar,New description",
+            f"{platforms[2].pk},Baz,New description",
         )
 
         cls.bulk_edit_data = {
@@ -1962,7 +1966,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             Platform(name='Platform 1', slug='platform-1'),
             Platform(name='Platform 2', slug='platform-2'),
         )
-        Platform.objects.bulk_create(platforms)
+        for platform in platforms:
+            platform.save()
 
         devices = (
             Device(

+ 24 - 6
netbox/dcim/views.py

@@ -2040,9 +2040,18 @@ class InventoryItemTemplateBulkDeleteView(generic.BulkDeleteView):
 
 @register_model_view(DeviceRole, 'list', path='', detail=False)
 class DeviceRoleListView(generic.ObjectListView):
-    queryset = DeviceRole.objects.annotate(
-        device_count=count_related(Device, 'role'),
-        vm_count=count_related(VirtualMachine, 'role')
+    queryset = DeviceRole.objects.add_related_count(
+        DeviceRole.objects.add_related_count(
+            DeviceRole.objects.all(),
+            VirtualMachine,
+            'role',
+            'vm_count',
+            cumulative=True
+        ),
+        Device,
+        'role',
+        'device_count',
+        cumulative=True
     )
     filterset = filtersets.DeviceRoleFilterSet
     filterset_form = forms.DeviceRoleFilterForm
@@ -2109,9 +2118,18 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
 
 @register_model_view(Platform, 'list', path='', detail=False)
 class PlatformListView(generic.ObjectListView):
-    queryset = Platform.objects.annotate(
-        device_count=count_related(Device, 'platform'),
-        vm_count=count_related(VirtualMachine, 'platform')
+    queryset = Platform.objects.add_related_count(
+        Platform.objects.add_related_count(
+            Platform.objects.all(),
+            VirtualMachine,
+            'platform',
+            'vm_count',
+            cumulative=True
+        ),
+        Device,
+        'platform',
+        'device_count',
+        cumulative=True
     )
     table = tables.PlatformTable
     filterset = filtersets.PlatformFilterSet

+ 36 - 4
netbox/extras/api/serializers_/configcontexts.py

@@ -6,7 +6,7 @@ from dcim.api.serializers_.platforms import PlatformSerializer
 from dcim.api.serializers_.roles import DeviceRoleSerializer
 from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, SiteSerializer, SiteGroupSerializer
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
-from extras.models import ConfigContext, Tag
+from extras.models import ConfigContext, ConfigContextProfile, Tag
 from netbox.api.fields import SerializedPKRelatedField
 from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer, TenantGroupSerializer
@@ -15,11 +15,43 @@ from virtualization.api.serializers_.clusters import ClusterSerializer, ClusterG
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 __all__ = (
+    'ConfigContextProfileSerializer',
     'ConfigContextSerializer',
 )
 
 
+class ConfigContextProfileSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
+    tags = serializers.SlugRelatedField(
+        queryset=Tag.objects.all(),
+        slug_field='slug',
+        required=False,
+        many=True
+    )
+    data_source = DataSourceSerializer(
+        nested=True,
+        required=False
+    )
+    data_file = DataFileSerializer(
+        nested=True,
+        read_only=True
+    )
+
+    class Meta:
+        model = ConfigContextProfile
+        fields = [
+            'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'tags', 'comments', 'data_source',
+            'data_path', 'data_file', 'data_synced', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
 class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
+    profile = ConfigContextProfileSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None,
+    )
     regions = SerializedPKRelatedField(
         queryset=Region.objects.all(),
         serializer=RegionSerializer,
@@ -122,9 +154,9 @@ class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializ
     class Meta:
         model = ConfigContext
         fields = [
-            'id', 'url', 'display_url', 'display', 'name', 'weight', 'description', 'is_active', 'regions',
+            'id', 'url', 'display_url', 'display', 'name', 'weight', 'profile', 'description', 'is_active', 'regions',
             'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
-            'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path',
-            'data_file', 'data_synced', 'data', 'created', 'last_updated',
+            'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file',
+            'data_synced', 'data', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')

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

@@ -25,6 +25,7 @@ router.register('tagged-objects', views.TaggedItemViewSet)
 router.register('image-attachments', views.ImageAttachmentViewSet)
 router.register('journal-entries', views.JournalEntryViewSet)
 router.register('config-contexts', views.ConfigContextViewSet)
+router.register('config-context-profiles', views.ConfigContextProfileViewSet)
 router.register('config-templates', views.ConfigTemplateViewSet)
 router.register('scripts', views.ScriptViewSet, basename='script')
 

+ 6 - 0
netbox/extras/api/views.py

@@ -217,6 +217,12 @@ class JournalEntryViewSet(NetBoxModelViewSet):
 # Config contexts
 #
 
+class ConfigContextProfileViewSet(SyncedDataMixin, NetBoxModelViewSet):
+    queryset = ConfigContextProfile.objects.all()
+    serializer_class = serializers.ConfigContextProfileSerializer
+    filterset_class = filtersets.ConfigContextProfileFilterSet
+
+
 class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
     queryset = ConfigContext.objects.all()
     serializer_class = serializers.ConfigContextSerializer

+ 15 - 10
netbox/extras/dashboard/widgets.py

@@ -11,7 +11,7 @@ from django.conf import settings
 from django.core.cache import cache
 from django.db.models import Model
 from django.template.loader import render_to_string
-from django.urls import NoReverseMatch, resolve, reverse
+from django.urls import NoReverseMatch, resolve
 from django.utils.translation import gettext as _
 
 from core.models import ObjectType
@@ -21,7 +21,7 @@ from utilities.permissions import get_permission_for_model
 from utilities.proxy import resolve_proxies
 from utilities.querydict import dict_to_querydict
 from utilities.templatetags.builtins.filters import render_markdown
-from utilities.views import get_viewname
+from utilities.views import get_action_url
 from .utils import register_widget
 
 __all__ = (
@@ -53,9 +53,9 @@ def object_list_widget_supports_model(model: Model) -> bool:
     """
     def can_resolve_model_list_view(model: Model) -> bool:
         try:
-            reverse(get_viewname(model, action='list'))
+            get_action_url(model, action='list')
             return True
-        except Exception:
+        except NoReverseMatch:
             return False
 
     tests = [
@@ -206,7 +206,7 @@ class ObjectCountsWidget(DashboardWidget):
             permission = get_permission_for_model(model, 'view')
             if request.user.has_perm(permission):
                 try:
-                    url = reverse(get_viewname(model, 'list'))
+                    url = get_action_url(model, action='list')
                 except NoReverseMatch:
                     url = None
                 qs = model.objects.restrict(request.user, 'view')
@@ -275,15 +275,13 @@ class ObjectListWidget(DashboardWidget):
             logger.debug(f"Dashboard Widget model_class not found: {app_label}:{model_name}")
             return
 
-        viewname = get_viewname(model, action='list')
-
         # Evaluate user's permission. Note that this controls only whether the HTMX element is
         # embedded on the page: The view itself will also evaluate permissions separately.
         permission = get_permission_for_model(model, 'view')
         has_permission = request.user.has_perm(permission)
 
         try:
-            htmx_url = reverse(viewname)
+            htmx_url = get_action_url(model, action='list')
         except NoReverseMatch:
             htmx_url = None
         parameters = self.config.get('url_params') or {}
@@ -297,7 +295,7 @@ class ObjectListWidget(DashboardWidget):
             except ValueError:
                 pass
         return render_to_string(self.template_name, {
-            'viewname': viewname,
+            'model_name': model_name,
             'has_permission': has_permission,
             'htmx_url': htmx_url,
         })
@@ -309,6 +307,7 @@ class RSSFeedWidget(DashboardWidget):
     default_config = {
         'max_entries': 10,
         'cache_timeout': 3600,  # seconds
+        'request_timeout': 3,  # seconds
         'requires_internet': True,
     }
     description = _('Embed an RSS feed from an external website.')
@@ -335,6 +334,12 @@ class RSSFeedWidget(DashboardWidget):
             max_value=86400,  # 24 hours
             help_text=_('How long to stored the cached content (in seconds)')
         )
+        request_timeout = forms.IntegerField(
+            min_value=1,
+            max_value=60,
+            required=False,
+            help_text=_('Timeout value for fetching the feed (in seconds)')
+        )
 
     def render(self, request):
         return render_to_string(self.template_name, {
@@ -366,7 +371,7 @@ class RSSFeedWidget(DashboardWidget):
                 url=self.config['feed_url'],
                 headers={'User-Agent': f'NetBox/{settings.RELEASE.version}'},
                 proxies=resolve_proxies(url=self.config['feed_url'], context={'client': self}),
-                timeout=3
+                timeout=self.config.get('request_timeout', 3),
             )
             response.raise_for_status()
         except requests.exceptions.RequestException as e:

+ 41 - 0
netbox/extras/filtersets.py

@@ -19,6 +19,7 @@ from .models import *
 __all__ = (
     'BookmarkFilterSet',
     'ConfigContextFilterSet',
+    'ConfigContextProfileFilterSet',
     'ConfigTemplateFilterSet',
     'CustomFieldChoiceSetFilterSet',
     'CustomFieldFilterSet',
@@ -588,11 +589,51 @@ class TaggedItemFilterSet(BaseFilterSet):
         )
 
 
+class ConfigContextProfileFilterSet(NetBoxModelFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label=_('Search'),
+    )
+    data_source_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=DataSource.objects.all(),
+        label=_('Data source (ID)'),
+    )
+    data_file_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=DataSource.objects.all(),
+        label=_('Data file (ID)'),
+    )
+
+    class Meta:
+        model = ConfigContextProfile
+        fields = (
+            'id', 'name', 'description', 'auto_sync_enabled', 'data_synced',
+        )
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value) |
+            Q(comments__icontains=value)
+        )
+
+
 class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label=_('Search'),
     )
+    profile_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ConfigContextProfile.objects.all(),
+        label=_('Profile (ID)'),
+    )
+    profile = django_filters.ModelMultipleChoiceFilter(
+        field_name='profile__name',
+        queryset=ConfigContextProfile.objects.all(),
+        to_field_name='name',
+        label=_('Profile (name)'),
+    )
     region_id = django_filters.ModelMultipleChoiceFilter(
         field_name='regions',
         queryset=Region.objects.all(),

+ 41 - 1
netbox/extras/forms/bulk_edit.py

@@ -13,12 +13,14 @@ from utilities.forms.widgets import BulkEditNullBooleanSelect
 
 __all__ = (
     'ConfigContextBulkEditForm',
+    'ConfigContextProfileBulkEditForm',
     'ConfigTemplateBulkEditForm',
     'CustomFieldBulkEditForm',
     'CustomFieldChoiceSetBulkEditForm',
     'CustomLinkBulkEditForm',
     'EventRuleBulkEditForm',
     'ExportTemplateBulkEditForm',
+    'ImageAttachmentBulkEditForm',
     'JournalEntryBulkEditForm',
     'NotificationGroupBulkEditForm',
     'SavedFilterBulkEditForm',
@@ -317,6 +319,25 @@ class TagBulkEditForm(ChangelogMessageMixin, BulkEditForm):
     nullable_fields = ('description',)
 
 
+class ConfigContextProfileBulkEditForm(NetBoxModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ConfigContextProfile.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        required=False,
+        max_length=100
+    )
+    comments = CommentField()
+
+    model = ConfigContextProfile
+    fieldsets = (
+        FieldSet('description',),
+    )
+    nullable_fields = ('description',)
+
+
 class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=ConfigContext.objects.all(),
@@ -327,6 +348,10 @@ class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
         required=False,
         min_value=0
     )
+    profile = DynamicModelChoiceField(
+        queryset=ConfigContextProfile.objects.all(),
+        required=False
+    )
     is_active = forms.NullBooleanField(
         label=_('Is active'),
         required=False,
@@ -338,7 +363,10 @@ class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm):
         max_length=100
     )
 
-    nullable_fields = ('description',)
+    fieldsets = (
+        FieldSet('weight', 'profile', 'is_active', 'description'),
+    )
+    nullable_fields = ('profile', 'description')
 
 
 class ConfigTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm):
@@ -374,6 +402,18 @@ class ConfigTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm):
     nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension')
 
 
+class ImageAttachmentBulkEditForm(ChangelogMessageMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ImageAttachment.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+
+
 class JournalEntryBulkEditForm(ChangelogMessageMixin, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=JournalEntry.objects.all(),

+ 10 - 0
netbox/extras/forms/bulk_import.py

@@ -18,6 +18,7 @@ from utilities.forms.fields import (
 )
 
 __all__ = (
+    'ConfigContextProfileImportForm',
     'ConfigTemplateImportForm',
     'CustomFieldChoiceSetImportForm',
     'CustomFieldImportForm',
@@ -149,6 +150,15 @@ class ExportTemplateImportForm(CSVModelForm):
         )
 
 
+class ConfigContextProfileImportForm(NetBoxModelImportForm):
+
+    class Meta:
+        model = ConfigContextProfile
+        fields = [
+            'name', 'description', 'schema', 'comments', 'tags',
+        ]
+
+
 class ConfigTemplateImportForm(CSVModelForm):
 
     class Meta:

+ 28 - 0
netbox/extras/forms/filtersets.py

@@ -20,6 +20,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 __all__ = (
     'ConfigContextFilterForm',
+    'ConfigContextProfileFilterForm',
     'ConfigTemplateFilterForm',
     'CustomFieldChoiceSetFilterForm',
     'CustomFieldFilterForm',
@@ -354,16 +355,43 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
     )
 
 
+class ConfigContextProfileFilterForm(SavedFiltersMixin, FilterForm):
+    model = ConfigContextProfile
+    fieldsets = (
+        FieldSet('q', 'filter_id'),
+        FieldSet('data_source_id', 'data_file_id', name=_('Data')),
+    )
+    data_source_id = DynamicModelMultipleChoiceField(
+        queryset=DataSource.objects.all(),
+        required=False,
+        label=_('Data source')
+    )
+    data_file_id = DynamicModelMultipleChoiceField(
+        queryset=DataFile.objects.all(),
+        required=False,
+        label=_('Data file'),
+        query_params={
+            'source_id': '$data_source_id'
+        }
+    )
+
+
 class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
     model = ConfigContext
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag_id'),
+        FieldSet('profile', name=_('Config Context')),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
         FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
         FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant'))
     )
+    profile_id = DynamicModelMultipleChoiceField(
+        queryset=ConfigContextProfile.objects.all(),
+        required=False,
+        label=_('Profile')
+    )
     data_source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
         required=False,

+ 34 - 4
netbox/extras/forms/model_forms.py

@@ -29,6 +29,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
 __all__ = (
     'BookmarkForm',
     'ConfigContextForm',
+    'ConfigContextProfileForm',
     'ConfigTemplateForm',
     'CustomFieldChoiceSetForm',
     'CustomFieldForm',
@@ -585,7 +586,36 @@ class TagForm(ChangelogMessageMixin, forms.ModelForm):
         ]
 
 
+class ConfigContextProfileForm(SyncedDataMixin, NetBoxModelForm):
+    schema = JSONField(
+        label=_('Schema'),
+        required=False,
+        help_text=_("Enter a valid JSON schema to define supported attributes.")
+    )
+    tags = DynamicModelMultipleChoiceField(
+        label=_('Tags'),
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    fieldsets = (
+        FieldSet('name', 'description', 'schema', 'tags', name=_('Config Context Profile')),
+        FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
+    )
+
+    class Meta:
+        model = ConfigContextProfile
+        fields = (
+            'name', 'description', 'schema', 'data_source', 'data_file', 'auto_sync_enabled', 'comments', 'tags',
+        )
+
+
 class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm):
+    profile = DynamicModelChoiceField(
+        label=_('Profile'),
+        queryset=ConfigContextProfile.objects.all(),
+        required=False
+    )
     regions = DynamicModelMultipleChoiceField(
         label=_('Regions'),
         queryset=Region.objects.all(),
@@ -657,7 +687,7 @@ class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm)
     )
 
     fieldsets = (
-        FieldSet('name', 'weight', 'description', 'data', 'is_active', name=_('Config Context')),
+        FieldSet('name', 'weight', 'profile', 'description', 'data', 'is_active', name=_('Config Context')),
         FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
         FieldSet(
             'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
@@ -669,9 +699,9 @@ class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm)
     class Meta:
         model = ConfigContext
         fields = (
-            'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
-            'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
-            'tenants', 'tags', 'data_source', 'data_file', 'auto_sync_enabled',
+            'name', 'weight', 'profile', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites',
+            'locations', 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
+            'tenant_groups', 'tenants', 'tags', 'data_source', 'data_file', 'auto_sync_enabled',
         )
 
     def __init__(self, *args, initial=None, **kwargs):

+ 9 - 1
netbox/extras/graphql/filters.py

@@ -8,7 +8,7 @@ from strawberry_django import FilterLookup
 from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
 from extras import models
 from extras.graphql.filter_mixins import TagBaseFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin
-from netbox.graphql.filter_mixins import SyncedDataFilterMixin
+from netbox.graphql.filter_mixins import PrimaryModelFilterMixin, SyncedDataFilterMixin
 
 if TYPE_CHECKING:
     from core.graphql.filters import ContentTypeFilter
@@ -24,6 +24,7 @@ if TYPE_CHECKING:
 
 __all__ = (
     'ConfigContextFilter',
+    'ConfigContextProfileFilter',
     'ConfigTemplateFilter',
     'CustomFieldFilter',
     'CustomFieldChoiceSetFilter',
@@ -97,6 +98,13 @@ class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Chan
     )
 
 
+@strawberry_django.filter_type(models.ConfigContextProfile, lookups=True)
+class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilterMixin):
+    name: FilterLookup[str] = strawberry_django.filter_field()
+    description: FilterLookup[str] = strawberry_django.filter_field()
+    tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
+
+
 @strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
 class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
     name: FilterLookup[str] | None = strawberry_django.filter_field()

+ 3 - 0
netbox/extras/graphql/schema.py

@@ -11,6 +11,9 @@ class ExtrasQuery:
     config_context: ConfigContextType = strawberry_django.field()
     config_context_list: List[ConfigContextType] = strawberry_django.field()
 
+    config_context_profile: ConfigContextProfileType = strawberry_django.field()
+    config_context_profile_list: List[ConfigContextProfileType] = strawberry_django.field()
+
     config_template: ConfigTemplateType = strawberry_django.field()
     config_template_list: List[ConfigTemplateType] = strawberry_django.field()
 

+ 18 - 12
netbox/extras/graphql/types.py

@@ -3,13 +3,13 @@ from typing import Annotated, List, TYPE_CHECKING
 import strawberry
 import strawberry_django
 
+from core.graphql.mixins import SyncedDataMixin
 from extras import models
 from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
-from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, OrganizationalObjectType
+from netbox.graphql.types import BaseObjectType, ContentTypeType, NetBoxObjectType, ObjectType, OrganizationalObjectType
 from .filters import *
 
 if TYPE_CHECKING:
-    from core.graphql.types import DataFileType, DataSourceType
     from dcim.graphql.types import (
         DeviceRoleType,
         DeviceType,
@@ -25,6 +25,7 @@ if TYPE_CHECKING:
     from virtualization.graphql.types import ClusterGroupType, ClusterType, ClusterTypeType, VirtualMachineType
 
 __all__ = (
+    'ConfigContextProfileType',
     'ConfigContextType',
     'ConfigTemplateType',
     'CustomFieldChoiceSetType',
@@ -44,15 +45,24 @@ __all__ = (
 )
 
 
+@strawberry_django.type(
+    models.ConfigContextProfile,
+    fields='__all__',
+    filters=ConfigContextProfileFilter,
+    pagination=True
+)
+class ConfigContextProfileType(SyncedDataMixin, NetBoxObjectType):
+    pass
+
+
 @strawberry_django.type(
     models.ConfigContext,
     fields='__all__',
     filters=ConfigContextFilter,
     pagination=True
 )
-class ConfigContextType(ObjectType):
-    data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
-    data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
+class ConfigContextType(SyncedDataMixin, ObjectType):
+    profile: ConfigContextProfileType | None
     roles: List[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]]
     device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]]
     tags: List[Annotated["TagType", strawberry.lazy('extras.graphql.types')]]
@@ -74,10 +84,7 @@ class ConfigContextType(ObjectType):
     filters=ConfigTemplateFilter,
     pagination=True
 )
-class ConfigTemplateType(TagsMixin, ObjectType):
-    data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
-    data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
-
+class ConfigTemplateType(SyncedDataMixin, TagsMixin, ObjectType):
     virtualmachines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]]
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]]
@@ -123,9 +130,8 @@ class CustomLinkType(ObjectType):
     filters=ExportTemplateFilter,
     pagination=True
 )
-class ExportTemplateType(ObjectType):
-    data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
-    data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
+class ExportTemplateType(SyncedDataMixin, ObjectType):
+    pass
 
 
 @strawberry_django.type(

+ 5 - 0
netbox/extras/jobs.py

@@ -59,6 +59,7 @@ class ScriptJob(JobRunner):
                 else:
                     script.log_failure(msg)
                 logger.error(f"Script aborted with error: {e}")
+                self.logger.error(f"Script aborted with error: {e}")
 
             else:
                 stacktrace = traceback.format_exc()
@@ -66,9 +67,11 @@ class ScriptJob(JobRunner):
                     message=_("An exception occurred: ") + f"`{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
                 )
                 logger.error(f"Exception raised during script execution: {e}")
+                self.logger.error(f"Exception raised during script execution: {e}")
 
             if type(e) is not AbortTransaction:
                 script.log_info(message=_("Database changes have been reverted due to error."))
+                self.logger.info("Database changes have been reverted due to error.")
 
             # Clear all pending events. Job termination (including setting the status) is handled by the job framework.
             if request:
@@ -108,9 +111,11 @@ class ScriptJob(JobRunner):
         # Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
         # change logging, event rules, etc.
         if commit:
+            self.logger.info("Executing script (commit enabled)")
             with ExitStack() as stack:
                 for request_processor in registry['request_processors']:
                     stack.enter_context(request_processor(request))
                 self.run_script(script, request, data, commit)
         else:
+            self.logger.warning("Executing script (commit disabled)")
             self.run_script(script, request, data, commit)

+ 27 - 1
netbox/extras/lookups.py

@@ -1,4 +1,5 @@
-from django.db.models import CharField, Lookup
+from django.db.models import CharField, JSONField, Lookup
+from django.db.models.fields.json import KeyTextTransform
 
 from .fields import CachedValueField
 
@@ -18,6 +19,30 @@ class Empty(Lookup):
             return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
 
 
+class JSONEmpty(Lookup):
+    """
+    Support "empty" lookups for JSONField keys.
+
+    A key is considered empty if it is "", null, or does not exist.
+    """
+    lookup_name = "empty"
+
+    def as_sql(self, compiler, connection):
+        # self.lhs.lhs is the parent expression (could be a JSONField or another KeyTransform)
+        # Rebuild the expression using KeyTextTransform to guarantee ->> (text)
+        text_expr = KeyTextTransform(self.lhs.key_name, self.lhs.lhs)
+        lhs_sql, lhs_params = compiler.compile(text_expr)
+
+        value = self.rhs
+        if value not in (True, False):
+            raise ValueError("The 'empty' lookup only accepts True or False.")
+
+        condition = '' if value else 'NOT '
+        sql = f"(NULLIF({lhs_sql}, '') IS {condition}NULL)"
+
+        return sql, lhs_params
+
+
 class NetHost(Lookup):
     """
     Similar to ipam.lookups.NetHost, but casts the field to INET.
@@ -45,5 +70,6 @@ class NetContainsOrEquals(Lookup):
 
 
 CharField.register_lookup(Empty)
+JSONField.register_lookup(JSONEmpty)
 CachedValueField.register_lookup(NetHost)
 CachedValueField.register_lookup(NetContainsOrEquals)

+ 75 - 0
netbox/extras/migrations/0132_configcontextprofile.py

@@ -0,0 +1,75 @@
+# Generated by Django 5.2.4 on 2025-08-08 16:40
+
+import django.db.models.deletion
+import netbox.models.deletion
+import taggit.managers
+import utilities.json
+import utilities.jsonschema
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('core', '0018_concrete_objecttype'),
+        ('extras', '0131_concrete_objecttype'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ConfigContextProfile',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(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=utilities.json.CustomFieldJSONEncoder),
+                ),
+                ('data_path', models.CharField(blank=True, editable=False, max_length=1000)),
+                ('auto_sync_enabled', models.BooleanField(default=False)),
+                ('data_synced', models.DateTimeField(blank=True, editable=False, null=True)),
+                ('comments', models.TextField(blank=True)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('schema', models.JSONField(blank=True, null=True, validators=[utilities.jsonschema.validate_schema])),
+                (
+                    'data_file',
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name='+',
+                        to='core.datafile',
+                    ),
+                ),
+                (
+                    'data_source',
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name='+',
+                        to='core.datasource',
+                    ),
+                ),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'config context profile',
+                'verbose_name_plural': 'config context profiles',
+                'ordering': ('name',),
+            },
+            bases=(netbox.models.deletion.DeleteMixin, models.Model),
+        ),
+        migrations.AddField(
+            model_name='configcontext',
+            name='profile',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.PROTECT,
+                related_name='config_contexts',
+                to='extras.configcontextprofile',
+            ),
+        ),
+    ]

+ 61 - 4
netbox/extras/models/configs.py

@@ -1,4 +1,6 @@
+import jsonschema
 from collections import defaultdict
+from jsonschema.exceptions import ValidationError as JSONValidationError
 
 from django.conf import settings
 from django.core.validators import ValidationError
@@ -9,13 +11,15 @@ from django.utils.translation import gettext_lazy as _
 from core.models import ObjectType
 from extras.models.mixins import RenderTemplateMixin
 from extras.querysets import ConfigContextQuerySet
-from netbox.models import ChangeLoggedModel
+from netbox.models import ChangeLoggedModel, PrimaryModel
 from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
 from utilities.data import deepmerge
+from utilities.jsonschema import validate_schema
 
 __all__ = (
     'ConfigContext',
     'ConfigContextModel',
+    'ConfigContextProfile',
     'ConfigTemplate',
 )
 
@@ -24,6 +28,46 @@ __all__ = (
 # Config contexts
 #
 
+class ConfigContextProfile(SyncedDataMixin, PrimaryModel):
+    """
+    A profile which can be used to enforce parameters on a ConfigContext.
+    """
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        unique=True
+    )
+    description = models.CharField(
+        verbose_name=_('description'),
+        max_length=200,
+        blank=True
+    )
+    schema = models.JSONField(
+        blank=True,
+        null=True,
+        validators=[validate_schema],
+        verbose_name=_('schema'),
+        help_text=_('A JSON schema specifying the structure of the context data for this profile')
+    )
+
+    clone_fields = ('schema',)
+
+    class Meta:
+        ordering = ('name',)
+        verbose_name = _('config context profile')
+        verbose_name_plural = _('config context profiles')
+
+    def __str__(self):
+        return self.name
+
+    def sync_data(self):
+        """
+        Synchronize schema from the designated DataFile (if any).
+        """
+        self.schema = self.data_file.get_data()
+    sync_data.alters_data = True
+
+
 class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLoggedModel):
     """
     A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
@@ -35,6 +79,13 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
         max_length=100,
         unique=True
     )
+    profile = models.ForeignKey(
+        to='extras.ConfigContextProfile',
+        on_delete=models.PROTECT,
+        blank=True,
+        null=True,
+        related_name='config_contexts',
+    )
     weight = models.PositiveSmallIntegerField(
         verbose_name=_('weight'),
         default=1000
@@ -118,9 +169,8 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
     objects = ConfigContextQuerySet.as_manager()
 
     clone_fields = (
-        'weight', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types',
-        'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
-        'tenants', 'tags', 'data',
+        'weight', 'profile', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles',
+        'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
     )
 
     class Meta:
@@ -147,6 +197,13 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
                 {'data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
             )
 
+        # Validate config data against the assigned profile's schema (if any)
+        if self.profile and self.profile.schema:
+            try:
+                jsonschema.validate(self.data, schema=self.profile.schema)
+            except JSONValidationError as e:
+                raise ValidationError(_("Data does not conform to profile schema: {error}").format(error=e))
+
     def sync_data(self):
         """
         Synchronize context data from the designated DataFile (if any).

+ 9 - 1
netbox/extras/models/customfields.py

@@ -600,11 +600,19 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         kwargs = {
             'field_name': f'custom_field_data__{self.name}'
         }
+        # Native numeric filters will use `isnull` by default for empty lookups, but
+        # JSON fields require `empty` (see bug #20012).
+        if lookup_expr == 'isnull':
+            lookup_expr = 'empty'
         if lookup_expr is not None:
             kwargs['lookup_expr'] = lookup_expr
 
+        # 'Empty' lookup is always a boolean
+        if lookup_expr == 'empty':
+            filter_class = django_filters.BooleanFilter
+
         # Text/URL
-        if self.type in (
+        elif self.type in (
                 CustomFieldTypeChoices.TYPE_TEXT,
                 CustomFieldTypeChoices.TYPE_LONGTEXT,
                 CustomFieldTypeChoices.TYPE_URL,

+ 3 - 0
netbox/extras/models/models.py

@@ -872,6 +872,9 @@ class Bookmark(models.Model):
             return str(self.object)
         return super().__str__()
 
+    def get_absolute_url(self):
+        return reverse('account:bookmarks')
+
     def clean(self):
         super().clean()
 

+ 8 - 5
netbox/extras/models/notifications.py

@@ -173,14 +173,17 @@ class NotificationGroup(ChangeLoggedModel):
             User.objects.filter(groups__in=self.groups.all())
         ).order_by('username')
 
-    def notify(self, **kwargs):
+    def notify(self, object_type, object_id, **kwargs):
         """
         Bulk-create Notifications for all members of this group.
         """
-        Notification.objects.bulk_create([
-            Notification(user=member, **kwargs)
-            for member in self.members
-        ])
+        for user in self.members:
+            Notification.objects.update_or_create(
+                object_type=object_type,
+                object_id=object_id,
+                user=user,
+                defaults=kwargs
+            )
     notify.alters_data = True
 
 

+ 4 - 4
netbox/extras/scripts.py

@@ -588,9 +588,9 @@ class BaseScript:
         """
         Return data from a YAML file
         """
-        # TODO: DEPRECATED: Remove this method in v4.4
+        # TODO: DEPRECATED: Remove this method in v4.5
         self._log(
-            _("load_yaml is deprecated and will be removed in v4.4"),
+            _("load_yaml is deprecated and will be removed in v4.5"),
             level=LogLevelChoices.LOG_WARNING
         )
         file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
@@ -603,9 +603,9 @@ class BaseScript:
         """
         Return data from a JSON file
         """
-        # TODO: DEPRECATED: Remove this method in v4.4
+        # TODO: DEPRECATED: Remove this method in v4.5
         self._log(
-            _("load_json is deprecated and will be removed in v4.4"),
+            _("load_json is deprecated and will be removed in v4.5"),
             level=LogLevelChoices.LOG_WARNING
         )
         file_path = os.path.join(settings.SCRIPTS_ROOT, filename)

+ 11 - 0
netbox/extras/search.py

@@ -2,6 +2,17 @@ from netbox.search import SearchIndex, register_search
 from . import models
 
 
+@register_search
+class ConfigContextProfileIndex(SearchIndex):
+    model = models.ConfigContextProfile
+    fields = (
+        ('name', 100),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('description',)
+
+
 @register_search
 class CustomFieldIndex(SearchIndex):
     model = models.CustomField

+ 43 - 8
netbox/extras/tables/tables.py

@@ -15,6 +15,7 @@ from .columns import NotificationActionsColumn
 
 __all__ = (
     'BookmarkTable',
+    'ConfigContextProfileTable',
     'ConfigContextTable',
     'ConfigTemplateTable',
     'CustomFieldChoiceSetTable',
@@ -39,9 +40,8 @@ __all__ = (
 
 IMAGEATTACHMENT_IMAGE = """
 {% if record.image %}
-  <a class="image-preview" href="{{ record.image.url }}" target="_blank">
-    <i class="mdi mdi-image"></i>
-  </a>
+  <a href="{{ record.image.url }}" target="_blank" class="image-preview" data-bs-placement="top">
+    <i class="mdi mdi-image"></i></a>
 {% endif %}
 <a href="{{ record.get_absolute_url }}">{{ record }}</a>
 """
@@ -235,6 +235,7 @@ class ImageAttachmentTable(NetBoxTable):
     image = columns.TemplateColumn(
         verbose_name=_('Image'),
         template_code=IMAGEATTACHMENT_IMAGE,
+        attrs={'td': {'class': 'text-nowrap'}}
     )
     name = tables.Column(
         verbose_name=_('Name'),
@@ -254,7 +255,7 @@ class ImageAttachmentTable(NetBoxTable):
         verbose_name=_('Object Type'),
     )
     parent = tables.Column(
-        verbose_name=_('Parent'),
+        verbose_name=_('Object'),
         linkify=True,
         orderable=False,
     )
@@ -546,7 +547,41 @@ class TaggedItemTable(NetBoxTable):
         fields = ('id', 'content_type', 'content_object')
 
 
+class ConfigContextProfileTable(NetBoxTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True
+    )
+    data_source = tables.Column(
+        verbose_name=_('Data Source'),
+        linkify=True
+    )
+    data_file = tables.Column(
+        verbose_name=_('Data File'),
+        linkify=True
+    )
+    is_synced = columns.BooleanColumn(
+        orderable=False,
+        verbose_name=_('Synced')
+    )
+    tags = columns.TagColumn(
+        url_name='extras:configcontextprofile_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = ConfigContextProfile
+        fields = (
+            'pk', 'id', 'name', 'description', 'comments', 'data_source', 'data_file', 'is_synced', 'tags', 'created',
+            'last_updated',
+        )
+        default_columns = ('pk', 'name', 'is_synced', 'description')
+
+
 class ConfigContextTable(NetBoxTable):
+    profile = tables.Column(
+        linkify=True,
+        verbose_name=_('Profile'),
+    )
     data_source = tables.Column(
         verbose_name=_('Data Source'),
         linkify=True
@@ -573,11 +608,11 @@ class ConfigContextTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = ConfigContext
         fields = (
-            'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations',
-            'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
-            'data_source', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
+            'pk', 'id', 'name', 'weight', 'profile', 'is_active', 'is_synced', 'description', 'regions', 'sites',
+            'locations', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
+            'tenants', 'data_source', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
         )
-        default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description')
+        default_columns = ('pk', 'name', 'weight', 'profile', 'is_active', 'is_synced', 'description')
 
 
 class ConfigTemplateTable(NetBoxTable):

+ 64 - 0
netbox/extras/tests/test_api.py

@@ -666,6 +666,70 @@ class JournalEntryTest(APIViewTestCases.APIViewTestCase):
         ]
 
 
+class ConfigContextProfileTest(APIViewTestCases.APIViewTestCase):
+    model = ConfigContextProfile
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
+    create_data = [
+        {
+            'name': 'Config Context Profile 4',
+        },
+        {
+            'name': 'Config Context Profile 5',
+        },
+        {
+            'name': 'Config Context Profile 6',
+        },
+    ]
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        profiles = (
+            ConfigContextProfile(
+                name='Config Context Profile 1',
+                schema={
+                    "properties": {
+                        "foo": {
+                            "type": "string"
+                        }
+                    },
+                    "required": [
+                        "foo"
+                    ]
+                }
+            ),
+            ConfigContextProfile(
+                name='Config Context Profile 2',
+                schema={
+                    "properties": {
+                        "bar": {
+                            "type": "string"
+                        }
+                    },
+                    "required": [
+                        "bar"
+                    ]
+                }
+            ),
+            ConfigContextProfile(
+                name='Config Context Profile 3',
+                schema={
+                    "properties": {
+                        "baz": {
+                            "type": "string"
+                        }
+                    },
+                    "required": [
+                        "baz"
+                    ]
+                }
+            ),
+        )
+        ConfigContextProfile.objects.bulk_create(profiles)
+
+
 class ConfigContextTest(APIViewTestCases.APIViewTestCase):
     model = ConfigContext
     brief_fields = ['description', 'display', 'id', 'name', 'url']

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

@@ -1615,6 +1615,7 @@ class CustomFieldModelFilterTest(TestCase):
                 'cf11': manufacturers[2].pk,
                 'cf12': [manufacturers[2].pk, manufacturers[3].pk],
             }),
+            Site(name='Site 4', slug='site-4'),
         ])
 
     def test_filter_integer(self):
@@ -1624,6 +1625,7 @@ class CustomFieldModelFilterTest(TestCase):
         self.assertEqual(self.filterset({'cf_cf1__gte': [200]}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf1__empty': True}, self.queryset).qs.count(), 1)
 
     def test_filter_decimal(self):
         self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2)
@@ -1632,6 +1634,7 @@ class CustomFieldModelFilterTest(TestCase):
         self.assertEqual(self.filterset({'cf_cf2__gte': [200.2]}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf2__lt': [200.2]}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf2__lte': [200.2]}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf2__empty': True}, self.queryset).qs.count(), 1)
 
     def test_filter_boolean(self):
         self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2)
@@ -1648,6 +1651,7 @@ class CustomFieldModelFilterTest(TestCase):
         self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf4__empty': True}, self.queryset).qs.count(), 1)
 
     def test_filter_text_loose(self):
         self.assertEqual(self.filterset({'cf_cf5': ['foo']}, self.queryset).qs.count(), 2)
@@ -1659,6 +1663,7 @@ class CustomFieldModelFilterTest(TestCase):
         self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf6__lte': ['2016-06-27']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf6__empty': True}, self.queryset).qs.count(), 1)
 
     def test_filter_url_strict(self):
         self.assertEqual(
@@ -1674,17 +1679,20 @@ class CustomFieldModelFilterTest(TestCase):
         self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 0)
         self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf7__empty': True}, self.queryset).qs.count(), 1)
 
     def test_filter_url_loose(self):
         self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
 
     def test_filter_select(self):
         self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf9__empty': True}, self.queryset).qs.count(), 1)
 
     def test_filter_multiselect(self):
         self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2)
-        self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1)  # Contains a literal null
+        self.assertEqual(self.filterset({'cf_cf10__empty': True}, self.queryset).qs.count(), 2)
 
     def test_filter_object(self):
         manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
@@ -1692,6 +1700,7 @@ class CustomFieldModelFilterTest(TestCase):
             self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(),
             2
         )
+        self.assertEqual(self.filterset({'cf_cf11__empty': True}, self.queryset).qs.count(), 1)
 
     def test_filter_multiobject(self):
         manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
@@ -1703,3 +1712,4 @@ class CustomFieldModelFilterTest(TestCase):
             self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(),
             3
         )
+        self.assertEqual(self.filterset({'cf_cf12__empty': True}, self.queryset).qs.count(), 1)

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

@@ -45,4 +45,4 @@ class ObjectListWidgetTests(TestCase):
         mock_request = Request()
         widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
         rendered = widget.render(mock_request)
-        self.assertTrue('Unable to load content. Invalid view name:' in rendered)
+        self.assertTrue('Unable to load content. Could not resolve list URL for:' in rendered)

+ 50 - 1
netbox/extras/tests/test_filtersets.py

@@ -871,6 +871,39 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+class ConfigContextProfileTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = ConfigContextProfile.objects.all()
+    filterset = ConfigContextProfileFilterSet
+    ignore_fields = ('schema', 'data_path')
+
+    @classmethod
+    def setUpTestData(cls):
+        profiles = (
+            ConfigContextProfile(
+                name='Config Context Profile 1',
+                description='foo',
+            ),
+            ConfigContextProfile(
+                name='Config Context Profile 2',
+                description='bar',
+            ),
+            ConfigContextProfile(
+                name='Config Context Profile 3',
+                description='baz',
+            ),
+        )
+        ConfigContextProfile.objects.bulk_create(profiles)
+
+    def test_q(self):
+        params = {'q': 'foo'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_name(self):
+        profiles = self.queryset.all()[:2]
+        params = {'name': [profiles[0].name, profiles[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
 class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConfigContext.objects.all()
     filterset = ConfigContextFilterSet
@@ -878,6 +911,12 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
 
     @classmethod
     def setUpTestData(cls):
+        profiles = (
+            ConfigContextProfile(name='Config Context Profile 1'),
+            ConfigContextProfile(name='Config Context Profile 2'),
+            ConfigContextProfile(name='Config Context Profile 3'),
+        )
+        ConfigContextProfile.objects.bulk_create(profiles)
 
         regions = (
             Region(name='Region 1', slug='region-1'),
@@ -931,7 +970,8 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
             Platform(name='Platform 2', slug='platform-2'),
             Platform(name='Platform 3', slug='platform-3'),
         )
-        Platform.objects.bulk_create(platforms)
+        for platform in platforms:
+            platform.save()
 
         cluster_types = (
             ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
@@ -975,6 +1015,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
             is_active = bool(i % 2)
             c = ConfigContext.objects.create(
                 name=f"Config Context {i + 1}",
+                profile=profiles[i],
                 is_active=is_active,
                 data='{"foo": 123}',
                 description=f"foobar{i + 1}"
@@ -1011,6 +1052,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_profile(self):
+        profiles = ConfigContextProfile.objects.all()[:2]
+        params = {'profile_id': [profiles[0].pk, profiles[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'profile': [profiles[0].name, profiles[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_region(self):
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -1184,6 +1232,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         'cluster',
         'clustergroup',
         'clustertype',
+        'configcontextprofile',
         'configtemplate',
         'consoleport',
         'consoleserverport',

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