فهرست منبع

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

Daniel Sheppard 6 ماه پیش
والد
کامیت
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:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.3.5
+      placeholder: v4.4.0
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

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

@@ -27,7 +27,7 @@ body:
     attributes:
     attributes:
       label: NetBox Version
       label: NetBox Version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.3.5
+      placeholder: v4.4.0
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - 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
 # Introspection for embedded code
 # https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
 # 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
 # Library for manipulating IP prefixes and addresses
 # https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
 # https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
@@ -135,7 +139,8 @@ requests
 
 
 # rq
 # rq
 # https://github.com/rq/rq/blob/master/CHANGES.md
 # 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
 # Django app for social-auth-core
 # https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
 # 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-c8",
                         "iec-60320-c14",
                         "iec-60320-c14",
                         "iec-60320-c16",
                         "iec-60320-c16",
+                        "iec-60320-c18",
                         "iec-60320-c20",
                         "iec-60320-c20",
                         "iec-60320-c22",
                         "iec-60320-c22",
                         "iec-60309-p-n-e-4h",
                         "iec-60309-p-n-e-4h",
@@ -209,6 +210,7 @@
                         "iec-60320-c7",
                         "iec-60320-c7",
                         "iec-60320-c13",
                         "iec-60320-c13",
                         "iec-60320-c15",
                         "iec-60320-c15",
+                        "iec-60320-c17",
                         "iec-60320-c19",
                         "iec-60320-c19",
                         "iec-60320-c21",
                         "iec-60320-c21",
                         "iec-60309-p-n-e-4h",
                         "iec-60309-p-n-e-4h",
@@ -474,6 +476,13 @@
                         "passive-48v-2pair",
                         "passive-48v-2pair",
                         "passive-48v-4pair"
                         "passive-48v-4pair"
                     ]
                     ]
+                },
+                "rf_role": {
+                    "type": "string",
+                    "enum": [
+                        "ap",
+                        "station"
+                    ]
                 }
                 }
             }
             }
         },
         },

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

@@ -4,7 +4,7 @@
 
 
 ### Enabling Error Reporting
 ### 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
 ```python
 SENTRY_ENABLED = True
 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`
 ### `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`
 ### `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).
 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
 ## 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`.
 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
 ### Update System Requirements
 
 
 If a new Django release is adopted or other major dependencies (Python, PostgreSQL, Redis) change:
 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 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 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`)
 * 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
 ### 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
 ## 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.
 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.
 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
 ## Fields
 
 
+## Parent
+
+!!! "This field was introduced in NetBox v4.4."
+
+The parent platform class to which this platform belongs (optional).
+
 ### Name
 ### Name
 
 
 A human-friendly name for the platform. Must be unique per manufacturer.
 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.
 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
 ### User
 
 
 The NetBox user account associated with the reservation. Note that users with sufficient permission can make rack reservations for other users.
 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.
 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
 ### Data
 
 
 The context data expressed in JSON format.
 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
 ## 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
 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
 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
 ::: 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
 ## 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.)
 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`.
 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
 ::: netbox.tables.BooleanColumn
     options:
     options:
       members: false
       members: false

+ 1 - 1
docs/reference/conditions.md

@@ -89,7 +89,7 @@ The following condition will evaluate as true:
 ```
 ```
 
 
 !!! note "Evaluating static choice fields"
 !!! 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
 ## 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)
 #### [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))
 * 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))
 * 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)
 #### [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
 # 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)
 ## v4.3.5 (2025-07-29)
 
 
 ### Enhancements
 ### Enhancements
@@ -16,6 +71,11 @@
 * [#19934](https://github.com/netbox-community/netbox/issues/19934) - Added missing description field to tenant bulk edit form
 * [#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
 * [#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)
 ## v4.3.4 (2025-07-15)
 
 
 ### Enhancements
 ### Enhancements

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

@@ -1,6 +1,6 @@
 # NetBox v4.4
 # NetBox v4.4
 
 
-## v4.4.0 (FUTURE)
+## v4.4.0 (2025-09-02)
 
 
 ### New Features
 ### 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.
 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`.
 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.
 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
 ### Enhancements
 
 
 * [#17413](https://github.com/netbox-community/netbox/issues/17413) - Platforms belonging to different manufacturers may now have identical names
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 ### 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`
 * [#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
 ### 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)
 * [#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/`
 * [#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)
 * [#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
 ### 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/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/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:
 * 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`
     * `is_plugin_model`
     * `rest_api_endpoint`
     * `rest_api_endpoint`
     * `description`
     * `description`
+* Introduced the `/api/extras/config-context-profiles/` endpoint
+* core.Job
+    * Added the read-only `log_entries` array field
 * dcim.Interface
 * dcim.Interface
     * The `tx_power` field now accepts negative values
     * 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
 * extras.ImageAttachment
     * Added an optional `description` field
     * Added an optional `description` field

+ 3 - 0
mkdocs.yml

@@ -30,6 +30,8 @@ plugins:
         python:
         python:
           paths: ["netbox"]
           paths: ["netbox"]
           options:
           options:
+            docstring_options:
+              warn_missing_types: false
             heading_level: 3
             heading_level: 3
             members_order: source
             members_order: source
             show_root_heading: true
             show_root_heading: true
@@ -226,6 +228,7 @@ nav:
         - Extras:
         - Extras:
             - Bookmark: 'models/extras/bookmark.md'
             - Bookmark: 'models/extras/bookmark.md'
             - ConfigContext: 'models/extras/configcontext.md'
             - ConfigContext: 'models/extras/configcontext.md'
+            - ConfigContextProfile: 'models/extras/configcontextprofile.md'
             - ConfigTemplate: 'models/extras/configtemplate.md'
             - ConfigTemplate: 'models/extras/configtemplate.md'
             - CustomField: 'models/extras/customfield.md'
             - CustomField: 'models/extras/customfield.md'
             - CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.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'))),
     path('circuit-group-assignments/<int:pk>/', include(get_model_urls('circuits', 'circuitgroupassignment'))),
 
 
     # Virtual circuits
     # 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-circuits/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuit'))),
 
 
     path('virtual-circuit-types/', include(get_model_urls('circuits', 'virtualcircuittype', detail=False))),
     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
 # Virtual circuits
 #
 #
 
 
+@register_model_view(VirtualCircuit, 'list', path='', detail=False)
 class VirtualCircuitListView(generic.ObjectListView):
 class VirtualCircuitListView(generic.ObjectListView):
     queryset = VirtualCircuit.objects.annotate(
     queryset = VirtualCircuit.objects.annotate(
         termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
         termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
@@ -701,6 +702,7 @@ class VirtualCircuitView(generic.ObjectView):
     queryset = VirtualCircuit.objects.all()
     queryset = VirtualCircuit.objects.all()
 
 
 
 
+@register_model_view(VirtualCircuit, 'add', detail=False)
 @register_model_view(VirtualCircuit, 'edit')
 @register_model_view(VirtualCircuit, 'edit')
 class VirtualCircuitEditView(generic.ObjectEditView):
 class VirtualCircuitEditView(generic.ObjectEditView):
     queryset = VirtualCircuit.objects.all()
     queryset = VirtualCircuit.objects.all()
@@ -712,6 +714,7 @@ class VirtualCircuitDeleteView(generic.ObjectDeleteView):
     queryset = VirtualCircuit.objects.all()
     queryset = VirtualCircuit.objects.all()
 
 
 
 
+@register_model_view(VirtualCircuit, 'bulk_import', path='import', detail=False)
 class VirtualCircuitBulkImportView(generic.BulkImportView):
 class VirtualCircuitBulkImportView(generic.BulkImportView):
     queryset = VirtualCircuit.objects.all()
     queryset = VirtualCircuit.objects.all()
     model_form = forms.VirtualCircuitImportForm
     model_form = forms.VirtualCircuitImportForm
@@ -727,6 +730,7 @@ class VirtualCircuitBulkImportView(generic.BulkImportView):
         return data
         return data
 
 
 
 
+@register_model_view(VirtualCircuit, 'bulk_edit', path='edit', detail=False)
 class VirtualCircuitBulkEditView(generic.BulkEditView):
 class VirtualCircuitBulkEditView(generic.BulkEditView):
     queryset = VirtualCircuit.objects.annotate(
     queryset = VirtualCircuit.objects.annotate(
         termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
         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)
 @register_model_view(VirtualCircuit, 'bulk_rename', path='rename', detail=False)
-class VirtualCircuitulkRenameView(generic.BulkRenameView):
+class VirtualCircuitBulkRenameView(generic.BulkRenameView):
     queryset = VirtualCircuit.objects.all()
     queryset = VirtualCircuit.objects.all()
     field_name = 'cid'
     field_name = 'cid'
 
 
 
 
+@register_model_view(VirtualCircuit, 'bulk_delete', path='delete', detail=False)
 class VirtualCircuitBulkDeleteView(generic.BulkDeleteView):
 class VirtualCircuitBulkDeleteView(generic.BulkDeleteView):
     queryset = VirtualCircuit.objects.annotate(
     queryset = VirtualCircuit.objects.annotate(
         termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
         termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')

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

@@ -1,13 +1,13 @@
 import inspect
 import inspect
 
 
-from django.urls import NoReverseMatch, reverse
+from django.urls import NoReverseMatch
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from core.models import ObjectType
 from core.models import ObjectType
 from netbox.api.serializers import BaseModelSerializer
 from netbox.api.serializers import BaseModelSerializer
-from utilities.views import get_viewname
+from utilities.views import get_action_url
 
 
 __all__ = (
 __all__ = (
     'ObjectTypeSerializer',
     'ObjectTypeSerializer',
@@ -15,7 +15,7 @@ __all__ = (
 
 
 
 
 class ObjectTypeSerializer(BaseModelSerializer):
 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)
     app_name = serializers.CharField(source='app_verbose_name', read_only=True)
     model_name = serializers.CharField(source='model_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)
     model_name_plural = serializers.CharField(source='model_verbose_name_plural', read_only=True)
@@ -26,19 +26,19 @@ class ObjectTypeSerializer(BaseModelSerializer):
     class Meta:
     class Meta:
         model = ObjectType
         model = ObjectType
         fields = [
         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)
     @extend_schema_field(OpenApiTypes.STR)
     def get_rest_api_endpoint(self, obj):
     def get_rest_api_endpoint(self, obj):
         if not (model := obj.model_class()):
         if not (model := obj.model_class()):
             return
             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)
     @extend_schema_field(OpenApiTypes.STR)
     def get_description(self, obj):
     def get_description(self, obj):

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

@@ -18,8 +18,8 @@ class BackgroundTaskSerializer(serializers.Serializer):
     description = serializers.CharField()
     description = serializers.CharField()
     origin = serializers.CharField()
     origin = serializers.CharField()
     func_name = serializers.CharField()
     func_name = serializers.CharField()
-    args = serializers.ListField(child=serializers.CharField())
-    kwargs = serializers.DictField()
+    args = serializers.SerializerMethodField()
+    kwargs = serializers.SerializerMethodField()
     result = serializers.CharField()
     result = serializers.CharField()
     timeout = serializers.IntegerField()
     timeout = serializers.IntegerField()
     result_ttl = serializers.IntegerField()
     result_ttl = serializers.IntegerField()
@@ -42,6 +42,16 @@ class BackgroundTaskSerializer(serializers.Serializer):
     is_scheduled = serializers.BooleanField()
     is_scheduled = serializers.BooleanField()
     is_stopped = 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:
     def get_position(self, obj) -> int:
         return obj.get_position()
         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-sources', views.DataSourceViewSet)
 router.register('data-files', views.DataFileViewSet)
 router.register('data-files', views.DataFileViewSet)
 router.register('jobs', views.JobViewSet)
 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('object-types', views.ObjectTypeViewSet)
 router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue')
 router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue')
 router.register('background-workers', views.BackgroundWorkerViewSet, basename='rqworker')
 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.
     Retrieve a list of recent changes.
     """
     """
     metadata_class = ContentTypeMetadata
     metadata_class = ContentTypeMetadata
-    queryset = ObjectChange.objects.valid_models()
     serializer_class = serializers.ObjectChangeSerializer
     serializer_class = serializers.ObjectChangeSerializer
     filterset_class = filtersets.ObjectChangeFilterSet
     filterset_class = filtersets.ObjectChangeFilterSet
 
 
+    def get_queryset(self):
+        return ObjectChange.objects.valid_models()
+
 
 
 class ObjectTypeViewSet(ReadOnlyModelViewSet):
 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(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
     )
     )
+    features = django_filters.CharFilter(
+        method='filter_features'
+    )
 
 
     class Meta:
     class Meta:
         model = ObjectType
         model = ObjectType
-        fields = ('id', 'app_label', 'model')
+        fields = ('id', 'app_label', 'model', 'public')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -152,6 +155,9 @@ class ObjectTypeFilterSet(django_filters.FilterSet):
             Q(model__icontains=value)
             Q(model__icontains=value)
         )
         )
 
 
+    def filter_features(self, queryset, name, value):
+        return queryset.filter(features__icontains=value)
+
 
 
 class ObjectChangeFilterSet(BaseFilterSet):
 class ObjectChangeFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
     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
 from core.models import ObjectChange
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
+    from core.graphql.types import DataFileType, DataSourceType
     from netbox.core.graphql.types import ObjectChangeType
     from netbox.core.graphql.types import ObjectChangeType
 
 
 __all__ = (
 __all__ = (
     'ChangelogMixin',
     'ChangelogMixin',
+    'SyncedDataMixin',
 )
 )
 
 
 
 
@@ -25,3 +27,9 @@ class ChangelogMixin:
             changed_object_id=self.pk
             changed_object_id=self.pk
         )
         )
         return object_changes.restrict(info.context.request.user, 'view')
         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
 import sys
 from datetime import timedelta
 from datetime import timedelta
 from importlib import import_module
 from importlib import import_module
@@ -17,8 +16,6 @@ from utilities.proxy import resolve_proxies
 from .choices import DataSourceStatusChoices, JobIntervalChoices
 from .choices import DataSourceStatusChoices, JobIntervalChoices
 from .models import DataSource
 from .models import DataSource
 
 
-logger = logging.getLogger(__name__)
-
 
 
 class SyncDataSourceJob(JobRunner):
 class SyncDataSourceJob(JobRunner):
     """
     """
@@ -69,7 +66,11 @@ class SystemHousekeepingJob(JobRunner):
 
 
     def run(self, *args, **kwargs):
     def run(self, *args, **kwargs):
         # Skip if running in development or test mode
         # 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
             return
 
 
         self.send_census_report()
         self.send_census_report()
@@ -78,17 +79,16 @@ class SystemHousekeepingJob(JobRunner):
         self.delete_expired_jobs()
         self.delete_expired_jobs()
         self.check_for_new_releases()
         self.check_for_new_releases()
 
 
-    @staticmethod
-    def send_census_report():
+    def send_census_report(self):
         """
         """
         Send a census report (if enabled).
         Send a census report (if enabled).
         """
         """
-        logging.info("Reporting census data...")
+        self.logger.info("Reporting census data...")
         if settings.ISOLATED_DEPLOYMENT:
         if settings.ISOLATED_DEPLOYMENT:
-            logging.info("ISOLATED_DEPLOYMENT is enabled; skipping")
+            self.logger.info("ISOLATED_DEPLOYMENT is enabled; skipping")
             return
             return
         if not settings.CENSUS_REPORTING_ENABLED:
         if not settings.CENSUS_REPORTING_ENABLED:
-            logging.info("CENSUS_REPORTING_ENABLED is disabled; skipping")
+            self.logger.info("CENSUS_REPORTING_ENABLED is disabled; skipping")
             return
             return
 
 
         census_data = {
         census_data = {
@@ -106,73 +106,71 @@ class SystemHousekeepingJob(JobRunner):
         except requests.exceptions.RequestException:
         except requests.exceptions.RequestException:
             pass
             pass
 
 
-    @staticmethod
-    def clear_expired_sessions():
+    def clear_expired_sessions(self):
         """
         """
         Clear any expired sessions from the database.
         Clear any expired sessions from the database.
         """
         """
-        logging.info("Clearing expired sessions...")
+        self.logger.info("Clearing expired sessions...")
         engine = import_module(settings.SESSION_ENGINE)
         engine = import_module(settings.SESSION_ENGINE)
         try:
         try:
             engine.SessionStore.clear_expired()
             engine.SessionStore.clear_expired()
-            logging.info("Sessions cleared.")
+            self.logger.info("Sessions cleared.")
         except NotImplementedError:
         except NotImplementedError:
-            logging.warning(
+            self.logger.warning(
                 f"The configured session engine ({settings.SESSION_ENGINE}) does not support "
                 f"The configured session engine ({settings.SESSION_ENGINE}) does not support "
                 f"clearing sessions; skipping."
                 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).
         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()
         config = Config()
         if not config.CHANGELOG_RETENTION:
         if not config.CHANGELOG_RETENTION:
-            logging.info("No retention period specified; skipping.")
+            self.logger.info("No retention period specified; skipping.")
             return
             return
 
 
         cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION)
         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]
         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).
         Delete any jobs older than the configured retention period (if any).
         """
         """
-        logging.info("Deleting expired jobs...")
+        self.logger.info("Deleting expired jobs...")
         config = Config()
         config = Config()
         if not config.JOB_RETENTION:
         if not config.JOB_RETENTION:
-            logging.info("No retention period specified; skipping.")
+            self.logger.info("No retention period specified; skipping.")
             return
             return
 
 
         cutoff = timezone.now() - timedelta(days=config.JOB_RETENTION)
         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]
         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.
         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:
         if settings.ISOLATED_DEPLOYMENT:
-            logging.info("ISOLATED_DEPLOYMENT is enabled; skipping")
+            self.logger.info("ISOLATED_DEPLOYMENT is enabled; skipping")
             return
             return
         if not settings.RELEASE_CHECK_URL:
         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
             return
 
 
         # Fetch the latest releases
         # 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:
         try:
             response = requests.get(
             response = requests.get(
                 url=settings.RELEASE_CHECK_URL,
                 url=settings.RELEASE_CHECK_URL,
@@ -181,7 +179,7 @@ class SystemHousekeepingJob(JobRunner):
             )
             )
             response.raise_for_status()
             response.raise_for_status()
         except requests.exceptions.RequestException as exc:
         except requests.exceptions.RequestException as exc:
-            logging.error(f"Error fetching release: {exc}")
+            self.logger.error(f"Error fetching release: {exc}")
             return
             return
 
 
         # Determine the most recent stable release
         # Determine the most recent stable release
@@ -191,8 +189,8 @@ class SystemHousekeepingJob(JobRunner):
                 continue
                 continue
             releases.append((version.parse(release['tag_name']), release.get('html_url')))
             releases.append((version.parse(release['tag_name']), release.get('html_url')))
         latest_release = max(releases)
         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 the most recent release
         cache.set('latest_release', latest_release, None)
         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:
         for app_label in app_labels:
             app_name = apps.get_app_config(app_label).verbose_name
             app_name = apps.get_app_config(app_label).verbose_name
             print(f'{app_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):
     def get_namespace(self):
         namespace = defaultdict(SimpleNamespace)
         namespace = defaultdict(SimpleNamespace)

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

@@ -1,3 +1,4 @@
+import inspect
 from collections import defaultdict
 from collections import defaultdict
 
 
 from django.contrib.contenttypes.models import ContentType
 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.
         Retrieve or create and return the ObjectType for a model.
         """
         """
         from netbox.models.features import get_model_features, model_is_public
         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)
         opts = self._get_opts(model, for_concrete_model)
 
 
         try:
         try:
@@ -75,7 +79,7 @@ class ObjectTypeManager(models.Manager):
                 app_label=opts.app_label,
                 app_label=opts.app_label,
                 model=opts.model_name,
                 model=opts.model_name,
                 public=model_is_public(model),
                 public=model_is_public(model),
-                features=get_model_features(model.__class__),
+                features=get_model_features(model),
             )[0]
             )[0]
 
 
         return ot
         return ot
@@ -93,6 +97,8 @@ class ObjectTypeManager(models.Manager):
         needed_models = defaultdict(set)
         needed_models = defaultdict(set)
         needed_opts = defaultdict(list)
         needed_opts = defaultdict(list)
         for model in models:
         for model in models:
+            if not inspect.isclass(model):
+                model = model.__class__
             opts = self._get_opts(model, for_concrete_models)
             opts = self._get_opts(model, for_concrete_models)
             needed_models[opts.app_label].add(opts.model_name)
             needed_models[opts.app_label].add(opts.model_name)
             needed_opts[(opts.app_label, opts.model_name)].append(model)
             needed_opts[(opts.app_label, opts.model_name)].append(model)
@@ -117,7 +123,7 @@ class ObjectTypeManager(models.Manager):
                     app_label=app_label,
                     app_label=app_label,
                     model=model_name,
                     model=model_name,
                     public=model_is_public(model),
                     public=model_is_public(model),
-                    features=get_model_features(model.__class__),
+                    features=get_model_features(model),
                 )
                 )
 
 
         return results
         return results
@@ -135,9 +141,9 @@ class ObjectTypeManager(models.Manager):
         """
         """
         Return ObjectTypes only for models which support the given feature.
         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')
             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.events import *
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.events import enqueue_event
 from extras.events import enqueue_event
+from extras.models import Tag
 from extras.utils import run_validators
 from extras.utils import run_validators
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.context import current_request, events_queue
 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 with objects added or removed
         m2m_changed = True
         m2m_changed = True
         event_type = OBJECT_UPDATED
         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:
     else:
         return
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
         params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         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 urllib.parse
 import uuid
 import uuid
 from datetime import datetime
 from datetime import datetime
@@ -366,6 +367,11 @@ class SystemTestCase(TestCase):
         # Test export
         # Test export
         response = self.client.get(f"{reverse('core:system')}?export=true")
         response = self.client.get(f"{reverse('core:system')}?export=true")
         self.assertEqual(response.status_code, 200)
         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):
     def test_system_view_with_config_revision(self):
         ConfigRevision.objects.create()
         ConfigRevision.objects.create()

+ 33 - 7
netbox/core/views.py

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

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

@@ -6,11 +6,13 @@ from dcim import models
 
 
 __all__ = (
 __all__ = (
     'NestedDeviceBaySerializer',
     'NestedDeviceBaySerializer',
+    'NestedDeviceRoleSerializer',
     'NestedDeviceSerializer',
     'NestedDeviceSerializer',
     'NestedInterfaceSerializer',
     'NestedInterfaceSerializer',
     'NestedInterfaceTemplateSerializer',
     'NestedInterfaceTemplateSerializer',
     'NestedLocationSerializer',
     'NestedLocationSerializer',
     'NestedModuleBaySerializer',
     'NestedModuleBaySerializer',
+    'NestedPlatformSerializer',
     'NestedRegionSerializer',
     'NestedRegionSerializer',
     'NestedSiteGroupSerializer',
     'NestedSiteGroupSerializer',
 )
 )
@@ -102,3 +104,10 @@ class NestedModuleBaySerializer(WritableNestedSerializer):
     class Meta:
     class Meta:
         model = models.ModuleBay
         model = models.ModuleBay
         fields = ['id', 'url', 'display_url', 'display', 'name']
         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 dcim.models import Platform
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 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 .manufacturers import ManufacturerSerializer
+from .nested import NestedPlatformSerializer
 
 
 __all__ = (
 __all__ = (
     'PlatformSerializer',
     '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)
     manufacturer = ManufacturerSerializer(nested=True, required=False, allow_null=True)
     config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
     config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
 
 
     # Related object counts
     # 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:
     class Meta:
         model = Platform
         model = Platform
         fields = [
         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):
 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:
     class Meta:
         model = RackReservation
         model = RackReservation
         fields = [
         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):
 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 dcim.models import DeviceRole, InventoryItemRole
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from netbox.api.fields import RelatedObjectCountField
 from netbox.api.fields import RelatedObjectCountField
@@ -13,10 +15,8 @@ __all__ = (
 class DeviceRoleSerializer(NestedGroupModelSerializer):
 class DeviceRoleSerializer(NestedGroupModelSerializer):
     parent = NestedDeviceRoleSerializer(required=False, allow_null=True, default=None)
     parent = NestedDeviceRoleSerializer(required=False, allow_null=True, default=None)
     config_template = ConfigTemplateSerializer(nested=True, 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:
     class Meta:
         model = DeviceRole
         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 netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from utilities.query_functions import CollateAsChar
 from utilities.query_functions import CollateAsChar
+from virtualization.models import VirtualMachine
 from . import serializers
 from . import serializers
 from .exceptions import MissingFilterException
 from .exceptions import MissingFilterException
 
 
@@ -351,7 +352,19 @@ class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
 #
 #
 
 
 class DeviceRoleViewSet(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
     serializer_class = serializers.DeviceRoleSerializer
     filterset_class = filtersets.DeviceRoleFilterSet
     filterset_class = filtersets.DeviceRoleFilterSet
 
 
@@ -360,8 +373,20 @@ class DeviceRoleViewSet(NetBoxModelViewSet):
 # Platforms
 # 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
     serializer_class = serializers.PlatformSerializer
     filterset_class = filtersets.PlatformFilterSet
     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
 # DeviceTypes
 #
 #
@@ -344,6 +362,7 @@ class PowerPortTypeChoices(ChoiceSet):
     TYPE_IEC_C8 = 'iec-60320-c8'
     TYPE_IEC_C8 = 'iec-60320-c8'
     TYPE_IEC_C14 = 'iec-60320-c14'
     TYPE_IEC_C14 = 'iec-60320-c14'
     TYPE_IEC_C16 = 'iec-60320-c16'
     TYPE_IEC_C16 = 'iec-60320-c16'
+    TYPE_IEC_C18 = 'iec-60320-c18'
     TYPE_IEC_C20 = 'iec-60320-c20'
     TYPE_IEC_C20 = 'iec-60320-c20'
     TYPE_IEC_C22 = 'iec-60320-c22'
     TYPE_IEC_C22 = 'iec-60320-c22'
     # IEC 60309
     # IEC 60309
@@ -462,6 +481,7 @@ class PowerPortTypeChoices(ChoiceSet):
             (TYPE_IEC_C8, 'C8'),
             (TYPE_IEC_C8, 'C8'),
             (TYPE_IEC_C14, 'C14'),
             (TYPE_IEC_C14, 'C14'),
             (TYPE_IEC_C16, 'C16'),
             (TYPE_IEC_C16, 'C16'),
+            (TYPE_IEC_C18, 'C18'),
             (TYPE_IEC_C20, 'C20'),
             (TYPE_IEC_C20, 'C20'),
             (TYPE_IEC_C22, 'C22'),
             (TYPE_IEC_C22, 'C22'),
         )),
         )),
@@ -599,6 +619,7 @@ class PowerOutletTypeChoices(ChoiceSet):
     TYPE_IEC_C7 = 'iec-60320-c7'
     TYPE_IEC_C7 = 'iec-60320-c7'
     TYPE_IEC_C13 = 'iec-60320-c13'
     TYPE_IEC_C13 = 'iec-60320-c13'
     TYPE_IEC_C15 = 'iec-60320-c15'
     TYPE_IEC_C15 = 'iec-60320-c15'
+    TYPE_IEC_C17 = 'iec-60320-c17'
     TYPE_IEC_C19 = 'iec-60320-c19'
     TYPE_IEC_C19 = 'iec-60320-c19'
     TYPE_IEC_C21 = 'iec-60320-c21'
     TYPE_IEC_C21 = 'iec-60320-c21'
     # IEC 60309
     # IEC 60309
@@ -711,6 +732,7 @@ class PowerOutletTypeChoices(ChoiceSet):
             (TYPE_IEC_C7, 'C7'),
             (TYPE_IEC_C7, 'C7'),
             (TYPE_IEC_C13, 'C13'),
             (TYPE_IEC_C13, 'C13'),
             (TYPE_IEC_C15, 'C15'),
             (TYPE_IEC_C15, 'C15'),
+            (TYPE_IEC_C17, 'C17'),
             (TYPE_IEC_C19, 'C19'),
             (TYPE_IEC_C19, 'C19'),
             (TYPE_IEC_C21, 'C21'),
             (TYPE_IEC_C21, 'C21'),
         )),
         )),

+ 60 - 14
netbox/dcim/filtersets.py

@@ -499,6 +499,10 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label=_('Location (slug)'),
         label=_('Location (slug)'),
     )
     )
+    status = django_filters.MultipleChoiceFilter(
+        choices=RackReservationStatusChoices,
+        null_value=None
+    )
     user_id = django_filters.ModelMultipleChoiceFilter(
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
         queryset=User.objects.all(),
         label=_('User (ID)'),
         label=_('User (ID)'),
@@ -547,14 +551,17 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label=_('Manufacturer (slug)'),
         label=_('Manufacturer (slug)'),
     )
     )
-    default_platform_id = django_filters.ModelMultipleChoiceFilter(
+    default_platform_id = TreeNodeMultipleChoiceFilter(
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
+        field_name='default_platform',
+        lookup_expr='in',
         label=_('Default platform (ID)'),
         label=_('Default platform (ID)'),
     )
     )
-    default_platform = django_filters.ModelMultipleChoiceFilter(
-        field_name='default_platform__slug',
+    default_platform = TreeNodeMultipleChoiceFilter(
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
+        field_name='default_platform',
         to_field_name='slug',
         to_field_name='slug',
+        lookup_expr='in',
         label=_('Default platform (slug)'),
         label=_('Default platform (slug)'),
     )
     )
     has_front_image = django_filters.BooleanFilter(
     has_front_image = django_filters.BooleanFilter(
@@ -979,6 +986,29 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet):
 
 
 
 
 class PlatformFilterSet(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(
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         field_name='manufacturer',
         field_name='manufacturer',
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
@@ -1058,14 +1088,17 @@ class DeviceFilterSet(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         label=_('Parent Device (ID)'),
         label=_('Parent Device (ID)'),
     )
     )
-    platform_id = django_filters.ModelMultipleChoiceFilter(
+    platform_id = TreeNodeMultipleChoiceFilter(
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
+        field_name='platform',
+        lookup_expr='in',
         label=_('Platform (ID)'),
         label=_('Platform (ID)'),
     )
     )
-    platform = django_filters.ModelMultipleChoiceFilter(
-        field_name='platform__slug',
+    platform = TreeNodeMultipleChoiceFilter(
+        field_name='platform',
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        lookup_expr='in',
         label=_('Platform (slug)'),
         label=_('Platform (slug)'),
     )
     )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
@@ -1515,34 +1548,34 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
         label=_('Site group (slug)'),
         label=_('Site group (slug)'),
     )
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__site',
+        field_name='_site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label=_('Site (ID)'),
         label=_('Site (ID)'),
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__site__slug',
+        field_name='_site__slug',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label=_('Site name (slug)'),
         label=_('Site name (slug)'),
     )
     )
     location_id = django_filters.ModelMultipleChoiceFilter(
     location_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__location',
+        field_name='_location',
         queryset=Location.objects.all(),
         queryset=Location.objects.all(),
         label=_('Location (ID)'),
         label=_('Location (ID)'),
     )
     )
     location = django_filters.ModelMultipleChoiceFilter(
     location = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__location__slug',
+        field_name='_location__slug',
         queryset=Location.objects.all(),
         queryset=Location.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label=_('Location (slug)'),
         label=_('Location (slug)'),
     )
     )
     rack_id = django_filters.ModelMultipleChoiceFilter(
     rack_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__rack',
+        field_name='_rack',
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
         label=_('Rack (ID)'),
         label=_('Rack (ID)'),
     )
     )
     rack = django_filters.ModelMultipleChoiceFilter(
     rack = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__rack__name',
+        field_name='_rack__name',
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
         to_field_name='name',
         to_field_name='name',
         label=_('Rack (name)'),
         label=_('Rack (name)'),
@@ -1885,6 +1918,16 @@ class InterfaceFilterSet(
     PathEndpointFilterSet,
     PathEndpointFilterSet,
     CommonInterfaceFilterSet
     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(
     virtual_chassis_member = MultiValueCharFilter(
         method='filter_virtual_chassis_member',
         method='filter_virtual_chassis_member',
         field_name='name',
         field_name='name',
@@ -1995,11 +2038,14 @@ class InterfaceFilterSet(
             'cable_id', 'cable_end',
             '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:
         try:
             vc_interface_ids = []
             vc_interface_ids = []
             for device in Device.objects.filter(**{f'{name}__in': value}):
             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)
             return queryset.filter(pk__in=vc_interface_ids)
         except Device.DoesNotExist:
         except Device.DoesNotExist:
             return queryset.none()
             return queryset.none()

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

@@ -69,11 +69,14 @@ class PowerPortBulkCreateForm(
 
 
 
 
 class PowerOutletBulkCreateForm(
 class PowerOutletBulkCreateForm(
-    form_from_model(PowerOutlet, ['type', 'color', 'feed_leg', 'mark_connected']),
+    form_from_model(PowerOutlet, ['type', 'status', 'color', 'feed_leg', 'mark_connected']),
     DeviceBulkAddComponentForm
     DeviceBulkAddComponentForm
 ):
 ):
     model = PowerOutlet
     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(
 class InterfaceBulkCreateForm(

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

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

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

@@ -358,6 +358,11 @@ class RackReservationImportForm(NetBoxModelImportForm):
         required=True,
         required=True,
         help_text=_('Comma-separated list of individual unit numbers')
         help_text=_('Comma-separated list of individual unit numbers')
     )
     )
+    status = CSVChoiceField(
+        label=_('Status'),
+        choices=RackReservationStatusChoices,
+        help_text=_('Operational status')
+    )
     tenant = CSVModelChoiceField(
     tenant = CSVModelChoiceField(
         label=_('Tenant'),
         label=_('Tenant'),
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
@@ -368,7 +373,7 @@ class RackReservationImportForm(NetBoxModelImportForm):
 
 
     class Meta:
     class Meta:
         model = RackReservation
         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):
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)
         super().__init__(data, *args, **kwargs)
@@ -504,6 +509,16 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
 
 
 class PlatformImportForm(NetBoxModelImportForm):
 class PlatformImportForm(NetBoxModelImportForm):
     slug = SlugField()
     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(
     manufacturer = CSVModelChoiceField(
         label=_('Manufacturer'),
         label=_('Manufacturer'),
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
@@ -522,7 +537,7 @@ class PlatformImportForm(NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = Platform
         model = Platform
         fields = (
         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)
             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
             # Limit device bay queryset by parent device
             if parent := data.get('parent'):
             if parent := data.get('parent'):
                 params = {f"device__{self.fields['parent'].to_field_name}": 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
                 # Device component
                 if hasattr(term_cls, 'device'):
                 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(
                     attrs[f'termination_{cable_end}_device'] = DynamicModelMultipleChoiceField(
                         queryset=Device.objects.all(),
                         queryset=Device.objects.all(),
                         label=_('Device'),
                         label=_('Device'),
@@ -36,7 +41,7 @@ def get_cable_form(a_type, b_type):
                             'parent': 'device',
                             'parent': 'device',
                         },
                         },
                         query_params={
                         query_params={
-                            'device_id': f'$termination_{cable_end}_device',
+                            query_param_device_field: f'$termination_{cable_end}_device',
                             'kind': 'physical',  # Exclude virtual interfaces
                             'kind': 'physical',  # Exclude virtual interfaces
                         }
                         }
                     )
                     )

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

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

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

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

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

@@ -633,6 +633,8 @@ class ModuleTypeType(NetBoxObjectType):
     pagination=True
     pagination=True
 )
 )
 class PlatformType(OrganizationalObjectType):
 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
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None
     config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.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 dcim.choices import *
 from netbox.choices import WeightUnitChoices
 from netbox.choices import WeightUnitChoices
+from wireless.choices import WirelessRoleChoices
 
 
 TEMPLATE_FILENAME = 'devicetype_schema.jinja2'
 TEMPLATE_FILENAME = 'devicetype_schema.jinja2'
 OUTPUT_FILENAME = 'contrib/generated_schema.json'
 OUTPUT_FILENAME = 'contrib/generated_schema.json'
@@ -23,6 +24,7 @@ CHOICES_MAP = {
     'interface_type_choices': InterfaceTypeChoices,
     'interface_type_choices': InterfaceTypeChoices,
     'interface_poe_mode_choices': InterfacePoEModeChoices,
     'interface_poe_mode_choices': InterfacePoEModeChoices,
     'interface_poe_type_choices': InterfacePoETypeChoices,
     'interface_poe_type_choices': InterfacePoETypeChoices,
+    'interface_rf_role_choices': WirelessRoleChoices,
     'front_port_type_choices': PortTypeChoices,
     'front_port_type_choices': PortTypeChoices,
     'rear_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
 from django.db import migrations, models
 
 
 import utilities.json
 import utilities.json
+import utilities.jsonschema
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
@@ -25,7 +26,7 @@ class Migration(migrations.Migration):
                 ('description', models.CharField(blank=True, max_length=200)),
                 ('description', models.CharField(blank=True, max_length=200)),
                 ('comments', models.TextField(blank=True)),
                 ('comments', models.TextField(blank=True)),
                 ('name', models.CharField(max_length=100, unique=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')),
                 ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
             ],
             ],
             options={
             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):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('dcim', '0208_devicerole_uniqueness'),
+        ('dcim', '0210_macaddress_ordering'),
         ('extras', '0129_fix_script_paths'),
         ('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):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('dcim', '0209_platform_manufacturer_uniqueness'),
+        ('dcim', '0211_platform_manufacturer_uniqueness'),
     ]
     ]
 
 
     operations = [
     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.constants import *
 from dcim.fields import PathField
 from dcim.fields import PathField
 from dcim.utils import decompile_path_node, object_to_path_node
 from dcim.utils import decompile_path_node, object_to_path_node
+from netbox.choices import ColorChoices
 from netbox.models import ChangeLoggedModel, PrimaryModel
 from netbox.models import ChangeLoggedModel, PrimaryModel
 from utilities.conversion import to_meters
 from utilities.conversion import to_meters
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
@@ -156,6 +157,15 @@ class Cable(PrimaryModel):
             self._terminations_modified = True
             self._terminations_modified = True
         self._b_terminations = value
         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):
     def clean(self):
         super().clean()
         super().clean()
 
 

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

@@ -65,6 +65,29 @@ class ComponentModel(NetBoxModel):
         blank=True
         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:
     class Meta:
         abstract = True
         abstract = True
         ordering = ('device', 'name')
         ordering = ('device', 'name')
@@ -100,6 +123,14 @@ class ComponentModel(NetBoxModel):
                 "device": _("Components cannot be moved to a different device.")
                 "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
     @property
     def parent_object(self):
     def parent_object(self):
         return self.device
         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.files.storage import default_storage
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
-from django.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.functions import Lower
 from django.db.models.signals import post_save
 from django.db.models.signals import post_save
 from django.urls import reverse
 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.mixins import WeightMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from utilities.fields import ColorField, CounterCacheField
 from utilities.fields import ColorField, CounterCacheField
+from utilities.prefetch import get_prefetchable_fields
 from utilities.tracking import TrackingModelMixin
 from utilities.tracking import TrackingModelMixin
 from .device_components import *
 from .device_components import *
 from .mixins import RenderConfigMixin
 from .mixins import RenderConfigMixin
@@ -424,7 +425,7 @@ class DeviceRole(NestedGroupModel):
         verbose_name_plural = _('device roles')
         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 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.
     Platform may optionally be associated with a particular Manufacturer.
@@ -437,15 +438,6 @@ class Platform(OrganizationalModel):
         null=True,
         null=True,
         help_text=_('Optionally limit this platform to devices of a certain manufacturer')
         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(
     config_template = models.ForeignKey(
         to='extras.ConfigTemplate',
         to='extras.ConfigTemplate',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
@@ -454,6 +446,8 @@ class Platform(OrganizationalModel):
         null=True
         null=True
     )
     )
 
 
+    clone_fields = ('parent', 'description')
+
     class Meta:
     class Meta:
         ordering = ('name',)
         ordering = ('name',)
         verbose_name = _('platform')
         verbose_name = _('platform')
@@ -955,7 +949,10 @@ class Device(
             if cf_defaults := CustomField.objects.get_defaults_for_model(model):
             if cf_defaults := CustomField.objects.get_defaults_for_model(model):
                 for component in components:
                 for component in components:
                     component.custom_field_data = cf_defaults
                     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
             # Manually send the post_save signal for each of the newly created components
             for component in components:
             for component in components:
                 post_save.send(
                 post_save.send(
@@ -1303,7 +1300,7 @@ class MACAddress(PrimaryModel):
     )
     )
 
 
     class Meta:
     class Meta:
-        ordering = ('mac_address',)
+        ordering = ('mac_address', 'pk',)
         verbose_name = _('MAC address')
         verbose_name = _('MAC address')
         verbose_name_plural = _('MAC addresses')
         verbose_name_plural = _('MAC addresses')
 
 

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

@@ -36,7 +36,8 @@ class ModuleTypeProfile(PrimaryModel):
     schema = models.JSONField(
     schema = models.JSONField(
         blank=True,
         blank=True,
         null=True,
         null=True,
-        verbose_name=_('schema')
+        validators=[validate_schema],
+        verbose_name=_('schema'),
     )
     )
 
 
     clone_fields = ('schema',)
     clone_fields = ('schema',)
@@ -49,18 +50,6 @@ class ModuleTypeProfile(PrimaryModel):
     def __str__(self):
     def __str__(self):
         return self.name
         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):
 class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
     """
     """

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

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

+ 0 - 3
netbox/dcim/object_actions.py

@@ -20,10 +20,7 @@ class BulkAddComponents(ObjectAction):
     @classmethod
     @classmethod
     def get_context(cls, context, obj):
     def get_context(cls, context, obj):
         return {
         return {
-            'perms': context.get('perms'),
-            'request': context.get('request'),
             'formaction': context.get('formaction'),
             '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.db.models.signals import post_save, post_delete, pre_delete
 from django.dispatch import receiver
 from django.dispatch import receiver
 
 
-from .choices import CableEndChoices, LinkStatusChoices
+from dcim.choices import CableEndChoices, LinkStatusChoices
 from .models import (
 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 .models.cables import trace_paths
 from .utils import create_cablepath, rebuild_paths
 from .utils import create_cablepath, rebuild_paths
 
 
+COMPONENT_MODELS = (
+    ConsolePort,
+    ConsoleServerPort,
+    DeviceBay,
+    FrontPort,
+    Interface,
+    InventoryItem,
+    ModuleBay,
+    PowerOutlet,
+    PowerPort,
+    RearPort,
+)
+
 
 
 #
 #
 # Location/rack/device assignment
 # 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)
         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
 # Virtual chassis
 #
 #

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

@@ -113,6 +113,10 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
         order_by=('_abs_length')
         order_by=('_abs_length')
     )
     )
     color = columns.ColorColumn()
     color = columns.ColorColumn()
+    color_name = tables.Column(
+        verbose_name=_('Color Name'),
+        orderable=False
+    )
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:cable_list'
         url_name='dcim:cable_list'
@@ -123,7 +127,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
         fields = (
         fields = (
             'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
             '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',
             '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 = (
         default_columns = (
             'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',
             '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):
 class PlatformTable(NetBoxTable):
-    name = tables.Column(
+    name = columns.MPTTColumn(
         verbose_name=_('Name'),
         verbose_name=_('Name'),
         linkify=True
         linkify=True
     )
     )
+    parent = tables.Column(
+        verbose_name=_('Parent'),
+        linkify=True,
+    )
     manufacturer = tables.Column(
     manufacturer = tables.Column(
         verbose_name=_('Manufacturer'),
         verbose_name=_('Manufacturer'),
         linkify=True
         linkify=True
@@ -132,8 +136,8 @@ class PlatformTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = models.Platform
         model = models.Platform
         fields = (
         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 = (
         default_columns = (
             'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'description',
             '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,
         orderable=False,
         verbose_name=_('Units')
         verbose_name=_('Units')
     )
     )
+    status = columns.ChoiceFieldColumn(
+        verbose_name=_('Status'),
+    )
     comments = columns.MarkdownColumn(
     comments = columns.MarkdownColumn(
         verbose_name=_('Comments'),
         verbose_name=_('Comments'),
     )
     )
@@ -239,7 +242,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = RackReservation
         model = RackReservation
         fields = (
         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',
             '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):
 class RackReservationTest(APIViewTestCases.APIViewTestCase):
     model = RackReservation
     model = RackReservation
-    brief_fields = ['description', 'display', 'id', 'units', 'url', 'user']
+    brief_fields = ['description', 'display', 'id', 'status', 'units', 'url', 'user']
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
@@ -483,9 +483,24 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
         rack_reservations = (
         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)
         RackReservation.objects.bulk_create(rack_reservations)
 
 
@@ -493,18 +508,21 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
             {
             {
                 'rack': racks[1].pk,
                 'rack': racks[1].pk,
                 'units': [10, 11, 12],
                 'units': [10, 11, 12],
+                'status': RackReservationStatusChoices.STATUS_ACTIVE,
                 'user': user.pk,
                 'user': user.pk,
                 'description': 'Reservation #4',
                 'description': 'Reservation #4',
             },
             },
             {
             {
                 'rack': racks[1].pk,
                 'rack': racks[1].pk,
                 'units': [13, 14, 15],
                 'units': [13, 14, 15],
+                'status': RackReservationStatusChoices.STATUS_PENDING,
                 'user': user.pk,
                 'user': user.pk,
                 'description': 'Reservation #5',
                 'description': 'Reservation #5',
             },
             },
             {
             {
                 'rack': racks[1].pk,
                 'rack': racks[1].pk,
                 'units': [16, 17, 18],
                 'units': [16, 17, 18],
+                'status': RackReservationStatusChoices.STATUS_STALE,
                 'user': user.pk,
                 'user': user.pk,
                 'description': 'Reservation #6',
                 'description': 'Reservation #6',
             },
             },
@@ -1247,7 +1265,9 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
 
 
 class PlatformTest(APIViewTestCases.APIViewTestCase):
 class PlatformTest(APIViewTestCases.APIViewTestCase):
     model = Platform
     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 = [
     create_data = [
         {
         {
             'name': 'Platform 4',
             'name': 'Platform 4',
@@ -1274,7 +1294,8 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
             Platform(name='Platform 2', slug='platform-2'),
             Platform(name='Platform 2', slug='platform-2'),
             Platform(name='Platform 3', slug='platform-3'),
             Platform(name='Platform 3', slug='platform-3'),
         )
         )
-        Platform.objects.bulk_create(platforms)
+        for platform in platforms:
+            platform.save()
 
 
 
 
 class DeviceTest(APIViewTestCases.APIViewTestCase):
 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)
         Tenant.objects.bulk_create(tenants)
 
 
         reservations = (
         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)
         RackReservation.objects.bulk_create(reservations)
 
 
@@ -1179,6 +1200,10 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'location': [locations[0].slug, locations[1].slug]}
         params = {'location': [locations[0].slug, locations[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_status(self):
+        params = {'status': [RackReservationStatusChoices.STATUS_ACTIVE, RackReservationStatusChoices.STATUS_PENDING]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_user(self):
     def test_user(self):
         users = User.objects.all()[:2]
         users = User.objects.all()[:2]
         params = {'user_id': [users[0].pk, users[1].pk]}
         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 2', slug='platform-2', manufacturer=manufacturers[1]),
             Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2]),
             Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2]),
         )
         )
-        Platform.objects.bulk_create(platforms)
+        for platform in platforms:
+            platform.save()
 
 
         device_types = (
         device_types = (
             DeviceType(
             DeviceType(
@@ -2435,7 +2461,37 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
             Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'),
             Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'),
             Platform(name='Platform 4', slug='platform-4'),
             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):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
@@ -2453,12 +2509,26 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_manufacturer(self):
         manufacturers = Manufacturer.objects.all()[:2]
         manufacturers = Manufacturer.objects.all()[:2]
         params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]}
         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]}
         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):
     def test_available_for_device_type(self):
         manufacturers = Manufacturer.objects.all()[:2]
         manufacturers = Manufacturer.objects.all()[:2]
@@ -2469,7 +2539,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
             u_height=1
             u_height=1
         )
         )
         params = {'available_for_device_type': device_type.pk}
         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):
 class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -2507,7 +2577,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
             Platform(name='Platform 2', slug='platform-2'),
             Platform(name='Platform 2', slug='platform-2'),
             Platform(name='Platform 3', slug='platform-3'),
             Platform(name='Platform 3', slug='platform-3'),
         )
         )
-        Platform.objects.bulk_create(platforms)
+        for platform in platforms:
+            platform.save()
 
 
         regions = (
         regions = (
             Region(name='Region 1', slug='region-1'),
             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]}
         params = {'device_type': [device_types[0].slug, device_types[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-    def test_devicerole(self):
+    def test_role(self):
         roles = DeviceRole.objects.all()[:2]
         roles = DeviceRole.objects.all()[:2]
         params = {'role_id': [roles[0].pk, roles[1].pk]}
         params = {'role_id': [roles[0].pk, roles[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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)
         ConsoleServerPort.objects.bulk_create(console_server_ports)
 
 
         console_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)
         ConsolePort.objects.bulk_create(console_ports)
 
 
@@ -3581,13 +3679,34 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
 
 
         console_server_ports = (
         console_server_ports = (
             ConsoleServerPort(
             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(
             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(
             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)
         ConsoleServerPort.objects.bulk_create(console_server_ports)
@@ -3807,6 +3926,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 maximum_draw=100,
                 maximum_draw=100,
                 allocated_draw=50,
                 allocated_draw=50,
                 description='First',
                 description='First',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             ),
             PowerPort(
             PowerPort(
                 device=devices[1],
                 device=devices[1],
@@ -3816,6 +3938,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 maximum_draw=200,
                 maximum_draw=200,
                 allocated_draw=100,
                 allocated_draw=100,
                 description='Second',
                 description='Second',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             ),
             PowerPort(
             PowerPort(
                 device=devices[2],
                 device=devices[2],
@@ -3825,6 +3950,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 maximum_draw=300,
                 maximum_draw=300,
                 allocated_draw=150,
                 allocated_draw=150,
                 description='Third',
                 description='Third',
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
             ),
             ),
         )
         )
         PowerPort.objects.bulk_create(power_ports)
         PowerPort.objects.bulk_create(power_ports)
@@ -4053,6 +4181,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
                 description='First',
                 description='First',
                 color='ff0000',
                 color='ff0000',
                 status=PowerOutletStatusChoices.STATUS_ENABLED,
                 status=PowerOutletStatusChoices.STATUS_ENABLED,
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             ),
             PowerOutlet(
             PowerOutlet(
                 device=devices[1],
                 device=devices[1],
@@ -4063,6 +4194,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
                 description='Second',
                 description='Second',
                 color='00ff00',
                 color='00ff00',
                 status=PowerOutletStatusChoices.STATUS_DISABLED,
                 status=PowerOutletStatusChoices.STATUS_DISABLED,
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             ),
             PowerOutlet(
             PowerOutlet(
                 device=devices[2],
                 device=devices[2],
@@ -4073,6 +4207,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
                 description='Third',
                 description='Third',
                 color='0000ff',
                 color='0000ff',
                 status=PowerOutletStatusChoices.STATUS_FAULTY,
                 status=PowerOutletStatusChoices.STATUS_FAULTY,
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
             ),
             ),
         )
         )
         PowerOutlet.objects.bulk_create(power_outlets)
         PowerOutlet.objects.bulk_create(power_outlets)
@@ -4307,6 +4444,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         )
         )
         Device.objects.bulk_create(devices)
         Device.objects.bulk_create(devices)
 
 
+        virtual_chassis.master = devices[0]
+        virtual_chassis.save()
+
         module_bays = (
         module_bays = (
             ModuleBay(device=devices[0], name='Module Bay 1'),
             ModuleBay(device=devices[0], name='Module Bay 1'),
             ModuleBay(device=devices[1], name='Module Bay 2'),
             ModuleBay(device=devices[1], name='Module Bay 2'),
@@ -4381,13 +4521,19 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 poe_mode=InterfacePoEModeChoices.MODE_PSE,
                 poe_mode=InterfacePoEModeChoices.MODE_PSE,
                 poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
                 poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
                 vlan_translation_policy=vlan_translation_policies[0],
                 vlan_translation_policy=vlan_translation_policies[0],
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             ),
             Interface(
             Interface(
                 device=devices[1],
                 device=devices[1],
                 module=modules[1],
                 module=modules[1],
                 name='VC Chassis Interface',
                 name='VC Chassis Interface',
                 type=InterfaceTypeChoices.TYPE_1GE_SFP,
                 type=InterfaceTypeChoices.TYPE_1GE_SFP,
-                enabled=True
+                enabled=True,
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             ),
             Interface(
             Interface(
                 device=devices[2],
                 device=devices[2],
@@ -4406,6 +4552,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 poe_mode=InterfacePoEModeChoices.MODE_PD,
                 poe_mode=InterfacePoEModeChoices.MODE_PD,
                 poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
                 poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
                 vlan_translation_policy=vlan_translation_policies[0],
                 vlan_translation_policy=vlan_translation_policies[0],
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
             ),
             ),
             Interface(
             Interface(
                 device=devices[3],
                 device=devices[3],
@@ -4424,6 +4573,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 poe_mode=InterfacePoEModeChoices.MODE_PSE,
                 poe_mode=InterfacePoEModeChoices.MODE_PSE,
                 poe_type=InterfacePoETypeChoices.TYPE_2_8023AT,
                 poe_type=InterfacePoETypeChoices.TYPE_2_8023AT,
                 vlan_translation_policy=vlan_translation_policies[1],
                 vlan_translation_policy=vlan_translation_policies[1],
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
             ),
             ),
             Interface(
             Interface(
                 device=devices[4],
                 device=devices[4],
@@ -4440,6 +4592,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 mode=InterfaceModeChoices.MODE_Q_IN_Q,
                 mode=InterfaceModeChoices.MODE_Q_IN_Q,
                 qinq_svlan=vlans[0],
                 qinq_svlan=vlans[0],
                 vlan_translation_policy=vlan_translation_policies[1],
                 vlan_translation_policy=vlan_translation_policies[1],
+                _site=devices[4].site,
+                _location=devices[4].location,
+                _rack=devices[4].rack,
             ),
             ),
             Interface(
             Interface(
                 device=devices[4],
                 device=devices[4],
@@ -4450,7 +4605,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 mgmt_only=True,
                 mgmt_only=True,
                 tx_power=40,
                 tx_power=40,
                 mode=InterfaceModeChoices.MODE_Q_IN_Q,
                 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(
             Interface(
                 device=devices[4],
                 device=devices[4],
@@ -4461,7 +4619,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 mgmt_only=False,
                 mgmt_only=False,
                 tx_power=40,
                 tx_power=40,
                 mode=InterfaceModeChoices.MODE_Q_IN_Q,
                 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(
             Interface(
                 device=devices[4],
                 device=devices[4],
@@ -4470,7 +4631,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 rf_role=WirelessRoleChoices.ROLE_AP,
                 rf_role=WirelessRoleChoices.ROLE_AP,
                 rf_channel=WirelessChannelChoices.CHANNEL_24G_1,
                 rf_channel=WirelessChannelChoices.CHANNEL_24G_1,
                 rf_channel_frequency=2412,
                 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(
             Interface(
                 device=devices[4],
                 device=devices[4],
@@ -4479,7 +4643,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 rf_role=WirelessRoleChoices.ROLE_STATION,
                 rf_role=WirelessRoleChoices.ROLE_STATION,
                 rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
                 rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
                 rf_channel_frequency=5160,
                 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)
         Interface.objects.bulk_create(interfaces)
@@ -4666,6 +4833,19 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         params = {'device': [devices[0].name, devices[1].name]}
         params = {'device': [devices[0].name, devices[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_virtual_chassis_member(self):
         # Device 1A & 3 have 1 management interface, Device 1B has 1 interfaces
         # Device 1A & 3 have 1 management interface, Device 1B has 1 interfaces
         devices = Device.objects.filter(name__in=['Device 1A', 'Device 3'])
         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=rear_ports[0],
                 rear_port_position=1,
                 rear_port_position=1,
                 description='First',
                 description='First',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             ),
             FrontPort(
             FrontPort(
                 device=devices[1],
                 device=devices[1],
@@ -4917,6 +5100,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 rear_port=rear_ports[1],
                 rear_port=rear_ports[1],
                 rear_port_position=2,
                 rear_port_position=2,
                 description='Second',
                 description='Second',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             ),
             FrontPort(
             FrontPort(
                 device=devices[2],
                 device=devices[2],
@@ -4928,6 +5114,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 rear_port=rear_ports[2],
                 rear_port=rear_ports[2],
                 rear_port_position=3,
                 rear_port_position=3,
                 description='Third',
                 description='Third',
+                _site=devices[2].site,
+                _location=devices[2].location,
+                _rack=devices[2].rack,
             ),
             ),
             FrontPort(
             FrontPort(
                 device=devices[3],
                 device=devices[3],
@@ -4936,6 +5125,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 type=PortTypeChoices.TYPE_FC,
                 type=PortTypeChoices.TYPE_FC,
                 rear_port=rear_ports[3],
                 rear_port=rear_ports[3],
                 rear_port_position=1,
                 rear_port_position=1,
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
             ),
             ),
             FrontPort(
             FrontPort(
                 device=devices[3],
                 device=devices[3],
@@ -4944,6 +5136,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 type=PortTypeChoices.TYPE_FC,
                 type=PortTypeChoices.TYPE_FC,
                 rear_port=rear_ports[4],
                 rear_port=rear_ports[4],
                 rear_port_position=1,
                 rear_port_position=1,
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
             ),
             ),
             FrontPort(
             FrontPort(
                 device=devices[3],
                 device=devices[3],
@@ -4952,6 +5147,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 type=PortTypeChoices.TYPE_FC,
                 type=PortTypeChoices.TYPE_FC,
                 rear_port=rear_ports[5],
                 rear_port=rear_ports[5],
                 rear_port_position=1,
                 rear_port_position=1,
+                _site=devices[3].site,
+                _location=devices[3].location,
+                _rack=devices[3].rack,
             ),
             ),
         )
         )
         FrontPort.objects.bulk_create(front_ports)
         FrontPort.objects.bulk_create(front_ports)
@@ -5168,6 +5366,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
                 color=ColorChoices.COLOR_RED,
                 color=ColorChoices.COLOR_RED,
                 positions=1,
                 positions=1,
                 description='First',
                 description='First',
+                _site=devices[0].site,
+                _location=devices[0].location,
+                _rack=devices[0].rack,
             ),
             ),
             RearPort(
             RearPort(
                 device=devices[1],
                 device=devices[1],
@@ -5178,6 +5379,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
                 color=ColorChoices.COLOR_GREEN,
                 color=ColorChoices.COLOR_GREEN,
                 positions=2,
                 positions=2,
                 description='Second',
                 description='Second',
+                _site=devices[1].site,
+                _location=devices[1].location,
+                _rack=devices[1].rack,
             ),
             ),
             RearPort(
             RearPort(
                 device=devices[2],
                 device=devices[2],
@@ -5188,10 +5392,40 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
                 color=ColorChoices.COLOR_BLUE,
                 color=ColorChoices.COLOR_BLUE,
                 positions=3,
                 positions=3,
                 description='Third',
                 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)
         RearPort.objects.bulk_create(rear_ports)
 
 
@@ -5550,9 +5784,33 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         Device.objects.bulk_create(devices)
         Device.objects.bulk_create(devices)
 
 
         device_bays = (
         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)
         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 = {
         cls.form_data = {
             'rack': rack.pk,
             'rack': rack.pk,
             'units': "10,11,12",
             'units': "10,11,12",
+            'status': RackReservationStatusChoices.STATUS_PENDING,
             'user': user3.pk,
             'user': user3.pk,
             'tenant': None,
             'tenant': None,
             'description': 'Rack reservation',
             'description': 'Rack reservation',
@@ -344,10 +345,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         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 = (
         cls.csv_update_data = (
@@ -358,6 +359,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
+            'status': RackReservationStatusChoices.STATUS_STALE,
             'user': user3.pk,
             'user': user3.pk,
             'tenant': None,
             'tenant': None,
             'description': 'New description',
             'description': 'New description',
@@ -619,7 +621,8 @@ class DeviceTypeTestCase(
             Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0]),
             Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0]),
             Platform(name='Platform 2', slug='platform-3', manufacturer=manufacturers[1]),
             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.objects.bulk_create([
             DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturers[0]),
             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 2', slug='platform-2', manufacturer=manufacturer),
             Platform(name='Platform 3', slug='platform-3', 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')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
@@ -1912,9 +1916,9 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 
 
         cls.csv_update_data = (
         cls.csv_update_data = (
             "id,name,description",
             "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 = {
         cls.bulk_edit_data = {
@@ -1962,7 +1966,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             Platform(name='Platform 1', slug='platform-1'),
             Platform(name='Platform 1', slug='platform-1'),
             Platform(name='Platform 2', slug='platform-2'),
             Platform(name='Platform 2', slug='platform-2'),
         )
         )
-        Platform.objects.bulk_create(platforms)
+        for platform in platforms:
+            platform.save()
 
 
         devices = (
         devices = (
             Device(
             Device(

+ 24 - 6
netbox/dcim/views.py

@@ -2040,9 +2040,18 @@ class InventoryItemTemplateBulkDeleteView(generic.BulkDeleteView):
 
 
 @register_model_view(DeviceRole, 'list', path='', detail=False)
 @register_model_view(DeviceRole, 'list', path='', detail=False)
 class DeviceRoleListView(generic.ObjectListView):
 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 = filtersets.DeviceRoleFilterSet
     filterset_form = forms.DeviceRoleFilterForm
     filterset_form = forms.DeviceRoleFilterForm
@@ -2109,9 +2118,18 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
 
 
 @register_model_view(Platform, 'list', path='', detail=False)
 @register_model_view(Platform, 'list', path='', detail=False)
 class PlatformListView(generic.ObjectListView):
 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
     table = tables.PlatformTable
     filterset = filtersets.PlatformFilterSet
     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_.roles import DeviceRoleSerializer
 from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, SiteSerializer, SiteGroupSerializer
 from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, SiteSerializer, SiteGroupSerializer
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 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.fields import SerializedPKRelatedField
 from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
 from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer, TenantGroupSerializer
 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
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
 __all__ = (
 __all__ = (
+    'ConfigContextProfileSerializer',
     'ConfigContextSerializer',
     '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):
 class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
+    profile = ConfigContextProfileSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        default=None,
+    )
     regions = SerializedPKRelatedField(
     regions = SerializedPKRelatedField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         serializer=RegionSerializer,
         serializer=RegionSerializer,
@@ -122,9 +154,9 @@ class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializ
     class Meta:
     class Meta:
         model = ConfigContext
         model = ConfigContext
         fields = [
         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',
             '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')
         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('image-attachments', views.ImageAttachmentViewSet)
 router.register('journal-entries', views.JournalEntryViewSet)
 router.register('journal-entries', views.JournalEntryViewSet)
 router.register('config-contexts', views.ConfigContextViewSet)
 router.register('config-contexts', views.ConfigContextViewSet)
+router.register('config-context-profiles', views.ConfigContextProfileViewSet)
 router.register('config-templates', views.ConfigTemplateViewSet)
 router.register('config-templates', views.ConfigTemplateViewSet)
 router.register('scripts', views.ScriptViewSet, basename='script')
 router.register('scripts', views.ScriptViewSet, basename='script')
 
 

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

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

+ 41 - 0
netbox/extras/filtersets.py

@@ -19,6 +19,7 @@ from .models import *
 __all__ = (
 __all__ = (
     'BookmarkFilterSet',
     'BookmarkFilterSet',
     'ConfigContextFilterSet',
     'ConfigContextFilterSet',
+    'ConfigContextProfileFilterSet',
     'ConfigTemplateFilterSet',
     'ConfigTemplateFilterSet',
     'CustomFieldChoiceSetFilterSet',
     'CustomFieldChoiceSetFilterSet',
     'CustomFieldFilterSet',
     '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):
 class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label=_('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(
     region_id = django_filters.ModelMultipleChoiceFilter(
         field_name='regions',
         field_name='regions',
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),

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

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

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

@@ -18,6 +18,7 @@ from utilities.forms.fields import (
 )
 )
 
 
 __all__ = (
 __all__ = (
+    'ConfigContextProfileImportForm',
     'ConfigTemplateImportForm',
     'ConfigTemplateImportForm',
     'CustomFieldChoiceSetImportForm',
     'CustomFieldChoiceSetImportForm',
     'CustomFieldImportForm',
     '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 ConfigTemplateImportForm(CSVModelForm):
 
 
     class Meta:
     class Meta:

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

@@ -20,6 +20,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
 __all__ = (
 __all__ = (
     'ConfigContextFilterForm',
     'ConfigContextFilterForm',
+    'ConfigContextProfileFilterForm',
     'ConfigTemplateFilterForm',
     'ConfigTemplateFilterForm',
     'CustomFieldChoiceSetFilterForm',
     'CustomFieldChoiceSetFilterForm',
     'CustomFieldFilterForm',
     '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):
 class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
     model = ConfigContext
     model = ConfigContext
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag_id'),
         FieldSet('q', 'filter_id', 'tag_id'),
+        FieldSet('profile', name=_('Config Context')),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
         FieldSet('data_source_id', 'data_file_id', name=_('Data')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
         FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
         FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
         FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
         FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant'))
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant'))
     )
     )
+    profile_id = DynamicModelMultipleChoiceField(
+        queryset=ConfigContextProfile.objects.all(),
+        required=False,
+        label=_('Profile')
+    )
     data_source_id = DynamicModelMultipleChoiceField(
     data_source_id = DynamicModelMultipleChoiceField(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
         required=False,
         required=False,

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

@@ -29,6 +29,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
 __all__ = (
 __all__ = (
     'BookmarkForm',
     'BookmarkForm',
     'ConfigContextForm',
     'ConfigContextForm',
+    'ConfigContextProfileForm',
     'ConfigTemplateForm',
     'ConfigTemplateForm',
     'CustomFieldChoiceSetForm',
     'CustomFieldChoiceSetForm',
     'CustomFieldForm',
     '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):
 class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm):
+    profile = DynamicModelChoiceField(
+        label=_('Profile'),
+        queryset=ConfigContextProfile.objects.all(),
+        required=False
+    )
     regions = DynamicModelMultipleChoiceField(
     regions = DynamicModelMultipleChoiceField(
         label=_('Regions'),
         label=_('Regions'),
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -657,7 +687,7 @@ class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm)
     )
     )
 
 
     fieldsets = (
     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('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
         FieldSet(
         FieldSet(
             'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
             'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
@@ -669,9 +699,9 @@ class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm)
     class Meta:
     class Meta:
         model = ConfigContext
         model = ConfigContext
         fields = (
         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):
     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 core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
 from extras import models
 from extras import models
 from extras.graphql.filter_mixins import TagBaseFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin
 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:
 if TYPE_CHECKING:
     from core.graphql.filters import ContentTypeFilter
     from core.graphql.filters import ContentTypeFilter
@@ -24,6 +24,7 @@ if TYPE_CHECKING:
 
 
 __all__ = (
 __all__ = (
     'ConfigContextFilter',
     'ConfigContextFilter',
+    'ConfigContextProfileFilter',
     'ConfigTemplateFilter',
     'ConfigTemplateFilter',
     'CustomFieldFilter',
     'CustomFieldFilter',
     'CustomFieldChoiceSetFilter',
     '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)
 @strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
 class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
 class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     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: ConfigContextType = strawberry_django.field()
     config_context_list: List[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: ConfigTemplateType = strawberry_django.field()
     config_template_list: List[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
 import strawberry_django
 import strawberry_django
 
 
+from core.graphql.mixins import SyncedDataMixin
 from extras import models
 from extras import models
 from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
 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 *
 from .filters import *
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
-    from core.graphql.types import DataFileType, DataSourceType
     from dcim.graphql.types import (
     from dcim.graphql.types import (
         DeviceRoleType,
         DeviceRoleType,
         DeviceType,
         DeviceType,
@@ -25,6 +25,7 @@ if TYPE_CHECKING:
     from virtualization.graphql.types import ClusterGroupType, ClusterType, ClusterTypeType, VirtualMachineType
     from virtualization.graphql.types import ClusterGroupType, ClusterType, ClusterTypeType, VirtualMachineType
 
 
 __all__ = (
 __all__ = (
+    'ConfigContextProfileType',
     'ConfigContextType',
     'ConfigContextType',
     'ConfigTemplateType',
     'ConfigTemplateType',
     'CustomFieldChoiceSetType',
     '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(
 @strawberry_django.type(
     models.ConfigContext,
     models.ConfigContext,
     fields='__all__',
     fields='__all__',
     filters=ConfigContextFilter,
     filters=ConfigContextFilter,
     pagination=True
     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')]]
     roles: List[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]]
     device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]]
     device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]]
     tags: List[Annotated["TagType", strawberry.lazy('extras.graphql.types')]]
     tags: List[Annotated["TagType", strawberry.lazy('extras.graphql.types')]]
@@ -74,10 +84,7 @@ class ConfigContextType(ObjectType):
     filters=ConfigTemplateFilter,
     filters=ConfigTemplateFilter,
     pagination=True
     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')]]
     virtualmachines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]]
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]]
     platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]]
@@ -123,9 +130,8 @@ class CustomLinkType(ObjectType):
     filters=ExportTemplateFilter,
     filters=ExportTemplateFilter,
     pagination=True
     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(
 @strawberry_django.type(

+ 5 - 0
netbox/extras/jobs.py

@@ -59,6 +59,7 @@ class ScriptJob(JobRunner):
                 else:
                 else:
                     script.log_failure(msg)
                     script.log_failure(msg)
                 logger.error(f"Script aborted with error: {e}")
                 logger.error(f"Script aborted with error: {e}")
+                self.logger.error(f"Script aborted with error: {e}")
 
 
             else:
             else:
                 stacktrace = traceback.format_exc()
                 stacktrace = traceback.format_exc()
@@ -66,9 +67,11 @@ class ScriptJob(JobRunner):
                     message=_("An exception occurred: ") + f"`{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
                     message=_("An exception occurred: ") + f"`{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
                 )
                 )
                 logger.error(f"Exception raised during script execution: {e}")
                 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:
             if type(e) is not AbortTransaction:
                 script.log_info(message=_("Database changes have been reverted due to error."))
                 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.
             # Clear all pending events. Job termination (including setting the status) is handled by the job framework.
             if request:
             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
         # Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
         # change logging, event rules, etc.
         # change logging, event rules, etc.
         if commit:
         if commit:
+            self.logger.info("Executing script (commit enabled)")
             with ExitStack() as stack:
             with ExitStack() as stack:
                 for request_processor in registry['request_processors']:
                 for request_processor in registry['request_processors']:
                     stack.enter_context(request_processor(request))
                     stack.enter_context(request_processor(request))
                 self.run_script(script, request, data, commit)
                 self.run_script(script, request, data, commit)
         else:
         else:
+            self.logger.warning("Executing script (commit disabled)")
             self.run_script(script, request, data, commit)
             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
 from .fields import CachedValueField
 
 
@@ -18,6 +19,30 @@ class Empty(Lookup):
             return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
             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):
 class NetHost(Lookup):
     """
     """
     Similar to ipam.lookups.NetHost, but casts the field to INET.
     Similar to ipam.lookups.NetHost, but casts the field to INET.
@@ -45,5 +70,6 @@ class NetContainsOrEquals(Lookup):
 
 
 
 
 CharField.register_lookup(Empty)
 CharField.register_lookup(Empty)
+JSONField.register_lookup(JSONEmpty)
 CachedValueField.register_lookup(NetHost)
 CachedValueField.register_lookup(NetHost)
 CachedValueField.register_lookup(NetContainsOrEquals)
 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 collections import defaultdict
+from jsonschema.exceptions import ValidationError as JSONValidationError
 
 
 from django.conf import settings
 from django.conf import settings
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
@@ -9,13 +11,15 @@ from django.utils.translation import gettext_lazy as _
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.models.mixins import RenderTemplateMixin
 from extras.models.mixins import RenderTemplateMixin
 from extras.querysets import ConfigContextQuerySet
 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 netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
 from utilities.data import deepmerge
 from utilities.data import deepmerge
+from utilities.jsonschema import validate_schema
 
 
 __all__ = (
 __all__ = (
     'ConfigContext',
     'ConfigContext',
     'ConfigContextModel',
     'ConfigContextModel',
+    'ConfigContextProfile',
     'ConfigTemplate',
     'ConfigTemplate',
 )
 )
 
 
@@ -24,6 +28,46 @@ __all__ = (
 # Config contexts
 # 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):
 class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLoggedModel):
     """
     """
     A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
     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,
         max_length=100,
         unique=True
         unique=True
     )
     )
+    profile = models.ForeignKey(
+        to='extras.ConfigContextProfile',
+        on_delete=models.PROTECT,
+        blank=True,
+        null=True,
+        related_name='config_contexts',
+    )
     weight = models.PositiveSmallIntegerField(
     weight = models.PositiveSmallIntegerField(
         verbose_name=_('weight'),
         verbose_name=_('weight'),
         default=1000
         default=1000
@@ -118,9 +169,8 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
     objects = ConfigContextQuerySet.as_manager()
     objects = ConfigContextQuerySet.as_manager()
 
 
     clone_fields = (
     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:
     class Meta:
@@ -147,6 +197,13 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
                 {'data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
                 {'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):
     def sync_data(self):
         """
         """
         Synchronize context data from the designated DataFile (if any).
         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 = {
         kwargs = {
             'field_name': f'custom_field_data__{self.name}'
             '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:
         if lookup_expr is not None:
             kwargs['lookup_expr'] = lookup_expr
             kwargs['lookup_expr'] = lookup_expr
 
 
+        # 'Empty' lookup is always a boolean
+        if lookup_expr == 'empty':
+            filter_class = django_filters.BooleanFilter
+
         # Text/URL
         # Text/URL
-        if self.type in (
+        elif self.type in (
                 CustomFieldTypeChoices.TYPE_TEXT,
                 CustomFieldTypeChoices.TYPE_TEXT,
                 CustomFieldTypeChoices.TYPE_LONGTEXT,
                 CustomFieldTypeChoices.TYPE_LONGTEXT,
                 CustomFieldTypeChoices.TYPE_URL,
                 CustomFieldTypeChoices.TYPE_URL,

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

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

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

@@ -173,14 +173,17 @@ class NotificationGroup(ChangeLoggedModel):
             User.objects.filter(groups__in=self.groups.all())
             User.objects.filter(groups__in=self.groups.all())
         ).order_by('username')
         ).order_by('username')
 
 
-    def notify(self, **kwargs):
+    def notify(self, object_type, object_id, **kwargs):
         """
         """
         Bulk-create Notifications for all members of this group.
         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
     notify.alters_data = True
 
 
 
 

+ 4 - 4
netbox/extras/scripts.py

@@ -588,9 +588,9 @@ class BaseScript:
         """
         """
         Return data from a YAML file
         Return data from a YAML file
         """
         """
-        # TODO: DEPRECATED: Remove this method in v4.4
+        # TODO: DEPRECATED: Remove this method in v4.5
         self._log(
         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
             level=LogLevelChoices.LOG_WARNING
         )
         )
         file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
         file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
@@ -603,9 +603,9 @@ class BaseScript:
         """
         """
         Return data from a JSON file
         Return data from a JSON file
         """
         """
-        # TODO: DEPRECATED: Remove this method in v4.4
+        # TODO: DEPRECATED: Remove this method in v4.5
         self._log(
         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
             level=LogLevelChoices.LOG_WARNING
         )
         )
         file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
         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
 from . import models
 
 
 
 
+@register_search
+class ConfigContextProfileIndex(SearchIndex):
+    model = models.ConfigContextProfile
+    fields = (
+        ('name', 100),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('description',)
+
+
 @register_search
 @register_search
 class CustomFieldIndex(SearchIndex):
 class CustomFieldIndex(SearchIndex):
     model = models.CustomField
     model = models.CustomField

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

@@ -15,6 +15,7 @@ from .columns import NotificationActionsColumn
 
 
 __all__ = (
 __all__ = (
     'BookmarkTable',
     'BookmarkTable',
+    'ConfigContextProfileTable',
     'ConfigContextTable',
     'ConfigContextTable',
     'ConfigTemplateTable',
     'ConfigTemplateTable',
     'CustomFieldChoiceSetTable',
     'CustomFieldChoiceSetTable',
@@ -39,9 +40,8 @@ __all__ = (
 
 
 IMAGEATTACHMENT_IMAGE = """
 IMAGEATTACHMENT_IMAGE = """
 {% if record.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 %}
 {% endif %}
 <a href="{{ record.get_absolute_url }}">{{ record }}</a>
 <a href="{{ record.get_absolute_url }}">{{ record }}</a>
 """
 """
@@ -235,6 +235,7 @@ class ImageAttachmentTable(NetBoxTable):
     image = columns.TemplateColumn(
     image = columns.TemplateColumn(
         verbose_name=_('Image'),
         verbose_name=_('Image'),
         template_code=IMAGEATTACHMENT_IMAGE,
         template_code=IMAGEATTACHMENT_IMAGE,
+        attrs={'td': {'class': 'text-nowrap'}}
     )
     )
     name = tables.Column(
     name = tables.Column(
         verbose_name=_('Name'),
         verbose_name=_('Name'),
@@ -254,7 +255,7 @@ class ImageAttachmentTable(NetBoxTable):
         verbose_name=_('Object Type'),
         verbose_name=_('Object Type'),
     )
     )
     parent = tables.Column(
     parent = tables.Column(
-        verbose_name=_('Parent'),
+        verbose_name=_('Object'),
         linkify=True,
         linkify=True,
         orderable=False,
         orderable=False,
     )
     )
@@ -546,7 +547,41 @@ class TaggedItemTable(NetBoxTable):
         fields = ('id', 'content_type', 'content_object')
         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):
 class ConfigContextTable(NetBoxTable):
+    profile = tables.Column(
+        linkify=True,
+        verbose_name=_('Profile'),
+    )
     data_source = tables.Column(
     data_source = tables.Column(
         verbose_name=_('Data Source'),
         verbose_name=_('Data Source'),
         linkify=True
         linkify=True
@@ -573,11 +608,11 @@ class ConfigContextTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = ConfigContext
         model = ConfigContext
         fields = (
         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):
 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):
 class ConfigContextTest(APIViewTestCases.APIViewTestCase):
     model = ConfigContext
     model = ConfigContext
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     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,
                 'cf11': manufacturers[2].pk,
                 'cf12': [manufacturers[2].pk, manufacturers[3].pk],
                 'cf12': [manufacturers[2].pk, manufacturers[3].pk],
             }),
             }),
+            Site(name='Site 4', slug='site-4'),
         ])
         ])
 
 
     def test_filter_integer(self):
     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__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__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__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):
     def test_filter_decimal(self):
         self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2)
         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__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__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__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):
     def test_filter_boolean(self):
         self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2)
         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__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__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__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):
     def test_filter_text_loose(self):
         self.assertEqual(self.filterset({'cf_cf5': ['foo']}, self.queryset).qs.count(), 2)
         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__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__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__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):
     def test_filter_url_strict(self):
         self.assertEqual(
         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__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__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__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):
     def test_filter_url_loose(self):
         self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
 
 
     def test_filter_select(self):
     def test_filter_select(self):
         self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
         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):
     def test_filter_multiselect(self):
         self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
         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': ['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):
     def test_filter_object(self):
         manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
         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(),
             self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(),
             2
             2
         )
         )
+        self.assertEqual(self.filterset({'cf_cf11__empty': True}, self.queryset).qs.count(), 1)
 
 
     def test_filter_multiobject(self):
     def test_filter_multiobject(self):
         manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
         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(),
             self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(),
             3
             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()
         mock_request = Request()
         widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
         widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
         rendered = widget.render(mock_request)
         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)
         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):
 class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
     filterset = ConfigContextFilterSet
     filterset = ConfigContextFilterSet
@@ -878,6 +911,12 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     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 = (
         regions = (
             Region(name='Region 1', slug='region-1'),
             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 2', slug='platform-2'),
             Platform(name='Platform 3', slug='platform-3'),
             Platform(name='Platform 3', slug='platform-3'),
         )
         )
-        Platform.objects.bulk_create(platforms)
+        for platform in platforms:
+            platform.save()
 
 
         cluster_types = (
         cluster_types = (
             ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
             ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
@@ -975,6 +1015,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
             is_active = bool(i % 2)
             is_active = bool(i % 2)
             c = ConfigContext.objects.create(
             c = ConfigContext.objects.create(
                 name=f"Config Context {i + 1}",
                 name=f"Config Context {i + 1}",
+                profile=profiles[i],
                 is_active=is_active,
                 is_active=is_active,
                 data='{"foo": 123}',
                 data='{"foo": 123}',
                 description=f"foobar{i + 1}"
                 description=f"foobar{i + 1}"
@@ -1011,6 +1052,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -1184,6 +1232,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         'cluster',
         'cluster',
         'clustergroup',
         'clustergroup',
         'clustertype',
         'clustertype',
+        'configcontextprofile',
         'configtemplate',
         'configtemplate',
         'consoleport',
         'consoleport',
         'consoleserverport',
         'consoleserverport',

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است