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

Merge pull request #22111 from netbox-community/feature

Merge `feature` into `main` ahead of v4.6.0 release
Jeremy Stretch 2 недель назад
Родитель
Сommit
1e1548edd1
100 измененных файлов с 3036 добавлено и 424 удалено
  1. 1 1
      .github/workflows/ci.yml
  2. 3 3
      .pre-commit-config.yaml
  3. 6 6
      .readthedocs.yaml
  4. 9 7
      base_requirements.txt
  5. 652 110
      contrib/openapi.json
  6. 0 18
      docs/_theme/partials/copyright.html
  7. 3 1
      docs/administration/permissions.md
  8. 3 3
      docs/configuration/development.md
  9. 1 0
      docs/configuration/index.md
  10. 29 0
      docs/configuration/miscellaneous.md
  11. 3 0
      docs/configuration/security.md
  12. 7 0
      docs/configuration/system.md
  13. 1 0
      docs/customization/custom-fields.md
  14. 14 0
      docs/customization/custom-scripts.md
  15. 3 0
      docs/development/application-registry.md
  16. 1 1
      docs/development/getting-started.md
  17. 3 0
      docs/development/models.md
  18. 1 1
      docs/development/release-checklist.md
  19. 0 4
      docs/extra.css
  20. 27 9
      docs/features/virtualization.md
  21. 44 1
      docs/integrations/rest-api.md
  22. 18 2
      docs/integrations/webhooks.md
  23. 15 0
      docs/models/dcim/cablebundle.md
  24. 5 0
      docs/models/dcim/devicebay.md
  25. 12 0
      docs/models/dcim/devicetype.md
  26. 6 1
      docs/models/dcim/modulebay.md
  27. 30 0
      docs/models/dcim/moduletype.md
  28. 5 1
      docs/models/dcim/rack.md
  29. 15 0
      docs/models/dcim/rackgroup.md
  30. 4 0
      docs/models/dcim/rackreservation.md
  31. 6 0
      docs/models/extras/customfield.md
  32. 22 0
      docs/models/extras/customfieldchoiceset.md
  33. 4 1
      docs/models/extras/webhook.md
  34. 4 0
      docs/models/ipam/asn.md
  35. 4 0
      docs/models/ipam/vlangroup.md
  36. 29 13
      docs/models/virtualization/virtualmachine.md
  37. 27 0
      docs/models/virtualization/virtualmachinetype.md
  38. 24 0
      docs/plugins/development/permissions.md
  39. 69 28
      docs/plugins/development/ui-components.md
  40. 6 0
      docs/plugins/development/webhooks.md
  41. 8 0
      docs/release-notes/index.md
  42. 129 0
      docs/release-notes/version-4.6.md
  43. 6 0
      mkdocs.yml
  44. 10 0
      netbox/account/views.py
  45. 35 0
      netbox/circuits/migrations/0057_default_ordering_indexes.py
  46. 6 0
      netbox/circuits/models/circuits.py
  47. 6 0
      netbox/circuits/models/virtual_circuits.py
  48. 23 7
      netbox/circuits/ui/panels.py
  49. 6 5
      netbox/circuits/views.py
  50. 2 1
      netbox/core/api/serializers_/jobs.py
  51. 12 0
      netbox/core/choices.py
  52. 3 2
      netbox/core/forms/model_forms.py
  53. 41 8
      netbox/core/jobs.py
  54. 21 0
      netbox/core/migrations/0022_default_ordering_indexes.py
  55. 15 0
      netbox/core/migrations/0023_datasource_sync_permission.py
  56. 16 0
      netbox/core/migrations/0024_job_notifications.py
  57. 13 13
      netbox/core/models/change_logging.py
  58. 3 0
      netbox/core/models/config.py
  59. 3 0
      netbox/core/models/data.py
  60. 22 8
      netbox/core/models/jobs.py
  61. 0 1
      netbox/core/models/object_types.py
  62. 8 0
      netbox/core/tables/jobs.py
  63. 104 1
      netbox/core/tests/test_changelog.py
  64. 88 1
      netbox/core/tests/test_models.py
  65. 7 9
      netbox/core/views.py
  66. 16 2
      netbox/dcim/api/serializers_/cables.py
  67. 8 6
      netbox/dcim/api/serializers_/device_components.py
  68. 104 21
      netbox/dcim/api/serializers_/devices.py
  69. 4 4
      netbox/dcim/api/serializers_/devicetype_components.py
  70. 36 8
      netbox/dcim/api/serializers_/racks.py
  71. 2 0
      netbox/dcim/api/urls.py
  72. 20 0
      netbox/dcim/api/views.py
  73. 96 4
      netbox/dcim/cable_profiles.py
  74. 3 0
      netbox/dcim/constants.py
  75. 86 4
      netbox/dcim/filtersets.py
  76. 14 7
      netbox/dcim/forms/bulk_create.py
  77. 58 8
      netbox/dcim/forms/bulk_edit.py
  78. 48 7
      netbox/dcim/forms/bulk_import.py
  79. 0 1
      netbox/dcim/forms/common.py
  80. 79 10
      netbox/dcim/forms/filtersets.py
  81. 50 13
      netbox/dcim/forms/model_forms.py
  82. 21 1
      netbox/dcim/graphql/filters.py
  83. 6 0
      netbox/dcim/graphql/schema.py
  84. 37 0
      netbox/dcim/graphql/types.py
  85. 5 1
      netbox/dcim/migrations/0206_load_module_type_profiles.py
  86. 109 0
      netbox/dcim/migrations/0228_rack_group.py
  87. 54 0
      netbox/dcim/migrations/0229_cable_bundle.py
  88. 30 0
      netbox/dcim/migrations/0230_devicebay_modulebay_enabled.py
  89. 23 0
      netbox/dcim/migrations/0231_interface_rf_channel_frequency_precision.py
  90. 78 0
      netbox/dcim/migrations/0232_default_ordering_indexes.py
  91. 15 0
      netbox/dcim/migrations/0233_device_render_config_permission.py
  92. 59 8
      netbox/dcim/models/cables.py
  93. 75 32
      netbox/dcim/models/device_component_templates.py
  94. 36 4
      netbox/dcim/models/device_components.py
  95. 40 2
      netbox/dcim/models/devices.py
  96. 11 0
      netbox/dcim/models/modules.py
  97. 35 1
      netbox/dcim/models/racks.py
  98. 23 0
      netbox/dcim/search.py
  99. 29 2
      netbox/dcim/tables/cables.py
  100. 23 11
      netbox/dcim/tables/devices.py

+ 1 - 1
.github/workflows/ci.yml

@@ -92,7 +92,7 @@ jobs:
         pip install coverage tblib
 
     - name: Build documentation
-      run: mkdocs build
+      run: zensical build
 
     - name: Collect static files
       run: python netbox/manage.py collectstatic --no-input

+ 3 - 3
.pre-commit-config.yaml

@@ -21,11 +21,11 @@ repos:
       language: system
       pass_filenames: false
       types: [python]
-    - id: mkdocs-build
+    - id: zensical-build
       name: "Build documentation"
-      description: "Build the documentation with mkdocs"
+      description: "Build the documentation with Zensical"
       files: 'docs/'
-      entry: mkdocs build
+      entry: zensical build
       language: system
       pass_filenames: false
     - id: yarn-validate

+ 6 - 6
.readthedocs.yaml

@@ -1,10 +1,10 @@
 version: 2
 build:
-  os: ubuntu-22.04
+  os: ubuntu-24.04
   tools:
     python: "3.12"
-mkdocs:
-  configuration: mkdocs.yml
-python:
-   install:
-   - requirements: requirements.txt
+  commands:
+    - pip install -r requirements.txt
+    - python -m zensical build --config-file mkdocs.yml
+    - mkdir -p $READTHEDOCS_OUTPUT/html/
+    - cp -r netbox/project-static/docs/* $READTHEDOCS_OUTPUT/html/

+ 9 - 7
base_requirements.txt

@@ -4,7 +4,7 @@ colorama
 
 # The Python web framework on which NetBox is built
 # https://docs.djangoproject.com/en/stable/releases/
-Django==5.2.*
+Django==6.0.*
 
 # Django middleware which permits cross-domain API requests
 # https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
@@ -35,7 +35,9 @@ django-pglocks
 
 # Prometheus metrics library for Django
 # https://github.com/korfuri/django-prometheus/blob/master/CHANGELOG.md
-django-prometheus
+# TODO: 2.4.1 is incompatible with Django>=6.0, but a fixed release is expected
+# https://github.com/django-commons/django-prometheus/issues/494
+django-prometheus>=2.4.0,<2.5.0,!=2.4.1
 
 # Django caching backend using Redis
 # https://github.com/jazzband/django-redis/blob/master/CHANGELOG.rst
@@ -69,7 +71,7 @@ django-timezone-field
 # A REST API framework for Django projects
 # https://www.django-rest-framework.org/community/release-notes/
 # TODO: Re-evaluate the monkey-patch of get_unique_validators() before upgrading
-djangorestframework==3.16.1
+djangorestframework==3.17.1
 
 # Sane and flexible OpenAPI 3 schema generation for Django REST framework.
 # https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst
@@ -99,10 +101,6 @@ jsonschema
 # https://python-markdown.github.io/changelog/
 Markdown
 
-# MkDocs
-# https://github.com/mkdocs/mkdocs/releases
-mkdocs<2.0
-
 # MkDocs Material theme (for documentation build)
 # https://squidfunk.github.io/mkdocs-material/changelog/
 mkdocs-material
@@ -177,3 +175,7 @@ tablib
 # Timezone data (required by django-timezone-field on Python 3.9+)
 # https://github.com/python/tzdata/blob/master/NEWS.md
 tzdata
+
+# Documentation builder (succeeds mkdocs)
+# https://github.com/zensical/zensical
+zensical

Разница между файлами не показана из-за своего большого размера
+ 652 - 110
contrib/openapi.json


+ 0 - 18
docs/_theme/partials/copyright.html

@@ -1,18 +0,0 @@
-<div class="md-copyright">
-  {% if config.copyright %}
-    <div class="md-copyright__highlight">
-      {{ config.copyright }}
-    </div>
-  {% endif %}
-  {% if not config.extra.generator == false %}
-    Made with
-    <a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener">
-      Material for MkDocs
-    </a>
-  {% endif %}
-</div>
-{% if not config.extra.build_public %}
-  <div class="md-copyright">
-    ℹ️ Documentation is being served locally
-  </div>
-{% endif %}

+ 3 - 1
docs/administration/permissions.md

@@ -20,7 +20,9 @@ There are four core actions that can be permitted for each type of object within
 * **Change** - Modify an existing object
 * **Delete** - Delete an existing object
 
-In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `run` permission for scripts allows a user to execute custom scripts. These can be specified when granting a permission in the "additional actions" field.
+In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `sync` action for data sources allows a user to synchronize data from a remote source, and the `render_config` action for devices and virtual machines allows rendering configuration templates.
+
+Some models have registered actions that appear as checkboxes in the "Actions" section when creating or editing a permission. These are shown in a flat list alongside the built-in CRUD actions. Additional actions (such as those not yet registered by a plugin, or for backwards compatibility) can be entered manually in the "Additional actions" field.
 
 !!! note
     Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`.

+ 3 - 3
docs/configuration/development.md

@@ -4,9 +4,9 @@
 
 Default: `False`
 
-This setting enables debugging. Debugging should be enabled only during development or troubleshooting. Note that only
-clients which access NetBox from a recognized [internal IP address](./system.md#internal_ips) will see debugging tools in the user
-interface.
+This setting enables debugging and displays a debugging toolbar in the user interface. Debugging should be enabled only during development or troubleshooting.
+
+Note that the debugging toolbar will be displayed only for requests originating from [internal IP addresses](./system.md#internal_ips), if defined. If no internal IPs are defined, the toolbar will be displayed for all requests.
 
 !!! warning
     Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users and impose a

+ 1 - 0
docs/configuration/index.md

@@ -21,6 +21,7 @@ Some configuration parameters are primarily controlled via NetBox's admin interf
 * [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom)
 * [`BANNER_LOGIN`](./miscellaneous.md#banner_login)
 * [`BANNER_TOP`](./miscellaneous.md#banner_top)
+* [`CHANGELOG_RETAIN_CREATE_LAST_UPDATE`](./miscellaneous.md#changelog_retain_create_last_update)
 * [`CHANGELOG_RETENTION`](./miscellaneous.md#changelog_retention)
 * [`CUSTOM_VALIDATORS`](./data-validation.md#custom_validators)
 * [`DEFAULT_USER_PREFERENCES`](./default-values.md#default_user_preferences)

+ 29 - 0
docs/configuration/miscellaneous.md

@@ -73,6 +73,23 @@ This data enables the project maintainers to estimate how many NetBox deployment
 
 ---
 
+## CHANGELOG_RETAIN_CREATE_LAST_UPDATE
+
+!!! tip "Dynamic Configuration Parameter"
+
+Default: `False`
+
+When pruning expired changelog entries (per `CHANGELOG_RETENTION`), retain each non-deleted object's original `create`
+change record and its most recent `update` change record. If an object has a `delete` change record, its changelog
+entries are pruned normally according to `CHANGELOG_RETENTION`.
+
+!!! note
+    For objects without a `delete` change record, the original `create` record and most recent `update` record are
+    exempt from pruning. All other changelog records (including intermediate `update` records and all `delete` records)
+    remain subject to pruning per `CHANGELOG_RETENTION`.
+
+---
+
 ## CHANGELOG_RETENTION
 
 !!! tip "Dynamic Configuration Parameter"
@@ -106,6 +123,18 @@ The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` da
 
 ---
 
+## STREAMING_EXPORTS
+
+!!! note "This parameter was introduced in NetBox v4.6."
+
+Default: `False`
+
+When set to `True`, CSV bulk exports are returned as a streaming HTTP response, emitting rows to the client as they are rendered rather than buffering the entire dataset in memory first. This can significantly reduce memory usage and time-to-first-byte for very large exports.
+
+Because streaming responses do not have a `Content-Length` header and defer errors until after the response has begun, this behavior is opt-in.
+
+---
+
 ## ENFORCE_GLOBAL_UNIQUE
 
 !!! tip "Dynamic Configuration Parameter"

+ 3 - 0
docs/configuration/security.md

@@ -161,6 +161,9 @@ Note that enabling this setting causes NetBox to update a user's session in the
 
 ## LOGIN_REQUIRED
 
+!!! warning "Legacy Configuration Parameter"
+    The `LOGIN_REQUIRED` configuration parameter is deprecated and will be removed in NetBox v5.0. Unauthenticated access to the application will no longer be supported once this configuration parameter is removed.
+
 Default: `True`
 
 When enabled, only authenticated users are permitted to access any part of NetBox. Disabling this will allow unauthenticated users to access most areas of NetBox (but not make any changes).

+ 7 - 0
docs/configuration/system.md

@@ -105,6 +105,13 @@ A list of IP addresses recognized as internal to the system, used to control the
 example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP
 addresses (and [`DEBUG`](./development.md#debug) is `True`).
 
+!!! info "New in NetBox v4.6"
+    Setting this parameter to an empty list will enable the toolbar for all requests provided debugging is enabled:
+
+    ```python
+    INTERNAL_IPS = []
+    ```
+
 ---
 
 ## ISOLATED_DEPLOYMENT

+ 1 - 0
docs/customization/custom-fields.md

@@ -63,6 +63,7 @@ NetBox supports limited custom validation for custom field values. Following are
 * Text: Regular expression (optional)
 * Integer: Minimum and/or maximum value (optional)
 * Selection: Must exactly match one of the prescribed choices
+* JSON: Must adhere to the defined validation schema (if any)
 
 ### Custom Selection Fields
 

+ 14 - 0
docs/customization/custom-scripts.md

@@ -115,6 +115,20 @@ commit_default = False
 
 By default, a script can be scheduled for execution at a later time. Setting `scheduling_enabled` to False disables this ability: Only immediate execution will be possible. (This also disables the ability to set a recurring execution interval.)
 
+### `notifications_default`
+
+By default, a notification is generated for the requesting user each time a script finishes running. This attribute sets the initial value for the notifications field when running a script. Valid values are `always` (default), `on_failure`, and `never`.
+
+```python
+notifications_default = 'on_failure'
+```
+
+| Value | Behavior |
+|-------|----------|
+| `always` | Notify on every completion (default) |
+| `on_failure` | Notify only when the job fails or errors |
+| `never` | Never send a notification |
+
 ### `job_timeout`
 
 Set the maximum allowed runtime for the script. If not set, `RQ_DEFAULT_TIMEOUT` will be used.

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

@@ -32,6 +32,9 @@ Core model features are listed in the [features matrix](./models.md#features-mat
 
 ### `models`
 
+!!! warning "Deprecated"
+    Usage of this key has been deprecated and will be removed in NetBox v4.7. Use `ObjectType.objects.public()` to find registered models.
+
 This key lists all models which have been registered in NetBox which are not designated for private use. (Setting `_netbox_private` to True on a model excludes it from this list.) As with individual features under `model_features`, models are organized by app label.
 
 ### `plugins`

+ 1 - 1
docs/development/getting-started.md

@@ -97,7 +97,7 @@ NetBox uses [`pre-commit`](https://pre-commit.com/) to automatically validate co
 * Run the `ruff` Python linter
 * Run Django's internal system check
 * Check for missing database migrations
-* Validate any changes to the documentation with `mkdocs`
+* Validate any changes to the documentation with `zensical`
 * Validate Typescript & Sass styling with `yarn`
 * Ensure that any modified static front end assets have been recompiled
 

+ 3 - 0
docs/development/models.md

@@ -45,6 +45,7 @@ These are considered the "core" application models which are used to model netwo
 * [core.DataSource](../models/core/datasource.md)
 * [core.Job](../models/core/job.md)
 * [dcim.Cable](../models/dcim/cable.md)
+* [dcim.CableBundle](../models/dcim/cablebundle.md)
 * [dcim.Device](../models/dcim/device.md)
 * [dcim.DeviceType](../models/dcim/devicetype.md)
 * [dcim.Module](../models/dcim/module.md)
@@ -73,6 +74,7 @@ These are considered the "core" application models which are used to model netwo
 * [tenancy.Tenant](../models/tenancy/tenant.md)
 * [virtualization.Cluster](../models/virtualization/cluster.md)
 * [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md)
+* [virtualization.VirtualMachineType](../models/virtualization/virtualmachinetype.md)
 * [vpn.IKEPolicy](../models/vpn/ikepolicy.md)
 * [vpn.IKEProposal](../models/vpn/ikeproposal.md)
 * [vpn.IPSecPolicy](../models/vpn/ipsecpolicy.md)
@@ -92,6 +94,7 @@ Organization models are used to organize and classify primary models.
 * [dcim.DeviceRole](../models/dcim/devicerole.md)
 * [dcim.Manufacturer](../models/dcim/manufacturer.md)
 * [dcim.Platform](../models/dcim/platform.md)
+* [dcim.RackGroup](../models/dcim/rackgroup.md)
 * [dcim.RackRole](../models/dcim/rackrole.md)
 * [ipam.ASNRange](../models/ipam/asnrange.md)
 * [ipam.RIR](../models/ipam/rir.md)

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

@@ -47,7 +47,7 @@ If a new Django release is adopted or other major dependencies (Python, PostgreS
 Start the documentation server and navigate to the current version of the installation docs:
 
 ```no-highlight
-mkdocs serve
+zensical serve
 ```
 
 Follow these instructions to perform a new installation of NetBox in a temporary environment. This process must not be automated: The goal of this step is to catch any errors or omissions in the documentation and ensure that it is kept up to date for each release. Make any necessary changes to the documentation before proceeding with the release.

+ 0 - 4
docs/extra.css

@@ -5,10 +5,6 @@ img {
     margin-right: auto;
 }
 
-.md-content img {
-    background-color: rgba(255, 255, 255, 0.64);
-}
-
 /* Tables */
 table {
     margin-bottom: 24px;

+ 27 - 9
docs/features/virtualization.md

@@ -1,26 +1,44 @@
 # Virtualization
 
-Virtual machines and clusters can be modeled in NetBox alongside physical infrastructure. IP addresses and other resources are assigned to these objects just like physical objects, providing a seamless integration between physical and virtual networks.
+Virtual machines, clusters, and standalone hypervisors can be modeled in NetBox alongside physical infrastructure. IP addresses and other resources are assigned to these objects just like physical objects, providing a seamless integration between physical and virtual networks.
 
 ```mermaid
 flowchart TD
     ClusterGroup & ClusterType --> Cluster
+    VirtualMachineType --> VirtualMachine
+    Device --> VirtualMachine
     Cluster --> VirtualMachine
     Platform --> VirtualMachine
     VirtualMachine --> VMInterface
 
-click Cluster "../../models/virtualization/cluster/"
-click ClusterGroup "../../models/virtualization/clustergroup/"
-click ClusterType "../../models/virtualization/clustertype/"
-click Platform "../../models/dcim/platform/"
-click VirtualMachine "../../models/virtualization/virtualmachine/"
-click VMInterface "../../models/virtualization/vminterface/"
+    click Cluster "../../models/virtualization/cluster/"
+    click ClusterGroup "../../models/virtualization/clustergroup/"
+    click ClusterType "../../models/virtualization/clustertype/"
+    click VirtualMachineType "../../models/virtualization/virtualmachinetype/"
+    click Device "../../models/dcim/device/"
+    click Platform "../../models/dcim/platform/"
+    click VirtualMachine "../../models/virtualization/virtualmachine/"
+    click VMInterface "../../models/virtualization/vminterface/"
 ```
 
 ## Clusters
 
-A cluster is one or more physical host devices on which virtual machines can run. Each cluster must have a type and operational status, and may be assigned to a group. (Both types and groups are user-defined.) Each cluster may designate one or more devices as hosts, however this is optional.
+A cluster is one or more physical host devices on which virtual machines can run.
+
+Each cluster must have a type and operational status, and may be assigned to a group. (Both types and groups are user-defined.) Each cluster may designate one or more devices as hosts, however this is optional.
+
+## Virtual Machine Types
+
+A virtual machine type provides reusable classification for virtual machines and can define create-time defaults for platform, vCPUs, and memory. This is useful when multiple virtual machines share a common sizing or profile while still allowing per-instance overrides after creation.
 
 ## Virtual Machines
 
-A virtual machine is a virtualized compute instance. These behave in NetBox very similarly to device objects, but without any physical attributes. For example, a VM may have interfaces assigned to it with IP addresses and VLANs, however its interfaces cannot be connected via cables (because they are virtual). Each VM may also define its compute, memory, and storage resources as well.
+A virtual machine is a virtualized compute instance. These behave in NetBox very similarly to device objects, but without any physical attributes.
+
+For example, a VM may have interfaces assigned to it with IP addresses and VLANs, however its interfaces cannot be connected via cables (because they are virtual). Each VM may define its compute, memory, and storage resources as well. A VM can optionally be assigned a [virtual machine type](../models/virtualization/virtualmachinetype.md) to classify it and provide default values for selected attributes at creation time.
+
+A VM can be placed in one of three ways:
+
+- Assigned to a site alone for logical grouping.
+- Assigned to a cluster and optionally pinned to a specific host device within that cluster.
+- Assigned directly to a standalone device that does not belong to any cluster.

+ 44 - 1
docs/integrations/rest-api.md

@@ -341,7 +341,7 @@ When retrieving devices and virtual machines via the REST API, each will include
 
 ## Pagination
 
-API responses which contain a list of many objects will be paginated for efficiency. The root JSON object returned by a list endpoint contains the following attributes:
+API responses which contain a list of many objects will be paginated for efficiency. NetBox employs offset-based pagination by default, which forms a page by skipping the number of objects indicated by the `offset` URL parameter. The root JSON object returned by a list endpoint contains the following attributes:
 
 * `count`: The total number of all objects matching the query
 * `next`: A hyperlink to the next page of results (if applicable)
@@ -398,6 +398,49 @@ The maximum number of objects that can be returned is limited by the [`MAX_PAGE_
 !!! warning
     Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database.
 
+### Cursor-Based Pagination
+
+For large datasets, offset-based pagination can become inefficient because the database must scan all rows up to the offset. As an alternative, cursor-based pagination uses the `start` query parameter to filter results by primary key (PK), enabling efficient keyset pagination.
+
+To use cursor-based pagination, pass `start` (the minimum PK value) and `limit` (the page size):
+
+```
+http://netbox/api/dcim/devices/?start=0&limit=100
+```
+
+This returns objects with an `id` greater than or equal to zero, ordered by PK, limited to 100 results. Below is an example showing an arbitrary `start` value.
+
+```json
+{
+    "count": null,
+    "next": "http://netbox/api/dcim/devices/?start=356&limit=100",
+    "previous": null,
+    "results": [
+        {
+            "id": 109,
+            "name": "dist-router07",
+            ...
+        },
+        ...
+        {
+            "id": 356,
+            "name": "acc-switch492",
+            ...
+        }
+    ]
+}
+```
+
+To iterate through all results, use the `id` of the last object in each response plus one as the `start` value for the next request. Continue until `next` is null.
+
+!!! info
+    Some important differences from offset-based pagination:
+
+    * `start` and `offset` are **mutually exclusive**; specifying both will result in a 400 error.
+    * Results are always ordered by primary key when using `start`. This is required to ensure deterministic behavior.
+    * `count` is always `null` in cursor mode, as counting all matching rows would partially negate its performance benefit.
+    * `previous` is always `null`: cursor-based pagination supports only forward navigation.
+
 ## Interacting with Objects
 
 ### Retrieving Multiple Objects

+ 18 - 2
docs/integrations/webhooks.md

@@ -26,10 +26,20 @@ The following data is available as context for Jinja2 templates:
 * `event` - The type of event which triggered the webhook: `created`, `updated`, or `deleted`.
 * `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format).
 * `object_type` - The NetBox model which triggered the change in the form `app_label.model_name`.
-* `username` - The name of the user account associated with the change.
-* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
+* `request` - Data about the triggering request (if available).
+    * `request.id` - The UUID associated with the request
+    * `request.method` - The HTTP method (e.g. `GET` or `POST`)
+    * `request.path` - The URL path (ex: `/dcim/sites/123/edit/`)
+    * `request.user` - The name of the authenticated user who made the request (if available)
 * `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
 * `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
+* ⚠️ `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
+* ⚠️ `username` - The name of the user account associated with the change.
+
+!!! warning "Deprecation of legacy keys"
+    The `request_id` and `username` keys in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
+
+    Use `request.user` and `request.id` from the `request` object included in the callback context instead.
 
 ### Default Request Body
 
@@ -56,6 +66,12 @@ If no body template is specified, the request body will be populated with a JSON
         "region": null,
         ...
     },
+    "request": {
+        "id": "17af32f0-852a-46ca-a7d4-33ecd0c13de6",
+        "method": "POST",
+        "path": "/dcim/sites/add/",
+        "user": "jstretch"
+    },
     "snapshots": {
         "prechange": null,
         "postchange": {

+ 15 - 0
docs/models/dcim/cablebundle.md

@@ -0,0 +1,15 @@
+# Cable Bundles
+
+A cable bundle is a logical grouping of individual [cables](./cable.md). Bundles can be used to organize cables that share a common purpose, route, or physical grouping (such as a conduit or harness).
+
+Assigning cables to a bundle is optional and does not affect cable tracing or connectivity. Bundles persist independently of their member cables: deleting a cable clears its bundle assignment but does not delete the bundle itself.
+
+## Fields
+
+### Name
+
+A unique name for the cable bundle.
+
+### Description
+
+A brief description of the bundle's purpose or contents.

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

@@ -23,3 +23,8 @@ The device bay's name. Must be unique to the parent device.
 ### Label
 
 An alternative physical label identifying the device bay.
+
+### Enabled
+
+Whether this device bay is enabled. Disabled device bays are not available for installation.
+

+ 12 - 0
docs/models/dcim/devicetype.md

@@ -7,6 +7,18 @@ Device types are instantiated as devices installed within sites and/or equipment
 !!! note
     This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as modules or inventory items within a device.
 
+## Automatic Component Renaming
+
+When adding component templates to a device type, the string `{vc_position}` can be used in component template names to reference the
+`vc_position` field of the device being provisioned, when that device is a member of a Virtual Chassis.
+
+For example, an interface template named `Gi{vc_position}/0/0` installed on a Virtual Chassis
+member with position `2` will be rendered as `Gi2/0/0`.
+
+If the device is not a member of a Virtual Chassis, `{vc_position}` defaults to `0`. A custom
+fallback value can be specified using the syntax `{vc_position:X}`, where `X` is the desired default.
+For example, `{vc_position:1}` will render as `1` when no Virtual Chassis position is set.
+
 ## Fields
 
 ### Manufacturer

+ 6 - 1
docs/models/dcim/modulebay.md

@@ -1,6 +1,6 @@
 # Module Bays
 
-Module bays represent a space or slot within a device in which a field-replaceable [module](./module.md) may be installed. A common example is that of a chassis-based switch such as the Cisco Nexus 9000 or Juniper EX9200. Modules in turn hold additional components that become available to the parent device.
+Module bays represent a space or slot within a device in which a field-replaceable [module](./module.md) may be installed. A common example is that of a chassis-based switch such as the Cisco Nexus 9000 or Juniper EX9200. Modules, in turn, hold additional components that become available to the parent device.
 
 !!! note
     If you need to model child devices rather than modules, use a [device bay](./devicebay.md) instead.
@@ -29,3 +29,8 @@ An alternative physical label identifying the module bay.
 ### Position
 
 The numeric position in which this module bay is situated. For example, this would be the number assigned to a slot within a chassis-based switch.
+
+### Enabled
+
+Whether this module bay is enabled. Disabled module bays are not available for installation.
+

+ 30 - 0
docs/models/dcim/moduletype.md

@@ -20,8 +20,38 @@ When adding component templates to a module type, the string `{module}` can be u
 
 For example, you can create a module type with interface templates named `Gi{module}/0/[1-48]`. When a new module of this type is "installed" to a module bay with a position of "3", NetBox will automatically name these interfaces `Gi3/0/[1-48]`.
 
+Similarly, the string `{vc_position}` can be used in component template names to reference the
+`vc_position` field of the device being provisioned, when that device is a member of a Virtual Chassis.
+
+For example, an interface template named `Gi{vc_position}/{module}/0` installed on a Virtual Chassis
+member with position `2` and module bay position `3` will be rendered as `Gi2/3/0`.
+
+If the device is not a member of a Virtual Chassis, `{vc_position}` defaults to `0`. A custom
+fallback value can be specified using the syntax `{vc_position:X}`, where `X` is the desired default.
+For example, `{vc_position:1}` will render as `1` when no Virtual Chassis position is set.
+
 Automatic renaming is supported for all modular component types (those listed above).
 
+### Position Inheritance for Nested Modules
+
+When using nested module bays (modules installed inside other modules), the `{module}` placeholder
+can also be used in the **position** field of module bay templates to inherit the parent bay's
+position. This allows a single module type to produce correctly named components at any nesting
+depth, with a user-controlled separator.
+
+For example, a line card module type might define sub-bay positions as `{module}/1`, `{module}/2`,
+etc. When the line card is installed in a device bay with position `3`, these sub-bay positions
+resolve to `3/1`, `3/2`, etc. An SFP module type with interface template `SFP {module}` installed
+in sub-bay `3/2` then produces interface `SFP 3/2`.
+
+The separator between levels is defined by the user in the position field template itself. Using
+`{module}-1` produces positions like `3-1`, while `{module}.1` produces `3.1`. This provides
+full flexibility without requiring a global separator configuration.
+
+!!! note
+    If the position field does not contain `{module}`, no inheritance occurs and behavior is
+    unchanged from previous versions.
+
 ## Fields
 
 ### Manufacturer

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

@@ -1,6 +1,6 @@
 # Racks
 
-The rack model represents a physical two- or four-post equipment rack in which [devices](./device.md) can be installed. Each rack must be assigned to a [site](./site.md), and may optionally be assigned to a [location](./location.md) within that site. Racks can also be organized by user-defined functional roles. The name and facility ID of each rack within a location must be unique.
+The rack model represents a physical two- or four-post equipment rack in which [devices](./device.md) can be installed. Each rack must be assigned to a [site](./site.md), and may optionally be assigned to a [location](./location.md) within that site. Racks can also be organized by user-defined functional roles or by [rack groups](./rackgroup.md). The name and facility ID of each rack within a location must be unique.
 
 Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending (from the ground up) or descending order.
 
@@ -16,6 +16,10 @@ The [site](./site.md) to which the rack is assigned.
 
 The [location](./location.md) within a site where the rack has been installed (optional).
 
+### Rack Group
+
+The [group](./rackgroup.md) used to organize racks by physical placement (optional).
+
 ### Name
 
 The rack's name or identifier. Must be unique to the rack's location, if assigned.

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

@@ -0,0 +1,15 @@
+# Rack Groups
+
+Racks can optionally be assigned to rack groups to reflect their physical placement. Rack groups provide a secondary means of categorization alongside [locations](./location.md), which is particularly useful for datacenter operators who need to group racks by row, aisle, or similar physical arrangement while keeping them assigned to the same location, such as a cage or room. Rack groups are flat and do not form a hierarchy.
+
+Rack groups can also be used to scope [VLAN groups](../ipam/vlangroup.md), which can help model L2 domains spanning rows or pairs of racks.
+
+## Fields
+
+### Name
+
+A unique human-friendly name.
+
+### Slug
+
+A unique URL-friendly identifier. (This value can be used for filtering.)

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

@@ -12,6 +12,10 @@ 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.
 
+### Total U's
+
+A calculated (read-only) field that reflects the total number of units in the reservation. Can be filtered upon using `unit_count_min` and `unit_count_max` parameters in the UI or API.
+
 ### 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.)

+ 6 - 0
docs/models/extras/customfield.md

@@ -103,6 +103,8 @@ The default value to populate for the custom field when creating new objects (op
 
 For selection and multi-select custom fields only, this is the [set of choices](./customfieldchoiceset.md) which are valid for the field.
 
+Choice sets may optionally define colors for individual values. Colored choices are rendered as badges on object detail pages for selection and multiple-selection custom fields.
+
 ### Cloneable
 
 If enabled, values from this field will be automatically pre-populated when cloning existing objects.
@@ -118,3 +120,7 @@ For numeric custom fields only. The maximum valid value (optional).
 ### Validation Regex
 
 For string-based custom fields only. A regular expression used to validate the field's value (optional).
+
+### Validation Schema
+
+For JSON custom fields, users have the option of defining a [validation schema](https://json-schema.org). Any value applied to this custom field on a model will be validated against the provided schema, if any.

+ 22 - 0
docs/models/extras/customfieldchoiceset.md

@@ -22,6 +22,28 @@ The set of pre-defined choices to include. Available sets are listed below. This
 
 A set of custom choices that will be appended to the base choice set (if any).
 
+### Choice Colors
+
+Optional color bindings for individual choice values. Each color is bound to a choice by its value rather than its label.
+
+When editing a choice set in the UI, enter one mapping per line using the format `value:color`. Supported colors are:
+
+* `blue`
+* `indigo`
+* `purple`
+* `pink`
+* `red`
+* `orange`
+* `yellow`
+* `green`
+* `teal`
+* `cyan`
+* `gray`
+* `black`
+* `white`
+
+Colored choices are rendered as badges on object detail pages for selection and multiple-selection custom fields.
+
 ### Order Alphabetically
 
 If enabled, the choices list will be automatically ordered alphabetically. If disabled, choices will appear in the order in which they were defined.

+ 4 - 1
docs/models/extras/webhook.md

@@ -81,10 +81,13 @@ The following context variables are available to the text and link templates.
 
 | Variable      | Description                                          |
 |---------------|------------------------------------------------------|
-| `event`       | The event type (`created`, `updated`, or `deleted`)  |
+| `event`       | The event type (`create`, `update`, or `delete`)     |
 | `timestamp`   | The time at which the event occurred                 |
 | `object_type` | The type of object impacted (`app_label.model_name`) |
 | `username`    | The name of the user associated with the change      |
 | `request_id`  | The unique request ID                                |
 | `data`        | A complete serialized representation of the object   |
 | `snapshots`   | Pre- and post-change snapshots of the object         |
+
+!!! warning "Deprecation of legacy fields"
+    The `request_id` and `username` fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0. Use `request.user` and `request.id` from the `request` object included in the callback context instead. (Note that `request` is populated in the context only when the webhook is associated with a triggering request.)

+ 4 - 0
docs/models/ipam/asn.md

@@ -14,6 +14,10 @@ The 16- or 32-bit AS number.
 
 The [Regional Internet Registry](./rir.md) or similar authority responsible for the allocation of this particular ASN.
 
+### Role
+
+The user-defined functional [role](./role.md) assigned to this ASN.
+
 ### Sites
 
 The [site(s)](../dcim/site.md) to which this ASN is assigned.

+ 4 - 0
docs/models/ipam/vlangroup.md

@@ -18,6 +18,10 @@ A unique URL-friendly identifier. (This value can be used for filtering.)
 
 The set of VLAN IDs which are encompassed by the group. By default, this will be the entire range of valid IEEE 802.1Q VLAN IDs (1 to 4094, inclusive). VLANs created within a group must have a VID that falls within one of these ranges. Ranges may not overlap.
 
+### Total VLAN IDs
+
+A read-only integer indicating the total count of VLAN IDs available within the group, calculated from the configured VLAN ID Ranges. For example, a group with ranges `100-199` and `300-399` would have a total of 200 VLAN IDs. This value is automatically computed and updated whenever the VLAN ID ranges are modified.
+
 ### Scope
 
 The domain covered by a VLAN group, defined as one of the supported object types. This conveys the context in which a VLAN group applies.

+ 29 - 13
docs/models/virtualization/virtualmachine.md

@@ -1,18 +1,27 @@
 # Virtual Machines
 
-A virtual machine (VM) represents a virtual compute instance hosted within a [cluster](./cluster.md). Each VM must be assigned to a [site](../dcim/site.md) and/or cluster, and may optionally be assigned to a particular host [device](../dcim/device.md) within a cluster.
+A virtual machine (VM) represents a virtual compute instance hosted within a cluster or directly on a device. Each VM must be assigned to at least one of: a [site](../dcim/site.md), a [cluster](./cluster.md), or a [device](../dcim/device.md).
 
-Virtual machines may have virtual [interfaces](./vminterface.md) assigned to them, but do not support any physical component. When a VM has one or more interfaces with IP addresses assigned, a primary IP for the device can be designated, for both IPv4 and IPv6.
+Virtual machines may have virtual [interfaces](./vminterface.md) assigned to them, but do not support any physical component. When a VM has one or more interfaces with IP addresses assigned, a primary IP for the VM can be designated, for both IPv4 and IPv6.
 
 ## Fields
 
 ### Name
 
-The virtual machine's configured name. Must be unique to the assigned cluster and tenant.
+The virtual machine's configured name. Must be unique within its scoping context:
+
+- If assigned to a **cluster**: unique within the cluster and tenant.
+- If assigned to a **device** (no cluster): unique within the device and tenant.
+
+### Type
+
+The [virtual machine type](./virtualmachinetype.md) assigned to the VM. A type classifies a virtual machine and can provide default values for platform, vCPUs, and memory when the VM is created.
+
+Changes made to a virtual machine type do **not** apply retroactively to existing virtual machines.
 
 ### Role
 
-The functional [role](../dcim/devicerole.md) assigned to the VM.
+The functional role assigned to the VM.
 
 ### Status
 
@@ -21,24 +30,28 @@ The VM's operational status.
 !!! tip
     Additional statuses may be defined by setting `VirtualMachine.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
 
-### Start on boot
+### Start on Boot
 
 The start on boot setting from the hypervisor.
 
 !!! tip
     Additional statuses may be defined by setting `VirtualMachine.start_on_boot` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
 
-### Site & Cluster
+### Site / Cluster / Device
 
-The [site](../dcim/site.md) and/or [cluster](./cluster.md) to which the VM is assigned.
+The location or host for this VM. At least one must be specified:
 
-### Device
+- **Site only**: The VM exists at a site but is not assigned to a specific cluster or device.
+- **Cluster only**: The VM belongs to a virtualization cluster. The site is automatically inferred from the cluster's scope.
+- **Device only**: The VM runs directly on a physical host device without a cluster (e.g. containers). The site is automatically inferred from the device's site.
+- **Cluster + Device**: The VM belongs to a cluster and is pinned to a specific host device within that cluster. The device must be a registered host of the assigned cluster.
 
-The physical host [device](../dcim/device.md) within the assigned site/cluster on which this VM resides.
+!!! info "New in NetBox v4.6"
+    Virtual machines can now be assigned directly to a device without requiring a cluster. This is particularly useful for modeling VMs running on standalone hosts outside of a cluster.
 
 ### Platform
 
-A VM may be associated with a particular [platform](../dcim/platform.md) to indicate its operating system.
+A VM may be associated with a particular [platform](../dcim/platform.md) to indicate its operating system. If a virtual machine type defines a default platform, it will be applied when the VM is created unless an explicit platform is specified.
 
 ### Primary IPv4 & IPv6 Addresses
 
@@ -49,11 +62,11 @@ Each VM may designate one primary IPv4 address and/or one primary IPv6 address f
 
 ### vCPUs
 
-The number of virtual CPUs provisioned. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU).
+The number of virtual CPUs provisioned. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU). If a virtual machine type defines a default vCPU allocation, it will be applied when the VM is created unless an explicit value is specified.
 
 ### Memory
 
-The amount of running memory provisioned, in megabytes.
+The amount of running memory provisioned, in megabytes. If a virtual machine type defines a default memory allocation, it will be applied when the VM is created unless an explicit value is specified.
 
 ### Disk
 
@@ -64,4 +77,7 @@ The amount of disk storage provisioned, in megabytes.
 
 ### Serial Number
 
-Optional serial number assigned to this virtual machine. Unlike devices, uniqueness is not enforced for virtual machine serial numbers.
+Optional serial number assigned to this virtual machine.
+
+!!! info
+    Unlike devices, uniqueness is not enforced for virtual machine serial numbers.

+ 27 - 0
docs/models/virtualization/virtualmachinetype.md

@@ -0,0 +1,27 @@
+# Virtual Machine Types
+
+A virtual machine type defines a reusable classification and default configuration for [virtual machines](./virtualmachine.md).
+
+A type can optionally provide default values for a VM's [platform](../dcim/platform.md), vCPU allocation, and memory allocation. When a virtual machine is created with an assigned type, any unset values among these fields will inherit their defaults from the type. Changes made to a virtual machine type do **not** apply retroactively to existing virtual machines.
+
+## Fields
+
+### Name
+
+A unique human-friendly name.
+
+### Slug
+
+A unique URL-friendly identifier. (This value can be used for filtering.)
+
+### Default Platform
+
+If defined, virtual machines instantiated with this type will automatically inherit the selected platform when no explicit platform is provided.
+
+### Default vCPUs
+
+The default number of vCPUs to assign when creating a virtual machine from this type.
+
+### Default Memory
+
+The default amount of memory, in megabytes, to assign when creating a virtual machine from this type.

+ 24 - 0
docs/plugins/development/permissions.md

@@ -0,0 +1,24 @@
+# Custom Model Actions
+
+Plugins can register custom permission actions for their models. These actions appear as checkboxes in the ObjectPermission form, making it easy for administrators to grant or restrict access to plugin-specific functionality without manually entering action names.
+
+For example, a plugin might define a "sync" action for a model that syncs data from an external source, or a "bypass" action that allows users to bypass certain restrictions.
+
+## Registering Model Actions
+
+The preferred way to register custom actions is via Django's `Meta.permissions` on the model class. NetBox will automatically register these as model actions when the app is loaded:
+
+```python
+from netbox.models import NetBoxModel
+
+class MyModel(NetBoxModel):
+    # ...
+
+    class Meta:
+        permissions = [
+            ('sync', 'Synchronize data from external source'),
+            ('export', 'Export data to external system'),
+        ]
+```
+
+Once registered, these actions appear as checkboxes in a flat list when creating or editing an ObjectPermission.

+ 69 - 28
docs/plugins/development/ui-components.md

@@ -1,12 +1,9 @@
 # UI Components
 
-!!! note "New in NetBox v4.5"
-    All UI components described here were introduced in NetBox v4.5. Be sure to set the minimum NetBox version to 4.5.0 for your plugin before incorporating any of these resources.
+!!! note "New in NetBox v4.6"
+    All UI components described here were introduced in NetBox v4.6. Be sure to set the minimum NetBox version to 4.6.0 for your plugin before incorporating any of these resources.
 
-!!! danger "Beta Feature"
-    UI components are considered a beta feature, and are still under active development. Please be aware that the API for resources on this page is subject to change in future releases.
-
-To simply the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML.
+To simplify the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML.
 
 ## Page Layout
 
@@ -75,9 +72,12 @@ class RecentChangesPanel(Panel):
             **super().get_context(context),
             'changes': get_changes()[:10],
         }
+
+    def should_render(self, context):
+        return len(context['changes']) > 0
 ```
 
-NetBox also includes a set of panels suite for specific uses, such as display object details or embedding a table of related objects. These are listed below.
+NetBox also includes a set of panels suited for specific uses, such as displaying object details or embedding a table of related objects. These are listed below.
 
 ::: netbox.ui.panels.Panel
 
@@ -85,26 +85,6 @@ NetBox also includes a set of panels suite for specific uses, such as display ob
 
 ::: netbox.ui.panels.ObjectAttributesPanel
 
-#### Object Attributes
-
-The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
-
-| Class                                | Description                                      |
-|--------------------------------------|--------------------------------------------------|
-| `netbox.ui.attrs.AddressAttr`        | A physical or mailing address.                   |
-| `netbox.ui.attrs.BooleanAttr`        | A boolean value                                  |
-| `netbox.ui.attrs.ColorAttr`          | A color expressed in RGB                         |
-| `netbox.ui.attrs.ChoiceAttr`         | A selection from a set of choices                |
-| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude)         |
-| `netbox.ui.attrs.ImageAttr`          | An attached image (displays the image)           |
-| `netbox.ui.attrs.NestedObjectAttr`   | A related nested object                          |
-| `netbox.ui.attrs.NumericAttr`        | An integer or float value                        |
-| `netbox.ui.attrs.RelatedObjectAttr`  | A related object                                 |
-| `netbox.ui.attrs.TemplatedAttr`      | Renders an attribute using a custom template     |
-| `netbox.ui.attrs.TextAttr`           | A string (text) value                            |
-| `netbox.ui.attrs.TimezoneAttr`       | A timezone with annotated offset                 |
-| `netbox.ui.attrs.UtilizationAttr`    | A numeric value expressed as a utilization graph |
-
 ::: netbox.ui.panels.OrganizationalObjectPanel
 
 ::: netbox.ui.panels.NestedGroupObjectPanel
@@ -119,9 +99,13 @@ The following classes are available to represent object attributes within an Obj
 
 ::: netbox.ui.panels.TemplatePanel
 
+::: netbox.ui.panels.TextCodePanel
+
+::: netbox.ui.panels.ContextTablePanel
+
 ::: netbox.ui.panels.PluginContentPanel
 
-## Panel Actions
+### Panel Actions
 
 Each panel may have actions associated with it. These render as links or buttons within the panel header, opposite the panel's title. For example, a common use case is to include an "Add" action on a panel which displays a list of objects. Below is an example of this.
 
@@ -146,3 +130,60 @@ panels.ObjectsTablePanel(
 ::: netbox.ui.actions.AddObject
 
 ::: netbox.ui.actions.CopyContent
+
+## Object Attributes
+
+The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
+
+| Class                                    | Description                                      |
+|------------------------------------------|--------------------------------------------------|
+| `netbox.ui.attrs.AddressAttr`            | A physical or mailing address.                   |
+| `netbox.ui.attrs.BooleanAttr`            | A boolean value                                  |
+| `netbox.ui.attrs.ChoiceAttr`             | A selection from a set of choices                |
+| `netbox.ui.attrs.ColorAttr`              | A color expressed in RGB                         |
+| `netbox.ui.attrs.DateTimeAttr`           | A date or datetime value                         |
+| `netbox.ui.attrs.GenericForeignKeyAttr`  | A related object via a generic foreign key       |
+| `netbox.ui.attrs.GPSCoordinatesAttr`     | GPS coordinates (latitude and longitude)         |
+| `netbox.ui.attrs.ImageAttr`              | An attached image (displays the image)           |
+| `netbox.ui.attrs.NestedObjectAttr`       | A related nested object (includes ancestors)     |
+| `netbox.ui.attrs.NumericAttr`            | An integer or float value                        |
+| `netbox.ui.attrs.RelatedObjectAttr`      | A related object                                 |
+| `netbox.ui.attrs.RelatedObjectListAttr`  | A list of related objects                        |
+| `netbox.ui.attrs.TemplatedAttr`          | Renders an attribute using a custom template     |
+| `netbox.ui.attrs.TextAttr`               | A string (text) value                            |
+| `netbox.ui.attrs.TimezoneAttr`           | A timezone with annotated offset                 |
+| `netbox.ui.attrs.UtilizationAttr`        | A numeric value expressed as a utilization graph |
+
+::: netbox.ui.attrs.ObjectAttribute
+
+::: netbox.ui.attrs.AddressAttr
+
+::: netbox.ui.attrs.BooleanAttr
+
+::: netbox.ui.attrs.ChoiceAttr
+
+::: netbox.ui.attrs.ColorAttr
+
+::: netbox.ui.attrs.DateTimeAttr
+
+::: netbox.ui.attrs.GenericForeignKeyAttr
+
+::: netbox.ui.attrs.GPSCoordinatesAttr
+
+::: netbox.ui.attrs.ImageAttr
+
+::: netbox.ui.attrs.NestedObjectAttr
+
+::: netbox.ui.attrs.NumericAttr
+
+::: netbox.ui.attrs.RelatedObjectAttr
+
+::: netbox.ui.attrs.RelatedObjectListAttr
+
+::: netbox.ui.attrs.TemplatedAttr
+
+::: netbox.ui.attrs.TextAttr
+
+::: netbox.ui.attrs.TimezoneAttr
+
+::: netbox.ui.attrs.UtilizationAttr

+ 6 - 0
docs/plugins/development/webhooks.md

@@ -36,6 +36,7 @@ The resulting webhook payload will look like the following:
         "url": "/api/dcim/sites/2/",
         ...
     },
+    "request": {...},
     "snapshots": {...},
     "context": {
         "foo": 123
@@ -43,6 +44,11 @@ The resulting webhook payload will look like the following:
 }
 ```
 
+!!! warning "Deprecation of legacy keys"
+    The `request_id` and `username` keys in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
+
+    Use `request.user` and `request.id` from the `request` object included in the callback context instead.
+
 !!! note "Consider namespacing webhook data"
     The data returned from all webhook callbacks will be compiled into a single `context` dictionary. Any existing keys within this dictionary will be overwritten by subsequent callbacks which include those keys. To avoid collisions with webhook data provided by other plugins, consider namespacing your plugin's data within a nested dictionary as such:
     

+ 8 - 0
docs/release-notes/index.md

@@ -10,6 +10,14 @@ Minor releases are published in April, August, and December of each calendar yea
 
 This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
 
+#### [Version 4.6](./version-4.6.md) (May 2026)
+
+* Virtual Machine Types ([#5795](https://github.com/netbox-community/netbox/issues/5795))
+* Cable Bundles ([#20151](https://github.com/netbox-community/netbox/issues/20151))
+* Rack Groups ([#20961](https://github.com/netbox-community/netbox/issues/20961))
+* ETag Support for REST API ([#21356](https://github.com/netbox-community/netbox/issues/21356))
+* Cursor-based Pagination for REST API ([#21363](https://github.com/netbox-community/netbox/issues/21363))
+
 #### [Version 4.5](./version-4.5.md) (January 2026)
 
 * Lookup Modifiers in Filter Forms ([#7604](https://github.com/netbox-community/netbox/issues/7604))

+ 129 - 0
docs/release-notes/version-4.6.md

@@ -0,0 +1,129 @@
+# NetBox v4.6
+
+## v4.6.0-beta2 (2026-04-28)
+
+### New Features
+
+#### Virtual Machine Types ([#5795](https://github.com/netbox-community/netbox/issues/5795))
+
+A new VirtualMachineType model has been introduced to enable categorization of virtual machines by instance type, analogous to how DeviceType categorizes physical hardware. VM types can be defined once and reused across many virtual machines.
+
+#### Cable Bundles ([#20151](https://github.com/netbox-community/netbox/issues/20151))
+
+A new CableBundle model allows individual cables to be grouped together to represent physical cable runs that are managed as a unit; e.g. a bundle of 48 CAT6 cables between two patch panels. (Please note that this feature is _not_ suitable for modeling individual fiber strands within a single cable.)
+
+#### Rack Groups ([#20961](https://github.com/netbox-community/netbox/issues/20961))
+
+A flat RackGroup model has been reintroduced to provide a lightweight secondary axis of rack organization (e.g. by row or aisle) that is independent of the location hierarchy. Racks carry an optional foreign key to a RackGroup, and RackGroup can also serve as a scope for VLANGroup assignments.
+
+#### ETag Support for REST API ([#21356](https://github.com/netbox-community/netbox/issues/21356))
+
+The REST API now returns an `ETag` header on responses for individual objects, derived from the object's last-updated timestamp. Clients can supply an `If-Match` header on PUT/PATCH requests to guard against conflicting concurrent updates; if the object has been modified since the ETag was issued, the server returns a 412 (Precondition Failed) response.
+
+#### Cursor-based Pagination for REST API ([#21363](https://github.com/netbox-community/netbox/issues/21363))
+
+A new `start` query parameter has been introduced as an efficient alternative to the existing `offset` parameter for paginating large result sets. Rather than scanning the table up to a relative offset, the `start` parameter filters for objects with a primary key equal to or greater than the given value, enabling constant-time pagination regardless of result set size.
+
+### Enhancements
+
+* [#12024](https://github.com/netbox-community/netbox/issues/12024) - Permit virtual machines to be assigned to devices without a cluster
+* [#14329](https://github.com/netbox-community/netbox/issues/14329) - Improve diff highlighting for custom field data in change logs
+* [#15513](https://github.com/netbox-community/netbox/issues/15513) - Add bulk creation support for IP prefixes
+* [#17654](https://github.com/netbox-community/netbox/issues/17654) - Support role assignment for ASNs
+* [#19025](https://github.com/netbox-community/netbox/issues/19025) - Support optional schema validation for JSON custom fields
+* [#19034](https://github.com/netbox-community/netbox/issues/19034) - Annotate total reserved unit count on rack reservations
+* [#19138](https://github.com/netbox-community/netbox/issues/19138) - Include NAT addresses for primary & out-of-band IP addresses in REST API
+* [#19648](https://github.com/netbox-community/netbox/issues/19648) - Add a color custom field type
+* [#19796](https://github.com/netbox-community/netbox/issues/19796) - Support `{module}` position inheritance for nested module bays
+* [#19953](https://github.com/netbox-community/netbox/issues/19953) - Enable debugging support for ConfigTemplate rendering
+* [#20123](https://github.com/netbox-community/netbox/issues/20123) - Introduce options to control adoption/replication of device components via REST API (replicates UI behavior)
+* [#20152](https://github.com/netbox-community/netbox/issues/20152) - Support for marking module and device bays as disabled
+* [#20162](https://github.com/netbox-community/netbox/issues/20162) - Provide an option to execute as a background job when adding components to devices in bulk
+* [#20163](https://github.com/netbox-community/netbox/issues/20163) - Add changelog message support for bulk device component creation
+* [#20698](https://github.com/netbox-community/netbox/issues/20698) - Add read-only `total_vlan_ids` attribute on VLAN group representation in REST & GraphQL APIs
+* [#20916](https://github.com/netbox-community/netbox/issues/20916) - Include stack trace for unhandled exceptions in job logs
+* [#21157](https://github.com/netbox-community/netbox/issues/21157) - Include all public model classes in export template context
+* [#21409](https://github.com/netbox-community/netbox/issues/21409) - Introduce `CHANGELOG_RETAIN_CREATE_LAST_UPDATE` configuration parameter to retain creation & most recent update record in change log for each object
+* [#21575](https://github.com/netbox-community/netbox/issues/21575) - Introduce `{vc_position}` template variable for device component template name/label
+* [#21662](https://github.com/netbox-community/netbox/issues/21662) - Increase `rf_channel_frequency` precision to 3 decimal places
+* [#21702](https://github.com/netbox-community/netbox/issues/21702) - Include a serialized representation of the HTTP request in each webhook
+* [#21720](https://github.com/netbox-community/netbox/issues/21720) - Align HTTP basic auth regex of `EnhancedURLValidator` with Django's `URLValidator`
+* [#21751](https://github.com/netbox-community/netbox/issues/21751) - Disable notifications for scripts running in the background
+* [#21770](https://github.com/netbox-community/netbox/issues/21770) - Enable specifying columns to include/exclude on embedded tables
+* [#21771](https://github.com/netbox-community/netbox/issues/21771) - Add support for partial tag assignment (`add_tags`) and removal (`remove_tags`) via REST API
+* [#21780](https://github.com/netbox-community/netbox/issues/21780) - Add changelog message support to bulk creation of IP addresses
+* [#21865](https://github.com/netbox-community/netbox/issues/21865) - Allow setting empty `INTERNAL_IPS` to enable debug toolbar for all clients
+* [#21924](https://github.com/netbox-community/netbox/issues/21924) - Improve styling and consistency of floating bulk action controls
+
+### Performance Improvements
+
+* [#21455](https://github.com/netbox-community/netbox/issues/21455) - Ensure PostgreSQL indexes exist to support the default ordering of each model
+* [#21688](https://github.com/netbox-community/netbox/issues/21688) - Reduce per-position ORM lookups when tracing cable paths
+* [#21788](https://github.com/netbox-community/netbox/issues/21788) - Optimize bulk object export to avoid timeout errors on large querysets
+
+### Plugins
+
+* [#20924](https://github.com/netbox-community/netbox/issues/20924) - Introduce support for declarative layouts and reusable UI components
+* [#21357](https://github.com/netbox-community/netbox/issues/21357) - Provide an API for plugins to register custom model actions (for permission assignment)
+
+### Deprecations
+
+* [#21284](https://github.com/netbox-community/netbox/issues/21284) - Deprecate the `username` and `request_id` fields in event data
+* [#21304](https://github.com/netbox-community/netbox/issues/21304) - Deprecate the `housekeeping` management command
+* [#21331](https://github.com/netbox-community/netbox/issues/21331) - Deprecate NetBox's custom `querystring` template tag
+* [#21881](https://github.com/netbox-community/netbox/issues/21881) - Deprecate legacy Sentry configuration parameters
+* [#21884](https://github.com/netbox-community/netbox/issues/21884) - Deprecate the obsolete `DEFAULT_ACTION_PERMISSIONS` mapping
+* [#21887](https://github.com/netbox-community/netbox/issues/21887) - Deprecate support for legacy view actions
+* [#21890](https://github.com/netbox-community/netbox/issues/21890) - Deprecate `models` key in application registry
+* [#21936](https://github.com/netbox-community/netbox/issues/21936) - Deprecate the `LOGIN_REQUIRED` configuration parameter
+
+### Other Changes
+
+* [#20984](https://github.com/netbox-community/netbox/issues/20984) - Upgrade to Django 6.0
+* [#21635](https://github.com/netbox-community/netbox/issues/21635) - Migrate documentation site from mkdocs to Zensical
+
+### REST API Changes
+
+* New features:
+    * `ETag` response header and `If-Match` request header support for all individual object endpoints
+    * `start` query parameter for cursor-based pagination on all list endpoints
+    * `add_tags` and `remove_tags` write-only fields on all taggable model serializers
+* New endpoints:
+    * `GET/POST /api/dcim/cable-bundles/`
+    * `GET/PUT/PATCH/DELETE /api/dcim/cable-bundles/<id>/`
+    * `GET/POST /api/dcim/rack-groups/`
+    * `GET/PUT/PATCH/DELETE /api/dcim/rack-groups/<id>/`
+    * `GET/POST /api/virtualization/virtual-machine-types/`
+    * `GET/PUT/PATCH/DELETE /api/virtualization/virtual-machine-types/<id>/`
+* `dcim.Cable`
+    * Add optional foreign key field `bundle`
+* `dcim.Device`
+    * The `primary_ip`, `primary_ip4`, `primary_ip6`, and `oob_ip` nested representations now include `nat_inside` and `nat_outside`
+* `dcim.DeviceBay`
+    * Add boolean field `enabled`
+    * Add read-only boolean field `_occupied`
+* `dcim.DeviceBayTemplate`
+    * Add boolean field `enabled`
+* `dcim.Module`
+    * Add write-only boolean fields `replicate_components` and `adopt_components`
+* `dcim.ModuleBay`
+    * Add boolean field `enabled`
+    * Add read-only boolean field `_occupied`
+* `dcim.ModuleBayTemplate`
+    * Add boolean field `enabled`
+* `dcim.Rack`
+    * Add optional foreign key field `group`
+* `dcim.RackReservation`
+    * Add read-only integer field `unit_count`
+* `extras.CustomField`
+    * Add JSON field `validation_schema`
+* `ipam.ASN`
+    * Add optional foreign key field `role`
+* `ipam.Role`
+    * Annotate count of assigned ASNs (`asn_count`)
+* `ipam.VLANGroup`
+    * Add read-only field `total_vlan_ids`
+* `virtualization.VirtualMachine`
+    * Add optional foreign key field `virtual_machine_type`
+    * The `primary_ip`, `primary_ip4`, and `primary_ip6` nested representations now include `nat_inside` and `nat_outside`
+    * The `cluster` field is now optional (nullable)

+ 6 - 0
mkdocs.yml

@@ -1,3 +1,4 @@
+# Note: NetBox has migrated from MkDocs to Zensical
 site_name: NetBox Documentation
 site_dir: netbox/project-static/docs
 site_url: https://docs.netbox.dev/
@@ -151,6 +152,7 @@ nav:
             - Filters & Filter Sets: 'plugins/development/filtersets.md'
             - Search: 'plugins/development/search.md'
             - Event Types: 'plugins/development/event-types.md'
+            - Permissions: 'plugins/development/permissions.md'
             - Data Backends: 'plugins/development/data-backends.md'
             - Webhooks: 'plugins/development/webhooks.md'
             - User Interface: 'plugins/development/user-interface.md'
@@ -189,6 +191,7 @@ nav:
             - Job: 'models/core/job.md'
         - DCIM:
             - Cable: 'models/dcim/cable.md'
+            - CableBundle: 'models/dcim/cablebundle.md'
             - ConsolePort: 'models/dcim/consoleport.md'
             - ConsolePortTemplate: 'models/dcim/consoleporttemplate.md'
             - ConsoleServerPort: 'models/dcim/consoleserverport.md'
@@ -221,6 +224,7 @@ nav:
             - PowerPort: 'models/dcim/powerport.md'
             - PowerPortTemplate: 'models/dcim/powerporttemplate.md'
             - Rack: 'models/dcim/rack.md'
+            - RackGroup: 'models/dcim/rackgroup.md'
             - RackReservation: 'models/dcim/rackreservation.md'
             - RackRole: 'models/dcim/rackrole.md'
             - RackType: 'models/dcim/racktype.md'
@@ -285,6 +289,7 @@ nav:
             - VMInterface: 'models/virtualization/vminterface.md'
             - VirtualDisk: 'models/virtualization/virtualdisk.md'
             - VirtualMachine: 'models/virtualization/virtualmachine.md'
+            - VirtualMachineType: 'models/virtualization/virtualmachinetype.md'
         - VPN:
             - IKEPolicy: 'models/vpn/ikepolicy.md'
             - IKEProposal: 'models/vpn/ikeproposal.md'
@@ -322,6 +327,7 @@ nav:
         - git Cheat Sheet: 'development/git-cheat-sheet.md'
     - Release Notes:
         - Summary: 'release-notes/index.md'
+        - Version 4.6: 'release-notes/version-4.6.md'
         - Version 4.5: 'release-notes/version-4.5.md'
         - Version 4.4: 'release-notes/version-4.4.md'
         - Version 4.3: 'release-notes/version-4.3.md'

+ 10 - 0
netbox/account/views.py

@@ -356,9 +356,16 @@ class UserTokenView(LoginRequiredMixin, View):
     def get(self, request, pk):
         token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
 
+        # Pop a one-time plaintext value (set by UserTokenEditView.post_save when a token is first created) and
+        # assemble the full HTTP authorization string for display. The plaintext is never persisted; popping
+        # ensures the banner only renders once.
+        plaintext = request.session.pop(f'_token_plaintext_{token.pk}', None)
+        token_auth_string = f'{token.get_auth_header_prefix()}{plaintext}' if plaintext else None
+
         return render(request, 'account/token.html', {
             'object': token,
             'layout': self.layout,
+            'token_auth_string': token_auth_string,
         })
 
 
@@ -366,11 +373,14 @@ class UserTokenView(LoginRequiredMixin, View):
 class UserTokenEditView(generic.ObjectEditView):
     queryset = UserToken.objects.all()
     form = forms.UserTokenForm
+    template_name = 'account/usertoken_edit.html'
     default_return_url = 'account:usertoken_list'
 
     def alter_object(self, obj, request, url_args, url_kwargs):
         if not obj.pk:
             obj.user = request.user
+        # Attach the request so that UserTokenForm.save() can stash the newly-generated plaintext on the session.
+        obj._request = request
         return obj
 
 

+ 35 - 0
netbox/circuits/migrations/0057_default_ordering_indexes.py

@@ -0,0 +1,35 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('circuits', '0056_gfk_indexes'),
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('dcim', '0231_interface_rf_channel_frequency_precision'),
+        ('extras', '0136_customfield_validation_schema'),
+        ('tenancy', '0023_add_mptt_tree_indexes'),
+        ('users', '0015_owner'),
+    ]
+
+    operations = [
+        migrations.AddIndex(
+            model_name='circuit',
+            index=models.Index(fields=['provider', 'provider_account', 'cid'], name='circuits_ci_provide_a0c42c_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='circuitgroupassignment',
+            index=models.Index(
+                fields=['group', 'member_type', 'member_id', 'priority', 'id'], name='circuits_ci_group_i_2f8327_idx'
+            ),
+        ),
+        migrations.AddIndex(
+            model_name='virtualcircuit',
+            index=models.Index(
+                fields=['provider_network', 'provider_account', 'cid'], name='circuits_vi_provide_989efa_idx'
+            ),
+        ),
+        migrations.AddIndex(
+            model_name='virtualcircuittermination',
+            index=models.Index(fields=['virtual_circuit', 'role', 'id'], name='circuits_vi_virtual_4b5c0c_idx'),
+        ),
+    ]

+ 6 - 0
netbox/circuits/models/circuits.py

@@ -144,6 +144,9 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, DistanceMixin, PrimaryModel)
                 name='%(app_label)s_%(class)s_unique_provideraccount_cid'
             ),
         )
+        indexes = (
+            models.Index(fields=('provider', 'provider_account', 'cid')),  # Default ordering
+        )
         verbose_name = _('circuit')
         verbose_name_plural = _('circuits')
 
@@ -221,6 +224,9 @@ class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin,
                 name='%(app_label)s_%(class)s_unique_member_group'
             ),
         )
+        indexes = (
+            models.Index(fields=('group', 'member_type', 'member_id', 'priority', 'id')),  # Default ordering
+        )
         verbose_name = _('Circuit group assignment')
         verbose_name_plural = _('Circuit group assignments')
 

+ 6 - 0
netbox/circuits/models/virtual_circuits.py

@@ -97,6 +97,9 @@ class VirtualCircuit(ContactsMixin, PrimaryModel):
                 name='%(app_label)s_%(class)s_unique_provideraccount_cid'
             ),
         )
+        indexes = (
+            models.Index(fields=('provider_network', 'provider_account', 'cid')),  # Default ordering
+        )
         verbose_name = _('virtual circuit')
         verbose_name_plural = _('virtual circuits')
 
@@ -150,6 +153,9 @@ class VirtualCircuitTermination(
 
     class Meta:
         ordering = ['virtual_circuit', 'role', 'pk']
+        indexes = (
+            models.Index(fields=('virtual_circuit', 'role', 'id')),  # Default ordering
+        )
         verbose_name = _('virtual circuit termination')
         verbose_name_plural = _('virtual circuit terminations')
 

+ 23 - 7
netbox/circuits/ui/panels.py

@@ -13,13 +13,9 @@ class CircuitCircuitTerminationPanel(panels.ObjectPanel):
     template_name = 'circuits/panels/circuit_circuit_termination.html'
     title = _('Termination')
 
-    def __init__(self, accessor=None, side=None, **kwargs):
-        super().__init__(**kwargs)
-
-        if accessor is not None:
-            self.accessor = accessor
-        if side is not None:
-            self.side = side
+    def __init__(self, side, accessor=None, **kwargs):
+        super().__init__(accessor=accessor, **kwargs)
+        self.side = side
 
     def get_context(self, context):
         return {
@@ -58,6 +54,26 @@ class CircuitGroupAssignmentsPanel(panels.ObjectsTablePanel):
         )
 
 
+class CircuitTerminationPanel(panels.ObjectAttributesPanel):
+    title = _('Circuit Termination')
+    circuit = attrs.RelatedObjectAttr('circuit', linkify=True)
+    provider = attrs.RelatedObjectAttr('circuit.provider', linkify=True)
+    termination = attrs.GenericForeignKeyAttr('termination', linkify=True, label=_('Termination point'))
+    connection = attrs.TemplatedAttr(
+        'pk',
+        template_name='circuits/circuit_termination/attrs/connection.html',
+        label=_('Connection'),
+    )
+    speed = attrs.TemplatedAttr(
+        'port_speed',
+        template_name='circuits/circuit_termination/attrs/speed.html',
+        label=_('Speed'),
+    )
+    xconnect_id = attrs.TextAttr('xconnect_id', label=_('Cross-Connect'), style='font-monospace')
+    pp_info = attrs.TextAttr('pp_info', label=_('Patch Panel/Port'))
+    description = attrs.TextAttr('description')
+
+
 class CircuitGroupPanel(panels.OrganizationalObjectPanel):
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
 

+ 6 - 5
netbox/circuits/views.py

@@ -8,7 +8,6 @@ from netbox.ui import actions, layout
 from netbox.ui.panels import (
     CommentsPanel,
     ObjectsTablePanel,
-    Panel,
     RelatedObjectsPanel,
 )
 from netbox.views import generic
@@ -53,6 +52,7 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
             ObjectsTablePanel(
                 model='circuits.ProviderAccount',
                 filters={'provider_id': lambda ctx: ctx['object'].pk},
+                exclude_columns=['provider'],
                 actions=[
                     actions.AddObject(
                         'circuits.ProviderAccount', url_params={'provider': lambda ctx: ctx['object'].pk}
@@ -62,6 +62,7 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
             ObjectsTablePanel(
                 model='circuits.Circuit',
                 filters={'provider_id': lambda ctx: ctx['object'].pk},
+                exclude_columns=['provider'],
                 actions=[
                     actions.AddObject('circuits.Circuit', url_params={'provider': lambda ctx: ctx['object'].pk}),
                 ],
@@ -161,6 +162,7 @@ class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
             ObjectsTablePanel(
                 model='circuits.Circuit',
                 filters={'provider_account_id': lambda ctx: ctx['object'].pk},
+                exclude_columns=['provider_account'],
                 actions=[
                     actions.AddObject(
                         'circuits.Circuit',
@@ -257,6 +259,7 @@ class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
             ObjectsTablePanel(
                 model='circuits.VirtualCircuit',
                 filters={'provider_network_id': lambda ctx: ctx['object'].pk},
+                exclude_columns=['provider_network'],
                 actions=[
                     actions.AddObject(
                         'circuits.VirtualCircuit', url_params={'provider_network': lambda ctx: ctx['object'].pk}
@@ -508,10 +511,7 @@ class CircuitTerminationView(generic.ObjectView):
     queryset = CircuitTermination.objects.all()
     layout = layout.SimpleLayout(
         left_panels=[
-            Panel(
-                template_name='circuits/panels/circuit_termination.html',
-                title=_('Circuit Termination'),
-            )
+            panels.CircuitTerminationPanel(),
         ],
         right_panels=[
             CustomFieldsPanel(),
@@ -801,6 +801,7 @@ class VirtualCircuitView(generic.ObjectView):
                 model='circuits.VirtualCircuitTermination',
                 title=_('Terminations'),
                 filters={'virtual_circuit_id': lambda ctx: ctx['object'].pk},
+                exclude_columns=['virtual_circuit'],
                 actions=[
                     actions.AddObject(
                         'circuits.VirtualCircuitTermination',

+ 2 - 1
netbox/core/api/serializers_/jobs.py

@@ -26,13 +26,14 @@ class JobSerializer(BaseModelSerializer):
     object = serializers.SerializerMethodField(
         read_only=True
     )
+    notifications = ChoiceField(choices=JobNotificationChoices, read_only=True)
 
     class Meta:
         model = Job
         fields = [
             'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
             'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'queue_name',
-            'log_entries',
+            'notifications', 'log_entries',
         ]
         brief_fields = ('url', 'created', 'completed', 'user', 'status')
 

+ 12 - 0
netbox/core/choices.py

@@ -72,6 +72,18 @@ class JobStatusChoices(ChoiceSet):
     )
 
 
+class JobNotificationChoices(ChoiceSet):
+    NOTIFICATION_ALWAYS = 'always'
+    NOTIFICATION_ON_FAILURE = 'on_failure'
+    NOTIFICATION_NEVER = 'never'
+
+    CHOICES = (
+        (NOTIFICATION_ALWAYS, _('Always')),
+        (NOTIFICATION_ON_FAILURE, _('On failure')),
+        (NOTIFICATION_NEVER, _('Never')),
+    )
+
+
 class JobIntervalChoices(ChoiceSet):
     INTERVAL_MINUTELY = 1
     INTERVAL_HOURLY = 60

+ 3 - 2
netbox/core/forms/model_forms.py

@@ -165,9 +165,10 @@ class ConfigRevisionForm(forms.ModelForm, metaclass=ConfigFormMetaclass):
         FieldSet('PAGINATE_COUNT', 'MAX_PAGE_SIZE', name=_('Pagination')),
         FieldSet('CUSTOM_VALIDATORS', 'PROTECTION_RULES', name=_('Validation')),
         FieldSet('DEFAULT_USER_PREFERENCES', name=_('User Preferences')),
+        FieldSet('CHANGELOG_RETENTION', 'CHANGELOG_RETAIN_CREATE_LAST_UPDATE', name=_('Change Log')),
         FieldSet(
-            'MAINTENANCE_MODE', 'COPILOT_ENABLED', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION',
-            'MAPS_URL', name=_('Miscellaneous'),
+            'MAINTENANCE_MODE', 'COPILOT_ENABLED', 'GRAPHQL_ENABLED', 'JOB_RETENTION', 'MAPS_URL',
+            name=_('Miscellaneous'),
         ),
         FieldSet('comment', name=_('Config Revision'))
     )

+ 41 - 8
netbox/core/jobs.py

@@ -5,6 +5,7 @@ from importlib import import_module
 import requests
 from django.conf import settings
 from django.core.cache import cache
+from django.db.models import Exists, OuterRef, Subquery
 from django.utils import timezone
 from packaging import version
 
@@ -14,7 +15,7 @@ from netbox.jobs import JobRunner, system_job
 from netbox.search.backends import search_backend
 from utilities.proxy import resolve_proxies
 
-from .choices import DataSourceStatusChoices, JobIntervalChoices
+from .choices import DataSourceStatusChoices, JobIntervalChoices, ObjectChangeActionChoices
 from .models import DataSource
 
 
@@ -126,19 +127,51 @@ class SystemHousekeepingJob(JobRunner):
         """
         Delete any ObjectChange records older than the configured changelog retention time (if any).
         """
-        self.logger.info("Pruning old changelog entries...")
+        self.logger.info('Pruning old changelog entries...')
         config = Config()
         if not config.CHANGELOG_RETENTION:
-            self.logger.info("No retention period specified; skipping.")
+            self.logger.info('No retention period specified; skipping.')
             return
 
         cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION)
-        self.logger.debug(
-            f"Changelog retention period: {config.CHANGELOG_RETENTION} days ({cutoff:%Y-%m-%d %H:%M:%S})"
-        )
+        self.logger.debug(f'Changelog retention period: {config.CHANGELOG_RETENTION} days ({cutoff:%Y-%m-%d %H:%M:%S})')
+
+        expired_qs = ObjectChange.objects.filter(time__lt=cutoff)
+
+        # When enabled, retain each object's original create record and most recent update record while pruning expired
+        # changelog entries. This applies only to objects without a delete record.
+        if config.CHANGELOG_RETAIN_CREATE_LAST_UPDATE:
+            self.logger.debug('Retaining changelog create records and last update records (excluding deleted objects)')
+
+            deleted_exists = ObjectChange.objects.filter(
+                action=ObjectChangeActionChoices.ACTION_DELETE,
+                changed_object_type_id=OuterRef('changed_object_type_id'),
+                changed_object_id=OuterRef('changed_object_id'),
+            )
+
+            # Keep create records only where no delete exists for that object
+            create_pks_to_keep = (
+                ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_CREATE)
+                .annotate(has_delete=Exists(deleted_exists))
+                .filter(has_delete=False)
+                .values('pk')
+            )
+
+            # Keep the most recent update per object only where no delete exists for the object
+            latest_update_pks_to_keep = (
+                ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_UPDATE)
+                .annotate(has_delete=Exists(deleted_exists))
+                .filter(has_delete=False)
+                .order_by('changed_object_type_id', 'changed_object_id', '-time', '-pk')
+                .distinct('changed_object_type_id', 'changed_object_id')
+                .values('pk')
+            )
+
+            expired_qs = expired_qs.exclude(pk__in=Subquery(create_pks_to_keep))
+            expired_qs = expired_qs.exclude(pk__in=Subquery(latest_update_pks_to_keep))
 
-        count = ObjectChange.objects.filter(time__lt=cutoff).delete()[0]
-        self.logger.info(f"Deleted {count} expired changelog records")
+        count = expired_qs.delete()[0]
+        self.logger.info(f'Deleted {count} expired changelog records')
 
     def delete_expired_jobs(self):
         """

+ 21 - 0
netbox/core/migrations/0022_default_ordering_indexes.py

@@ -0,0 +1,21 @@
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('core', '0021_job_queue_name'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.AddIndex(
+            model_name='configrevision',
+            index=models.Index(fields=['-created'], name='core_config_created_ef9552_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='job',
+            index=models.Index(fields=['-created'], name='core_job_created_efa7cb_idx'),
+        ),
+    ]

+ 15 - 0
netbox/core/migrations/0023_datasource_sync_permission.py

@@ -0,0 +1,15 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0022_default_ordering_indexes'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='datasource',
+            options={'ordering': ('name',), 'permissions': [('sync', 'Synchronize data from remote source')]},
+        ),
+    ]

+ 16 - 0
netbox/core/migrations/0024_job_notifications.py

@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0023_datasource_sync_permission'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='job',
+            name='notifications',
+            field=models.CharField(default='always', max_length=30),
+        ),
+    ]

+ 13 - 13
netbox/core/models/change_logging.py

@@ -11,7 +11,7 @@ from mptt.models import MPTTModel
 from core.choices import ObjectChangeActionChoices
 from core.querysets import ObjectChangeQuerySet
 from netbox.models.features import ChangeLoggingMixin, has_feature
-from utilities.data import shallow_compare_dict
+from utilities.data import deep_compare_dict
 
 __all__ = (
     'ObjectChange',
@@ -199,18 +199,18 @@ class ObjectChange(models.Model):
         # Determine which attributes have changed
         if self.action == ObjectChangeActionChoices.ACTION_CREATE:
             changed_attrs = sorted(postchange_data.keys())
-        elif self.action == ObjectChangeActionChoices.ACTION_DELETE:
+            return {
+                'pre': {k: prechange_data.get(k) for k in changed_attrs},
+                'post': {k: postchange_data.get(k) for k in changed_attrs},
+            }
+        if self.action == ObjectChangeActionChoices.ACTION_DELETE:
             changed_attrs = sorted(prechange_data.keys())
-        else:
-            # TODO: Support deep (recursive) comparison
-            changed_data = shallow_compare_dict(prechange_data, postchange_data)
-            changed_attrs = sorted(changed_data.keys())
-
+            return {
+                'pre': {k: prechange_data.get(k) for k in changed_attrs},
+                'post': {k: postchange_data.get(k) for k in changed_attrs},
+            }
+        diff_added, diff_removed = deep_compare_dict(prechange_data, postchange_data)
         return {
-            'pre': {
-                k: prechange_data.get(k) for k in changed_attrs
-            },
-            'post': {
-                k: postchange_data.get(k) for k in changed_attrs
-            },
+            'pre': dict(sorted(diff_removed.items())),
+            'post': dict(sorted(diff_added.items())),
         }

+ 3 - 0
netbox/core/models/config.py

@@ -37,6 +37,9 @@ class ConfigRevision(models.Model):
 
     class Meta:
         ordering = ['-created']
+        indexes = (
+            models.Index(fields=('-created',)),  # Default ordering
+        )
         verbose_name = _('config revision')
         verbose_name_plural = _('config revisions')
         constraints = [

+ 3 - 0
netbox/core/models/data.py

@@ -87,6 +87,9 @@ class DataSource(JobsMixin, PrimaryModel):
         ordering = ('name',)
         verbose_name = _('data source')
         verbose_name_plural = _('data sources')
+        permissions = [
+            ('sync', 'Synchronize data from remote source'),
+        ]
 
     def __str__(self):
         return f'{self.name}'

+ 22 - 8
netbox/core/models/jobs.py

@@ -16,7 +16,7 @@ from django.utils import timezone
 from django.utils.translation import gettext as _
 from rq.exceptions import InvalidJobOperation
 
-from core.choices import JobStatusChoices
+from core.choices import JobNotificationChoices, JobStatusChoices
 from core.dataclasses import JobLogEntry
 from core.events import JOB_COMPLETED, JOB_ERRORED, JOB_FAILED
 from core.models import ObjectType
@@ -118,6 +118,12 @@ class Job(models.Model):
         blank=True,
         help_text=_('Name of the queue in which this job was enqueued')
     )
+    notifications = models.CharField(
+        verbose_name=_('notifications'),
+        max_length=30,
+        choices=JobNotificationChoices,
+        default=JobNotificationChoices.NOTIFICATION_ALWAYS
+    )
     log_entries = ArrayField(
         verbose_name=_('log entries'),
         base_field=models.JSONField(
@@ -133,6 +139,7 @@ class Job(models.Model):
     class Meta:
         ordering = ['-created']
         indexes = (
+            models.Index(fields=('-created',)),  # Default ordering
             models.Index(fields=('object_type', 'object_id')),
         )
         verbose_name = _('job')
@@ -237,12 +244,16 @@ class Job(models.Model):
         self.save()
 
         # Notify the user (if any) of completion
-        if self.user:
-            Notification(
-                user=self.user,
-                object=self,
-                event_type=self.get_event_type(),
-            ).save()
+        if self.user and self.notifications != JobNotificationChoices.NOTIFICATION_NEVER:
+            if (
+                self.notifications == JobNotificationChoices.NOTIFICATION_ALWAYS or
+                status != JobStatusChoices.STATUS_COMPLETED
+            ):
+                Notification(
+                    user=self.user,
+                    object=self,
+                    event_type=self.get_event_type(),
+                ).save()
 
         # Send signal
         job_end.send(self)
@@ -266,6 +277,7 @@ class Job(models.Model):
             interval=None,
             immediate=False,
             queue_name=None,
+            notifications=None,
             **kwargs
     ):
         """
@@ -280,6 +292,7 @@ class Job(models.Model):
             interval: Recurrence interval (in minutes)
             immediate: Run the job immediately without scheduling it in the background. Should be used for interactive
                 management commands only.
+            notifications: Notification behavior on job completion (always, on_failure, or never)
         """
         if schedule_at and immediate:
             raise ValueError(_("enqueue() cannot be called with values for both schedule_at and immediate."))
@@ -301,7 +314,8 @@ class Job(models.Model):
             interval=interval,
             user=user,
             job_id=uuid.uuid4(),
-            queue_name=rq_queue_name
+            queue_name=rq_queue_name,
+            notifications=notifications if notifications is not None else JobNotificationChoices.NOTIFICATION_ALWAYS
         )
         job.full_clean()
         job.save()

+ 0 - 1
netbox/core/models/object_types.py

@@ -51,7 +51,6 @@ class ObjectTypeManager(models.Manager):
         """
         return self.get(app_label=app_label, model=model)
 
-    # TODO: Remove in NetBox v4.5
     def get_for_id(self, id):
         """
         Retrieve an ObjectType by its primary key (numeric ID).

+ 8 - 0
netbox/core/tables/jobs.py

@@ -1,4 +1,6 @@
 import django_tables2 as tables
+from django.utils.html import conditional_escape
+from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 
 from core.constants import JOB_LOG_ENTRY_LEVELS
@@ -82,3 +84,9 @@ class JobLogEntryTable(BaseTable):
     class Meta(BaseTable.Meta):
         empty_text = _('No log entries')
         fields = ('timestamp', 'level', 'message')
+
+    def render_message(self, record, value):
+        if record.get('level') == 'error' and '\n' in value:
+            value = conditional_escape(value)
+            return mark_safe(f'<pre class="p-0">{value}</pre>')
+        return value

+ 104 - 1
netbox/core/tests/test_changelog.py

@@ -1,9 +1,16 @@
+import logging
+import uuid
+from datetime import timedelta
+from unittest.mock import patch
+
 from django.contrib.contenttypes.models import ContentType
-from django.test import override_settings
+from django.test import TestCase, override_settings
 from django.urls import reverse
+from django.utils import timezone
 from rest_framework import status
 
 from core.choices import ObjectChangeActionChoices
+from core.jobs import SystemHousekeepingJob
 from core.models import ObjectChange, ObjectType
 from dcim.choices import InterfaceTypeChoices, ModuleStatusChoices, SiteStatusChoices
 from dcim.models import (
@@ -694,3 +701,99 @@ class ChangeLogAPITest(APITestCase):
         self.assertEqual(changes[3].changed_object_type, ContentType.objects.get_for_model(Module))
         self.assertEqual(changes[3].changed_object_id, module.pk)
         self.assertEqual(changes[3].action, ObjectChangeActionChoices.ACTION_DELETE)
+
+
+class ChangelogPruneRetentionTest(TestCase):
+    """Test suite for Changelog pruning retention settings."""
+
+    @staticmethod
+    def _make_oc(*, ct, obj_id, action, ts):
+        oc = ObjectChange.objects.create(
+            changed_object_type=ct,
+            changed_object_id=obj_id,
+            action=action,
+            user_name='test',
+            request_id=uuid.uuid4(),
+            object_repr=f'Object {obj_id}',
+        )
+        ObjectChange.objects.filter(pk=oc.pk).update(time=ts)
+        return oc.pk
+
+    @staticmethod
+    def _run_prune(*, retention_days, retain_create_last_update):
+        job = SystemHousekeepingJob.__new__(SystemHousekeepingJob)
+        job.logger = logging.getLogger('netbox.tests.changelog_prune')
+
+        with patch('core.jobs.Config') as MockConfig:
+            cfg = MockConfig.return_value
+            cfg.CHANGELOG_RETENTION = retention_days
+            cfg.CHANGELOG_RETAIN_CREATE_LAST_UPDATE = retain_create_last_update
+            job.prune_changelog()
+
+    def test_prune_retain_create_last_update_excludes_deleted_objects(self):
+        ct = ContentType.objects.get_for_model(Site)
+
+        retention_days = 90
+        now = timezone.now()
+        cutoff = now - timedelta(days=retention_days)
+
+        expired_old = cutoff - timedelta(days=10)
+        expired_newer = cutoff - timedelta(days=1)
+        not_expired = cutoff + timedelta(days=1)
+
+        # A) Not deleted: should keep CREATE + latest UPDATE, prune intermediate UPDATEs
+        a_create = self._make_oc(ct=ct, obj_id=1, action=ObjectChangeActionChoices.ACTION_CREATE, ts=expired_old)
+        a_update1 = self._make_oc(ct=ct, obj_id=1, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired_old)
+        a_update2 = self._make_oc(ct=ct, obj_id=1, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired_newer)
+
+        # B) Deleted (all expired): should keep NOTHING
+        b_create = self._make_oc(ct=ct, obj_id=2, action=ObjectChangeActionChoices.ACTION_CREATE, ts=expired_old)
+        b_update = self._make_oc(ct=ct, obj_id=2, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired_newer)
+        b_delete = self._make_oc(ct=ct, obj_id=2, action=ObjectChangeActionChoices.ACTION_DELETE, ts=expired_newer)
+
+        # C) Deleted but delete is not expired: create/update expired should be pruned; delete remains
+        c_create = self._make_oc(ct=ct, obj_id=3, action=ObjectChangeActionChoices.ACTION_CREATE, ts=expired_old)
+        c_update = self._make_oc(ct=ct, obj_id=3, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired_newer)
+        c_delete = self._make_oc(ct=ct, obj_id=3, action=ObjectChangeActionChoices.ACTION_DELETE, ts=not_expired)
+
+        self._run_prune(retention_days=retention_days, retain_create_last_update=True)
+
+        remaining = set(ObjectChange.objects.values_list('pk', flat=True))
+
+        # A) Not deleted -> create + latest update remain
+        self.assertIn(a_create, remaining)
+        self.assertIn(a_update2, remaining)
+        self.assertNotIn(a_update1, remaining)
+
+        # B) Deleted (all expired) -> nothing remains
+        self.assertNotIn(b_create, remaining)
+        self.assertNotIn(b_update, remaining)
+        self.assertNotIn(b_delete, remaining)
+
+        # C) Deleted, delete not expired -> delete remains, but create/update are pruned
+        self.assertNotIn(c_create, remaining)
+        self.assertNotIn(c_update, remaining)
+        self.assertIn(c_delete, remaining)
+
+    def test_prune_disabled_deletes_all_expired(self):
+        ct = ContentType.objects.get_for_model(Site)
+
+        retention_days = 90
+        now = timezone.now()
+        cutoff = now - timedelta(days=retention_days)
+        expired = cutoff - timedelta(days=1)
+        not_expired = cutoff + timedelta(days=1)
+
+        # expired create/update should be deleted when feature disabled
+        x_create = self._make_oc(ct=ct, obj_id=10, action=ObjectChangeActionChoices.ACTION_CREATE, ts=expired)
+        x_update = self._make_oc(ct=ct, obj_id=10, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired)
+
+        # non-expired delete should remain regardless
+        y_delete = self._make_oc(ct=ct, obj_id=11, action=ObjectChangeActionChoices.ACTION_DELETE, ts=not_expired)
+
+        self._run_prune(retention_days=retention_days, retain_create_last_update=False)
+
+        remaining = set(ObjectChange.objects.values_list('pk', flat=True))
+        self.assertNotIn(x_create, remaining)
+        self.assertNotIn(x_update, remaining)
+        self.assertIn(y_delete, remaining)

+ 88 - 1
netbox/core/tests/test_models.py

@@ -1,13 +1,16 @@
+import uuid
 from unittest.mock import MagicMock, patch
 
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.test import TestCase
 
-from core.choices import ObjectChangeActionChoices
+from core.choices import JobNotificationChoices, JobStatusChoices, ObjectChangeActionChoices
 from core.models import DataSource, Job, ObjectType
 from dcim.models import Device, Location, Site
+from extras.models import Notification
 from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
+from users.models import User
 
 
 class DataSourceIgnoreRulesTestCase(TestCase):
@@ -226,6 +229,18 @@ class ObjectTypeTest(TestCase):
 
 class JobTest(TestCase):
 
+    def _make_job(self, user, notifications):
+        """
+        Create and return a persisted Job with the given user and notifications setting.
+        """
+        return Job.objects.create(
+            name='Test Job',
+            job_id=uuid.uuid4(),
+            user=user,
+            notifications=notifications,
+            status=JobStatusChoices.STATUS_RUNNING,
+        )
+
     @patch('core.models.jobs.django_rq.get_queue')
     def test_delete_cancels_job_from_correct_queue(self, mock_get_queue):
         """
@@ -257,3 +272,75 @@ class JobTest(TestCase):
         mock_get_queue.assert_called_with(custom_queue)
         mock_queue.fetch_job.assert_called_with(str(job.job_id))
         mock_rq_job.cancel.assert_called_once()
+
+    @patch('core.models.jobs.job_end')
+    def test_terminate_notification_always(self, mock_job_end):
+        """
+        With notifications=always, a Notification should be created for every
+        terminal status (completed, failed, errored).
+        """
+        user = User.objects.create_user(username='notification-always')
+
+        for status in (
+            JobStatusChoices.STATUS_COMPLETED,
+            JobStatusChoices.STATUS_FAILED,
+            JobStatusChoices.STATUS_ERRORED,
+        ):
+            with self.subTest(status=status):
+                job = self._make_job(user, JobNotificationChoices.NOTIFICATION_ALWAYS)
+                job.terminate(status=status)
+                self.assertEqual(
+                    Notification.objects.filter(user=user, object_id=job.pk).count(),
+                    1,
+                    msg=f"Expected a notification for status={status} with notifications=always",
+                )
+
+    @patch('core.models.jobs.job_end')
+    def test_terminate_notification_on_failure(self, mock_job_end):
+        """
+        With notifications=on_failure, a Notification should be created only for
+        non-completed terminal statuses (failed, errored), not for completed.
+        """
+        user = User.objects.create_user(username='notification-on-failure')
+
+        # No notification on successful completion
+        job = self._make_job(user, JobNotificationChoices.NOTIFICATION_ON_FAILURE)
+        job.terminate(status=JobStatusChoices.STATUS_COMPLETED)
+        self.assertEqual(
+            Notification.objects.filter(user=user, object_id=job.pk).count(),
+            0,
+            msg="Expected no notification for status=completed with notifications=on_failure",
+        )
+
+        # Notification on failure/error
+        for status in (JobStatusChoices.STATUS_FAILED, JobStatusChoices.STATUS_ERRORED):
+            with self.subTest(status=status):
+                job = self._make_job(user, JobNotificationChoices.NOTIFICATION_ON_FAILURE)
+                job.terminate(status=status)
+                self.assertEqual(
+                    Notification.objects.filter(user=user, object_id=job.pk).count(),
+                    1,
+                    msg=f"Expected a notification for status={status} with notifications=on_failure",
+                )
+
+    @patch('core.models.jobs.job_end')
+    def test_terminate_notification_never(self, mock_job_end):
+        """
+        With notifications=never, no Notification should be created regardless
+        of terminal status.
+        """
+        user = User.objects.create_user(username='notification-never')
+
+        for status in (
+            JobStatusChoices.STATUS_COMPLETED,
+            JobStatusChoices.STATUS_FAILED,
+            JobStatusChoices.STATUS_ERRORED,
+        ):
+            with self.subTest(status=status):
+                job = self._make_job(user, JobNotificationChoices.NOTIFICATION_NEVER)
+                job.terminate(status=status)
+                self.assertEqual(
+                    Notification.objects.filter(user=user, object_id=job.pk).count(),
+                    0,
+                    msg=f"Expected no notification for status={status} with notifications=never",
+                )

+ 7 - 9
netbox/core/views.py

@@ -50,7 +50,7 @@ from netbox.views import generic
 from netbox.views.generic.base import BaseObjectView
 from netbox.views.generic.mixins import TableMixin
 from utilities.apps import get_installed_apps
-from utilities.data import shallow_compare_dict
+from utilities.data import deep_compare_dict
 from utilities.forms import ConfirmationForm
 from utilities.htmx import htmx_partial
 from utilities.json import ConfigJSONEncoder
@@ -103,6 +103,7 @@ class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
             ObjectsTablePanel(
                 model='core.DataFile',
                 filters={'source_id': lambda ctx: ctx['object'].pk},
+                exclude_columns=['source'],
             ),
         ],
     )
@@ -370,17 +371,14 @@ class ObjectChangeView(generic.ObjectView):
             prechange_data = instance.prechange_data_clean
 
         if prechange_data and instance.postchange_data:
-            diff_added = shallow_compare_dict(
-                prechange_data or dict(),
-                instance.postchange_data_clean or dict(),
+            diff_added, diff_removed = deep_compare_dict(
+                prechange_data,
+                instance.postchange_data_clean,
                 exclude=['last_updated'],
             )
-            diff_removed = {
-                x: prechange_data.get(x) for x in diff_added
-            } if prechange_data else {}
         else:
-            diff_added = None
-            diff_removed = None
+            diff_added = {}
+            diff_removed = {}
 
         return {
             'diff_added': diff_added,

+ 16 - 2
netbox/dcim/api/serializers_/cables.py

@@ -3,7 +3,7 @@ from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from dcim.choices import *
-from dcim.models import Cable, CablePath, CableTermination
+from dcim.models import Cable, CableBundle, CablePath, CableTermination
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.serializers import (
@@ -16,6 +16,7 @@ from tenancy.api.serializers_.tenants import TenantSerializer
 from utilities.api import get_serializer_for_model
 
 __all__ = (
+    'CableBundleSerializer',
     'CablePathSerializer',
     'CableSerializer',
     'CableTerminationSerializer',
@@ -24,6 +25,18 @@ __all__ = (
 )
 
 
+class CableBundleSerializer(PrimaryModelSerializer):
+    cable_count = serializers.IntegerField(read_only=True, default=0)
+
+    class Meta:
+        model = CableBundle
+        fields = [
+            'id', 'url', 'display_url', 'display', 'name', 'description', 'owner', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated', 'cable_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
 class CableSerializer(PrimaryModelSerializer):
     a_terminations = GenericObjectSerializer(many=True, required=False)
     b_terminations = GenericObjectSerializer(many=True, required=False)
@@ -31,12 +44,13 @@ class CableSerializer(PrimaryModelSerializer):
     profile = ChoiceField(choices=CableProfileChoices, required=False)
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
     length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True)
+    bundle = CableBundleSerializer(nested=True, required=False, allow_null=True, default=None)
 
     class Meta:
         model = Cable
         fields = [
             'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'profile',
-            'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
+            'tenant', 'bundle', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
             'custom_fields', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'label', 'description')

+ 8 - 6
netbox/dcim/api/serializers_/device_components.py

@@ -423,27 +423,29 @@ class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer):
         required=False,
         allow_null=True
     )
+    _occupied = serializers.BooleanField(required=False, read_only=True)
 
     class Meta:
         model = ModuleBay
         fields = [
-            'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'installed_module', 'label', 'position',
-            'description', 'owner', 'tags', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'position', 'enabled',
+            'description', 'installed_module', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
-        brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
+        brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'enabled', 'description', '_occupied')
 
 
 class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer):
     device = DeviceSerializer(nested=True)
     installed_device = DeviceSerializer(nested=True, required=False, allow_null=True)
+    _occupied = serializers.BooleanField(required=False, read_only=True)
 
     class Meta:
         model = DeviceBay
         fields = [
-            'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'description', 'installed_device',
-            'owner', 'tags', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'enabled', 'description',
+            'installed_device', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
-        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'enabled', 'description', '_occupied',)
 
 
 class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer):

+ 104 - 21
netbox/dcim/api/serializers_/devices.py

@@ -58,10 +58,30 @@ class DeviceSerializer(PrimaryModelSerializer):
     )
     status = ChoiceField(choices=DeviceStatusChoices, required=False)
     airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
-    primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True)
-    primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)
-    primary_ip6 = IPAddressSerializer(nested=True, required=False, allow_null=True)
-    oob_ip = IPAddressSerializer(nested=True, required=False, allow_null=True)
+    primary_ip = IPAddressSerializer(
+        nested=True,
+        read_only=True,
+        allow_null=True,
+        fields=[*IPAddressSerializer.Meta.brief_fields, 'nat_inside', 'nat_outside'],
+    )
+    primary_ip4 = IPAddressSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        fields=[*IPAddressSerializer.Meta.brief_fields, 'nat_inside', 'nat_outside'],
+    )
+    primary_ip6 = IPAddressSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        fields=[*IPAddressSerializer.Meta.brief_fields, 'nat_inside', 'nat_outside'],
+    )
+    oob_ip = IPAddressSerializer(
+        nested=True,
+        required=False,
+        allow_null=True,
+        fields=[*IPAddressSerializer.Meta.brief_fields, 'nat_inside', 'nat_outside'],
+    )
     parent_device = serializers.SerializerMethodField()
     cluster = ClusterSerializer(nested=True, required=False, allow_null=True)
     virtual_chassis = VirtualChassisSerializer(nested=True, required=False, allow_null=True, default=None)
@@ -151,48 +171,80 @@ class ModuleSerializer(PrimaryModelSerializer):
     module_bay = NestedModuleBaySerializer()
     module_type = ModuleTypeSerializer(nested=True)
     status = ChoiceField(choices=ModuleStatusChoices, required=False)
+    replicate_components = serializers.BooleanField(
+        required=False,
+        default=True,
+        write_only=True,
+        label=_('Replicate components'),
+        help_text=_('Automatically populate components associated with this module type (default: true)')
+    )
+    adopt_components = serializers.BooleanField(
+        required=False,
+        default=False,
+        write_only=True,
+        label=_('Adopt components'),
+        help_text=_('Adopt already existing components')
+    )
 
     class Meta:
         model = Module
         fields = [
             'id', 'url', 'display_url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial',
             'asset_tag', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'replicate_components', 'adopt_components',
         ]
         brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
 
     def validate(self, data):
+        # When used as a nested serializer (e.g. as the `module` field on device component
+        # serializers), `data` is already a resolved Module instance — skip our custom logic.
+        if self.nested:
+            return super().validate(data)
+
+        # Pop write-only transient fields before ValidatedModelSerializer tries to
+        # construct a Module instance for full_clean(); restore them afterwards.
+        replicate_components = data.pop('replicate_components', True)
+        adopt_components = data.pop('adopt_components', False)
         data = super().validate(data)
 
-        if self.nested:
+        # For updates these fields are not meaningful; omit them from validated_data so that
+        # ModelSerializer.update() does not set unexpected attributes on the instance.
+        if self.instance:
             return data
 
-        # Skip validation for existing modules (updates)
-        if self.instance is not None:
+        # Always pass the flags to create() so it can set the correct private attributes.
+        data['replicate_components'] = replicate_components
+        data['adopt_components'] = adopt_components
+
+        # Skip conflict checks when no component operations are requested.
+        if not replicate_components and not adopt_components:
             return data
 
-        module_bay = data.get('module_bay')
-        module_type = data.get('module_type')
         device = data.get('device')
+        module_type = data.get('module_type')
+        module_bay = data.get('module_bay')
 
-        if not all((module_bay, module_type, device)):
+        # Required-field validation fires separately; skip here if any are missing.
+        if not all([device, module_type, module_bay]):
             return data
 
         positions = get_module_bay_positions(module_bay)
 
-        for templates, component_attribute in [
-            ("consoleporttemplates", "consoleports"),
-            ("consoleserverporttemplates", "consoleserverports"),
-            ("interfacetemplates", "interfaces"),
-            ("powerporttemplates", "powerports"),
-            ("poweroutlettemplates", "poweroutlets"),
-            ("rearporttemplates", "rearports"),
-            ("frontporttemplates", "frontports"),
+        for templates_attr, component_attr in [
+            ('consoleporttemplates', 'consoleports'),
+            ('consoleserverporttemplates', 'consoleserverports'),
+            ('interfacetemplates', 'interfaces'),
+            ('powerporttemplates', 'powerports'),
+            ('poweroutlettemplates', 'poweroutlets'),
+            ('rearporttemplates', 'rearports'),
+            ('frontporttemplates', 'frontports'),
         ]:
             installed_components = {
-                component.name: component for component in getattr(device, component_attribute).all()
+                component.name: component
+                for component in getattr(device, component_attr).all()
             }
 
-            for template in getattr(module_type, templates).all():
+            for template in getattr(module_type, templates_attr).all():
                 resolved_name = template.name
                 if MODULE_TOKEN in template.name:
                     if not module_bay.position:
@@ -204,7 +256,17 @@ class ModuleSerializer(PrimaryModelSerializer):
                     except ValueError as e:
                         raise serializers.ValidationError(str(e))
 
-                if resolved_name in installed_components:
+                existing_item = installed_components.get(resolved_name)
+
+                if adopt_components and existing_item and existing_item.module:
+                    raise serializers.ValidationError(
+                        _("Cannot adopt {model} {name} as it already belongs to a module").format(
+                            model=template.component_model.__name__,
+                            name=resolved_name
+                        )
+                    )
+
+                if not adopt_components and resolved_name in installed_components:
                     raise serializers.ValidationError(
                         _("A {model} named {name} already exists").format(
                             model=template.component_model.__name__,
@@ -214,6 +276,27 @@ class ModuleSerializer(PrimaryModelSerializer):
 
         return data
 
+    def create(self, validated_data):
+        replicate_components = validated_data.pop('replicate_components', True)
+        adopt_components = validated_data.pop('adopt_components', False)
+
+        # Tags are handled after save; pop them here to pass to _save_tags()
+        tags = validated_data.pop('tags', None)
+
+        # _adopt_components and _disable_replication must be set on the instance before
+        # save() is called, so we cannot delegate to super().create() here.
+        instance = self.Meta.model(**validated_data)
+        if adopt_components:
+            instance._adopt_components = True
+        if not replicate_components:
+            instance._disable_replication = True
+        instance.save()
+
+        if tags is not None:
+            self._save_tags(instance, tags)
+
+        return instance
+
 
 class MACAddressSerializer(PrimaryModelSerializer):
     assigned_object_type = ContentTypeField(

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

@@ -317,10 +317,10 @@ class ModuleBayTemplateSerializer(ComponentTemplateSerializer):
     class Meta:
         model = ModuleBayTemplate
         fields = [
-            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'position', 'description',
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'position', 'enabled', 'description',
             'created', 'last_updated',
         ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
+        brief_fields = ('id', 'url', 'display', 'name', 'enabled', 'description')
 
 
 class DeviceBayTemplateSerializer(ComponentTemplateSerializer):
@@ -331,10 +331,10 @@ class DeviceBayTemplateSerializer(ComponentTemplateSerializer):
     class Meta:
         model = DeviceBayTemplate
         fields = [
-            'id', 'url', 'display', 'device_type', 'name', 'label', 'description',
+            'id', 'url', 'display', 'device_type', 'name', 'label', 'enabled', 'description',
             'created', 'last_updated'
         ]
-        brief_fields = ('id', 'url', 'display', 'name', 'description')
+        brief_fields = ('id', 'url', 'display', 'name', 'enabled', 'description')
 
 
 class InventoryItemTemplateSerializer(ComponentTemplateSerializer):

+ 36 - 8
netbox/dcim/api/serializers_/racks.py

@@ -1,9 +1,11 @@
 from django.utils.translation import gettext as _
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from dcim.choices import *
 from dcim.constants import *
-from dcim.models import Rack, RackReservation, RackRole, RackType
+from dcim.models import Rack, RackGroup, RackReservation, RackRole, RackType
 from netbox.api.fields import ChoiceField, RelatedObjectCountField
 from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer
 from netbox.choices import *
@@ -16,6 +18,7 @@ from .sites import LocationSerializer, SiteSerializer
 
 __all__ = (
     'RackElevationDetailFilterSerializer',
+    'RackGroupSerializer',
     'RackReservationSerializer',
     'RackRoleSerializer',
     'RackSerializer',
@@ -23,6 +26,20 @@ __all__ = (
 )
 
 
+class RackGroupSerializer(OrganizationalModelSerializer):
+
+    # Related object counts
+    rack_count = RelatedObjectCountField('racks')
+
+    class Meta:
+        model = RackGroup
+        fields = [
+            'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'owner', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated', 'rack_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
+
+
 class RackRoleSerializer(OrganizationalModelSerializer):
 
     # Related object counts
@@ -87,6 +104,11 @@ class RackSerializer(RackBaseSerializer):
         allow_null=True,
         default=None
     )
+    group = RackGroupSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
     tenant = TenantSerializer(
         nested=True,
         required=False,
@@ -127,11 +149,11 @@ class RackSerializer(RackBaseSerializer):
     class Meta:
         model = Rack
         fields = [
-            'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status',
-            'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'weight',
-            'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit',
-            'mounting_depth', 'airflow', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created',
-            'last_updated', 'device_count', 'powerfeed_count',
+            'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'group', 'tenant',
+            'status', 'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit',
+            'weight', 'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
+            'outer_unit', 'mounting_depth', 'airflow', 'description', 'owner', 'comments', 'tags', 'custom_fields',
+            'created', 'last_updated', 'device_count', 'powerfeed_count',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
 
@@ -153,11 +175,17 @@ class RackReservationSerializer(PrimaryModelSerializer):
         allow_null=True,
     )
 
+    unit_count = serializers.SerializerMethodField()
+
+    @extend_schema_field(OpenApiTypes.INT32)
+    def get_unit_count(self, obj):
+        return len(obj.units)
+
     class Meta:
         model = RackReservation
         fields = [
-            'id', 'url', 'display_url', 'display', 'rack', 'units', 'status', 'created', 'last_updated', 'user',
-            'tenant', 'description', 'owner', 'comments', 'tags', 'custom_fields',
+            'id', 'url', 'display_url', 'display', 'rack', 'units', 'unit_count', 'status', 'created', 'last_updated',
+            'user', 'tenant', 'description', 'owner', 'comments', 'tags', 'custom_fields',
         ]
         brief_fields = ('id', 'url', 'display', 'status', 'user', 'description', 'units')
 

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

@@ -12,6 +12,7 @@ router.register('sites', views.SiteViewSet)
 
 # Racks
 router.register('locations', views.LocationViewSet)
+router.register('rack-groups', views.RackGroupViewSet)
 router.register('rack-types', views.RackTypeViewSet)
 router.register('rack-roles', views.RackRoleViewSet)
 router.register('racks', views.RackViewSet)
@@ -63,6 +64,7 @@ router.register('mac-addresses', views.MACAddressViewSet)
 # Cables
 router.register('cables', views.CableViewSet)
 router.register('cable-terminations', views.CableTerminationViewSet)
+router.register('cable-bundles', views.CableBundleViewSet)
 
 # Virtual chassis
 router.register('virtual-chassis', views.VirtualChassisViewSet)

+ 20 - 0
netbox/dcim/api/views.py

@@ -19,6 +19,7 @@ from netbox.api.pagination import StripCountAnnotationsPaginator
 from netbox.api.viewsets import MPTTLockedMixin, NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
 from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
 from utilities.api import get_serializer_for_model
+from utilities.query import count_related
 from utilities.query_functions import CollateAsChar
 from virtualization.models import VirtualMachine
 
@@ -154,6 +155,17 @@ class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
     filterset_class = filtersets.LocationFilterSet
 
 
+#
+# Rack groups
+#
+
+
+class RackGroupViewSet(NetBoxModelViewSet):
+    queryset = RackGroup.objects.all()
+    serializer_class = serializers.RackGroupSerializer
+    filterset_class = filtersets.RackGroupFilterSet
+
+
 #
 # Rack roles
 #
@@ -574,6 +586,14 @@ class CableTerminationViewSet(NetBoxReadOnlyModelViewSet):
     filterset_class = filtersets.CableTerminationFilterSet
 
 
+class CableBundleViewSet(NetBoxModelViewSet):
+    queryset = CableBundle.objects.annotate(
+        cable_count=count_related(Cable, 'bundle')
+    )
+    serializer_class = serializers.CableBundleSerializer
+    filterset_class = filtersets.CableBundleFilterSet
+
+
 #
 # Virtual chassis
 #

+ 96 - 4
netbox/dcim/cable_profiles.py

@@ -1,9 +1,14 @@
+from collections import defaultdict, namedtuple
+
 from django.core.exceptions import ValidationError
+from django.db.models import Q
 from django.utils.translation import gettext_lazy as _
 
 from dcim.choices import CableEndChoices
 from dcim.models import CableTermination
 
+PeerLookupKey = namedtuple('PeerLookupKey', ['cable_id', 'cable_end', 'connector', 'position'])
+
 
 class BaseCableProfile:
     """Base class for representing a cable profile."""
@@ -55,9 +60,14 @@ class BaseCableProfile:
             return self._mapping.get((connector, position))
         return connector, position
 
-    def get_peer_termination(self, termination, position):
+    #
+    # Peer termination resolution
+    #
+
+    def _get_peer_lookup_key(self, termination, position):
         """
-        Given a terminating object, return the peer terminating object (if any) on the opposite end of the cable.
+        Given a cabled object and a local position, return the (cable_id, cable_end, connector, position)
+        tuple identifying the corresponding peer CableTermination on the opposite end of the cable.
         """
         try:
             connector, position = self.get_mapped_position(
@@ -70,10 +80,17 @@ class BaseCableProfile:
                 f"Could not map connector {termination.cable_connector} position {position} on side "
                 f"{termination.cable_end}"
             )
+        return PeerLookupKey(termination.cable_id, termination.opposite_cable_end, connector, position)
+
+    def get_peer_termination(self, termination, position):
+        """
+        Given a terminating object, return the peer terminating object (if any) on the opposite end of the cable.
+        """
+        cable_id, cable_end, connector, position = self._get_peer_lookup_key(termination, position)
         try:
             ct = CableTermination.objects.get(
-                cable=termination.cable,
-                cable_end=termination.opposite_cable_end,
+                cable_id=cable_id,
+                cable_end=cable_end,
                 connector=connector,
                 positions__contains=[position],
             )
@@ -81,6 +98,81 @@ class BaseCableProfile:
         except CableTermination.DoesNotExist:
             return None, None
 
+    def get_peer_terminations(self, term_position_pairs):
+        """
+        Resolve a batch of (termination, position) pairs to peer terminations.
+        """
+        if not term_position_pairs:
+            return []
+
+        # Fast path: a single pair doesn't benefit from batching
+        if len(term_position_pairs) == 1:
+            return [self.get_peer_termination(*term_position_pairs[0])]
+
+        lookup_keys = [
+            self._get_peer_lookup_key(termination, position) for termination, position in term_position_pairs
+        ]
+
+        # Group requested positions by (cable_id, cable_end, connector) so we can
+        # build one overlap clause per group instead of one clause per position.
+        positions_by_group = defaultdict(set)
+        for key in lookup_keys:
+            positions_by_group[(key.cable_id, key.cable_end, key.connector)].add(key.position)
+
+        q_filter = Q()
+        for (cable_id, cable_end, connector), positions in positions_by_group.items():
+            q_filter |= Q(
+                cable_id=cable_id,
+                cable_end=cable_end,
+                connector=connector,
+                positions__overlap=list(positions),
+            )
+
+        peer_ct_by_key = {}
+        term_ids_by_type = defaultdict(set)
+        model_by_type = {}
+
+        for ct in CableTermination.objects.filter(q_filter).select_related('termination_type'):
+            model_by_type[ct.termination_type_id] = ct.termination_type.model_class()
+            term_ids_by_type[ct.termination_type_id].add(ct.termination_id)
+
+            group_key = (ct.cable_id, ct.cable_end, ct.connector)
+            requested_positions = positions_by_group.get(group_key, ())
+
+            # The overlap query may return a termination carrying additional
+            # positions, so only index the positions we actually requested.
+            for pos in ct.positions or []:
+                if pos not in requested_positions:
+                    continue
+
+                key = PeerLookupKey(ct.cable_id, ct.cable_end, ct.connector, pos)
+                if key in peer_ct_by_key and peer_ct_by_key[key].pk != ct.pk:
+                    raise CableTermination.MultipleObjectsReturned(
+                        f"Multiple peer terminations found for cable {ct.cable_id} end {ct.cable_end} "
+                        f"connector {ct.connector} position {pos}"
+                    )
+                peer_ct_by_key[key] = ct
+
+        # Use _base_manager to ensure all termination objects are loaded
+        # regardless of any custom default manager filters (e.g. soft-delete).
+        # Note: Django's GenericForeignKey / ContentType.get_object_for_this_type()
+        # uses _default_manager, but _base_manager is safer for bulk resolution
+        # during path tracing.
+        term_by_type = {
+            type_id: model_by_type[type_id]._base_manager.in_bulk(ids) for type_id, ids in term_ids_by_type.items()
+        }
+
+        results = []
+        for key in lookup_keys:
+            ct = peer_ct_by_key.get(key)
+            if ct is None:
+                results.append((None, None))
+            else:
+                termination = term_by_type[ct.termination_type_id].get(ct.termination_id)
+                results.append((termination, key.position if termination is not None else None))
+
+        return results
+
     @staticmethod
     def get_position_list(n):
         """Return a list of integers from 1 to n, inclusive."""

+ 3 - 0
netbox/dcim/constants.py

@@ -1,3 +1,5 @@
+import re
+
 from django.db.models import Q
 
 from .choices import InterfaceTypeChoices
@@ -79,6 +81,7 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
 #
 
 MODULE_TOKEN = '{module}'
+VC_POSITION_RE = re.compile(r'\{vc_position(?::([^}]*))?\}')
 
 MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
     app_label='dcim',

+ 86 - 4
netbox/dcim/filtersets.py

@@ -1,6 +1,7 @@
 import django_filters
 import netaddr
 from django.contrib.contenttypes.models import ContentType
+from django.db.models import Func, IntegerField
 from django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
@@ -46,6 +47,7 @@ from .constants import *
 from .models import *
 
 __all__ = (
+    'CableBundleFilterSet',
     'CableFilterSet',
     'CableTerminationFilterSet',
     'CabledObjectFilterSet',
@@ -86,6 +88,7 @@ __all__ = (
     'PowerPortFilterSet',
     'PowerPortTemplateFilterSet',
     'RackFilterSet',
+    'RackGroupFilterSet',
     'RackReservationFilterSet',
     'RackRoleFilterSet',
     'RackTypeFilterSet',
@@ -313,6 +316,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
         return queryset
 
 
+@register_filterset
+class RackGroupFilterSet(OrganizationalModelFilterSet):
+
+    class Meta:
+        model = RackGroup
+        fields = ('id', 'name', 'slug', 'description')
+
+
 @register_filterset
 class RackRoleFilterSet(OrganizationalModelFilterSet):
 
@@ -417,6 +428,18 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS
         to_field_name='slug',
         label=_('Location (slug)'),
     )
+    group_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=RackGroup.objects.all(),
+        distinct=False,
+        label=_('Group (ID)'),
+    )
+    group = django_filters.ModelMultipleChoiceFilter(
+        field_name='group__slug',
+        queryset=RackGroup.objects.all(),
+        distinct=False,
+        to_field_name='slug',
+        label=_('Group (slug)'),
+    )
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         field_name='rack_type__manufacturer',
         queryset=Manufacturer.objects.all(),
@@ -551,6 +574,19 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         to_field_name='slug',
         label=_('Location (slug)'),
     )
+    group_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=RackGroup.objects.all(),
+        field_name='rack__group',
+        distinct=False,
+        label=_('Group (ID)'),
+    )
+    group = django_filters.ModelMultipleChoiceFilter(
+        field_name='rack__group__slug',
+        queryset=RackGroup.objects.all(),
+        distinct=False,
+        to_field_name='slug',
+        label=_('Group (slug)'),
+    )
     status = django_filters.MultipleChoiceFilter(
         choices=RackReservationStatusChoices,
         distinct=False,
@@ -572,11 +608,30 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         field_name='units',
         lookup_expr='contains'
     )
+    unit_count_min = django_filters.NumberFilter(
+        field_name='unit_count',
+        lookup_expr='gte',
+        label=_('Minimum unit count'),
+    )
+    unit_count_max = django_filters.NumberFilter(
+        field_name='unit_count',
+        lookup_expr='lte',
+        label=_('Maximum unit count'),
+    )
 
     class Meta:
         model = RackReservation
         fields = ('id', 'created', 'description')
 
+    def filter_queryset(self, queryset):
+        # Annotate unit_count here so unit_count_min/unit_count_max filters can reference it.
+        # When called from the list view the queryset is already annotated; Django silently
+        # overwrites a duplicate annotation with the same expression, so this is safe.
+        queryset = queryset.annotate(
+            unit_count=Func('units', function='CARDINALITY', output_field=IntegerField())
+        )
+        return super().filter_queryset(queryset)
+
     def search(self, queryset, name, value):
         if not value.strip():
             return queryset
@@ -995,7 +1050,7 @@ class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
 
     class Meta:
         model = ModuleBayTemplate
-        fields = ('id', 'name', 'label', 'position', 'description')
+        fields = ('id', 'name', 'label', 'position', 'enabled', 'description')
 
 
 @register_filterset
@@ -1003,7 +1058,7 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
 
     class Meta:
         model = DeviceBayTemplate
-        fields = ('id', 'name', 'label', 'description')
+        fields = ('id', 'name', 'label', 'enabled', 'description')
 
 
 @register_filterset
@@ -2373,7 +2428,7 @@ class ModuleBayFilterSet(ModularDeviceComponentFilterSet):
 
     class Meta:
         model = ModuleBay
-        fields = ('id', 'name', 'label', 'position', 'description')
+        fields = ('id', 'name', 'label', 'position', 'enabled', 'description')
 
 
 @register_filterset
@@ -2393,7 +2448,7 @@ class DeviceBayFilterSet(DeviceComponentFilterSet):
 
     class Meta:
         model = DeviceBay
-        fields = ('id', 'name', 'label', 'description')
+        fields = ('id', 'name', 'label', 'enabled', 'description')
 
 
 @register_filterset
@@ -2546,6 +2601,23 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet):
         return queryset.filter(qs_filter).distinct()
 
 
+@register_filterset
+class CableBundleFilterSet(PrimaryModelFilterSet):
+
+    class Meta:
+        model = CableBundle
+        fields = ('id', 'name', 'description')
+
+    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)
+        )
+
+
 @register_filterset
 class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
     termination_a_type = MultiValueContentTypeFilter(
@@ -2566,6 +2638,16 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
         method='_unterminated',
         label=_('Unterminated'),
     )
+    bundle_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=CableBundle.objects.all(),
+        label=_('Cable bundle (ID)'),
+    )
+    bundle = django_filters.ModelMultipleChoiceFilter(
+        field_name='bundle__name',
+        queryset=CableBundle.objects.all(),
+        to_field_name='name',
+        label=_('Cable bundle (name)'),
+    )
     type = django_filters.MultipleChoiceFilter(
         choices=CableTypeChoices,
         distinct=False,

+ 14 - 7
netbox/dcim/forms/bulk_create.py

@@ -3,9 +3,10 @@ from django.utils.translation import gettext_lazy as _
 
 from dcim.models import *
 from extras.models import Tag
-from netbox.forms.mixins import CustomFieldsMixin
+from netbox.forms.mixins import ChangelogMessageMixin, CustomFieldsMixin
 from utilities.forms import form_from_model
 from utilities.forms.fields import DynamicModelMultipleChoiceField, ExpandableNameField
+from utilities.forms.mixins import BackgroundJobMixin
 
 from .object_create import ComponentCreateForm
 
@@ -27,7 +28,7 @@ __all__ = (
 # Device components
 #
 
-class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm):
+class DeviceBulkAddComponentForm(BackgroundJobMixin, ChangelogMessageMixin, CustomFieldsMixin, ComponentCreateForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Device.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -108,10 +109,13 @@ class RearPortBulkCreateForm(
     field_order = ('name', 'label', 'type', 'positions', 'mark_connected', 'description', 'tags')
 
 
-class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
+class ModuleBayBulkCreateForm(
+    form_from_model(ModuleBay, ['enabled']),
+    DeviceBulkAddComponentForm
+):
     model = ModuleBay
-    field_order = ('name', 'label', 'position', 'description', 'tags')
-    replication_fields = ('name', 'label', 'position')
+    field_order = ('name', 'label', 'position', 'enabled', 'description', 'tags')
+    replication_fields = ('name', 'label', 'position', 'enabled')
     position = ExpandableNameField(
         label=_('Position'),
         required=False,
@@ -119,9 +123,12 @@ class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
     )
 
 
-class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
+class DeviceBayBulkCreateForm(
+    form_from_model(DeviceBay, ['enabled']),
+    DeviceBulkAddComponentForm
+):
     model = DeviceBay
-    field_order = ('name', 'label', 'description', 'tags')
+    field_order = ('name', 'label', 'enabled', 'description', 'tags')
 
 
 class InventoryItemBulkCreateForm(

+ 58 - 8
netbox/dcim/forms/bulk_edit.py

@@ -35,6 +35,7 @@ from wireless.models import WirelessLAN, WirelessLANGroup
 
 __all__ = (
     'CableBulkEditForm',
+    'CableBundleBulkEditForm',
     'ConsolePortBulkEditForm',
     'ConsolePortTemplateBulkEditForm',
     'ConsoleServerPortBulkEditForm',
@@ -67,6 +68,7 @@ __all__ = (
     'PowerPortBulkEditForm',
     'PowerPortTemplateBulkEditForm',
     'RackBulkEditForm',
+    'RackGroupBulkEditForm',
     'RackReservationBulkEditForm',
     'RackRoleBulkEditForm',
     'RackTypeBulkEditForm',
@@ -207,6 +209,14 @@ class LocationBulkEditForm(NestedGroupModelBulkEditForm):
     nullable_fields = ('parent', 'tenant', 'facility', 'description', 'comments')
 
 
+class RackGroupBulkEditForm(OrganizationalModelBulkEditForm):
+    model = RackGroup
+    fieldsets = (
+        FieldSet('description'),
+    )
+    nullable_fields = ('description', 'comments')
+
+
 class RackRoleBulkEditForm(OrganizationalModelBulkEditForm):
     color = ColorField(
         label=_('Color'),
@@ -342,6 +352,11 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
             'site_id': '$site'
         }
     )
+    group = DynamicModelChoiceField(
+        label=_('Group'),
+        queryset=RackGroup.objects.all(),
+        required=False
+    )
     tenant = DynamicModelChoiceField(
         label=_('Tenant'),
         queryset=Tenant.objects.all(),
@@ -441,14 +456,16 @@ class RackBulkEditForm(PrimaryModelBulkEditForm):
 
     model = Rack
     fieldsets = (
-        FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')),
+        FieldSet(
+            'status', 'group', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')
+        ),
         FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
         FieldSet('outer_width', 'outer_height', 'outer_depth', 'outer_unit', name=_('Outer Dimensions')),
         FieldSet('form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'mounting_depth', name=_('Hardware')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
     )
     nullable_fields = (
-        'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_height', 'outer_depth',
+        'location', 'group', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_height', 'outer_depth',
         'outer_unit', 'weight', 'max_weight', 'weight_unit', 'description', 'comments',
     )
 
@@ -776,6 +793,24 @@ class ModuleBulkEditForm(PrimaryModelBulkEditForm):
     nullable_fields = ('serial', 'description', 'comments')
 
 
+class CableBundleBulkEditForm(PrimaryModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=CableBundle.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False,
+    )
+
+    model = CableBundle
+    fieldsets = (
+        FieldSet('description',),
+    )
+    nullable_fields = ('description', 'comments')
+
+
 class CableBulkEditForm(PrimaryModelBulkEditForm):
     type = forms.ChoiceField(
         label=_('Type'),
@@ -800,6 +835,11 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
         queryset=Tenant.objects.all(),
         required=False
     )
+    bundle = DynamicModelChoiceField(
+        label=_('Bundle'),
+        queryset=CableBundle.objects.all(),
+        required=False,
+    )
     label = forms.CharField(
         label=_('Label'),
         max_length=100,
@@ -823,11 +863,11 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
 
     model = Cable
     fieldsets = (
-        FieldSet('type', 'status', 'profile', 'tenant', 'label', 'description'),
+        FieldSet('type', 'status', 'profile', 'tenant', 'bundle', 'label', 'description'),
         FieldSet('color', 'length', 'length_unit', name=_('Attributes')),
     )
     nullable_fields = (
-        'type', 'status', 'profile', 'tenant', 'label', 'color', 'length', 'description', 'comments',
+        'type', 'status', 'profile', 'tenant', 'bundle', 'label', 'color', 'length', 'description', 'comments',
     )
 
 
@@ -1211,6 +1251,11 @@ class ModuleBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         label=_('Description'),
         required=False
     )
+    enabled = forms.NullBooleanField(
+        label=_('Enabled'),
+        required=False,
+        widget=BulkEditNullBooleanSelect,
+    )
 
     nullable_fields = ('label', 'position', 'description')
 
@@ -1229,6 +1274,11 @@ class DeviceBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         label=_('Description'),
         required=False
     )
+    enabled = forms.NullBooleanField(
+        label=_('Enabled'),
+        required=False,
+        widget=BulkEditNullBooleanSelect,
+    )
 
     nullable_fields = ('label', 'description')
 
@@ -1653,23 +1703,23 @@ class RearPortBulkEditForm(
 
 
 class ModuleBayBulkEditForm(
-    form_from_model(ModuleBay, ['label', 'position', 'description']),
+    form_from_model(ModuleBay, ['label', 'position', 'enabled', 'description']),
     NetBoxModelBulkEditForm
 ):
     model = ModuleBay
     fieldsets = (
-        FieldSet('label', 'position', 'description'),
+        FieldSet('label', 'position', 'enabled', 'description'),
     )
     nullable_fields = ('label', 'position', 'description')
 
 
 class DeviceBayBulkEditForm(
-    form_from_model(DeviceBay, ['label', 'description']),
+    form_from_model(DeviceBay, ['label', 'enabled', 'description']),
     NetBoxModelBulkEditForm
 ):
     model = DeviceBay
     fieldsets = (
-        FieldSet('label', 'description'),
+        FieldSet('label', 'enabled', 'description'),
     )
     nullable_fields = ('label', 'description')
 

+ 48 - 7
netbox/dcim/forms/bulk_import.py

@@ -34,6 +34,7 @@ from wireless.choices import WirelessRoleChoices
 from .common import ModuleCommonForm
 
 __all__ = (
+    'CableBundleImportForm',
     'CableImportForm',
     'ConsolePortImportForm',
     'ConsoleServerPortImportForm',
@@ -57,6 +58,7 @@ __all__ = (
     'PowerOutletImportForm',
     'PowerPanelImportForm',
     'PowerPortImportForm',
+    'RackGroupImportForm',
     'RackImportForm',
     'RackReservationImportForm',
     'RackRoleImportForm',
@@ -187,6 +189,13 @@ class LocationImportForm(NestedGroupModelImportForm):
             self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
 
 
+class RackGroupImportForm(OrganizationalModelImportForm):
+
+    class Meta:
+        model = RackGroup
+        fields = ('name', 'slug', 'description', 'owner', 'comments', 'tags')
+
+
 class RackRoleImportForm(OrganizationalModelImportForm):
 
     class Meta:
@@ -261,6 +270,13 @@ class RackImportForm(PrimaryModelImportForm):
         to_field_name='name',
         help_text=_('Name of assigned tenant')
     )
+    group = CSVModelChoiceField(
+        label=_('Rack group'),
+        queryset=RackGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Name of assigned group')
+    )
     status = CSVChoiceField(
         label=_('Status'),
         choices=RackStatusChoices,
@@ -318,10 +334,10 @@ class RackImportForm(PrimaryModelImportForm):
     class Meta:
         model = Rack
         fields = (
-            'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor', 'serial',
-            'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit',
-            'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'owner', 'comments',
-            'tags',
+            'site', 'location', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor',
+            'serial', 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
+            'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'owner',
+            'comments', 'tags',
         )
 
     def __init__(self, data=None, *args, **kwargs):
@@ -1138,7 +1154,13 @@ class ModuleBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
 
     class Meta:
         model = ModuleBay
-        fields = ('device', 'name', 'label', 'position', 'description', 'owner', 'tags')
+        fields = ('device', 'name', 'label', 'position', 'enabled', 'description', 'owner', 'tags')
+
+    def clean_enabled(self):
+        # Make sure enabled is True when it's not included in the uploaded data
+        if 'enabled' not in self.data:
+            return True
+        return self.cleaned_data['enabled']
 
 
 class DeviceBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
@@ -1160,7 +1182,7 @@ class DeviceBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
 
     class Meta:
         model = DeviceBay
-        fields = ('device', 'name', 'label', 'installed_device', 'description', 'owner', 'tags')
+        fields = ('device', 'name', 'label', 'enabled', 'installed_device', 'description', 'owner', 'tags')
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -1188,6 +1210,12 @@ class DeviceBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
         else:
             self.fields['installed_device'].queryset = Device.objects.none()
 
+    def clean_enabled(self):
+        # Make sure enabled is True when it's not included in the uploaded data
+        if 'enabled' not in self.data:
+            return True
+        return self.cleaned_data['enabled']
+
 
 class InventoryItemImportForm(OwnerCSVMixin, NetBoxModelImportForm):
     device = CSVModelChoiceField(
@@ -1397,6 +1425,12 @@ class MACAddressImportForm(PrimaryModelImportForm):
 # Cables
 #
 
+class CableBundleImportForm(PrimaryModelImportForm):
+    class Meta:
+        model = CableBundle
+        fields = ('name', 'description', 'owner', 'comments', 'tags')
+
+
 class CableImportForm(PrimaryModelImportForm):
     # Termination A
     side_a_site = CSVModelChoiceField(
@@ -1490,6 +1524,13 @@ class CableImportForm(PrimaryModelImportForm):
         to_field_name='name',
         help_text=_('Assigned tenant')
     )
+    bundle = CSVModelChoiceField(
+        label=_('Bundle'),
+        queryset=CableBundle.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Cable bundle name'),
+    )
     length_unit = CSVChoiceField(
         label=_('Length unit'),
         choices=CableLengthUnitChoices,
@@ -1508,7 +1549,7 @@ class CableImportForm(PrimaryModelImportForm):
         fields = [
             'side_a_site', 'side_a_device', 'side_a_power_panel', 'side_a_type', 'side_a_name',
             'side_b_site', 'side_b_device', 'side_b_power_panel', 'side_b_type', 'side_b_name',
-            'type', 'status', 'profile', 'tenant', 'label', 'color', 'length', 'length_unit',
+            'type', 'status', 'profile', 'tenant', 'bundle', 'label', 'color', 'length', 'length_unit',
             'description', 'owner', 'comments', 'tags',
         ]
 

+ 0 - 1
netbox/dcim/forms/common.py

@@ -113,7 +113,6 @@ class ModuleCommonForm(forms.Form):
                         raise forms.ValidationError(
                             _("Cannot install module with placeholder values in a module bay with no position defined.")
                         )
-
                     try:
                         resolved_name = resolve_module_placeholder(template.name, positions)
                     except ValueError as e:

+ 79 - 10
netbox/dcim/forms/filtersets.py

@@ -27,6 +27,7 @@ from vpn.models import L2VPN
 from wireless.choices import *
 
 __all__ = (
+    'CableBundleFilterForm',
     'CableFilterForm',
     'ConsoleConnectionFilterForm',
     'ConsolePortFilterForm',
@@ -64,6 +65,7 @@ __all__ = (
     'PowerPortTemplateFilterForm',
     'RackElevationFilterForm',
     'RackFilterForm',
+    'RackGroupFilterForm',
     'RackReservationFilterForm',
     'RackRoleFilterForm',
     'RackTypeFilterForm',
@@ -276,6 +278,15 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupM
     tag = TagFilterField(model)
 
 
+class RackGroupFilterForm(OrganizationalModelFilterSetForm):
+    model = RackGroup
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
+    )
+    tag = TagFilterField(model)
+
+
 class RackRoleFilterForm(OrganizationalModelFilterSetForm):
     model = RackRole
     fieldsets = (
@@ -355,7 +366,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
     model = Rack
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
+        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'group_id', name=_('Location')),
         FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')),
         FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
         FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
@@ -392,6 +403,12 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
         },
         label=_('Location')
     )
+    group_id = DynamicModelMultipleChoiceField(
+        queryset=RackGroup.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Rack group')
+    )
     status = forms.MultipleChoiceField(
         label=_('Status'),
         choices=RackStatusChoices,
@@ -435,7 +452,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
 class RackElevationFilterForm(RackFilterForm):
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'id', name=_('Location')),
+        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'group_id', 'id', name=_('Location')),
         FieldSet('status', 'role_id', name=_('Function')),
         FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')),
         FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
@@ -458,8 +475,8 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     model = RackReservation
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('status', 'user_id', name=_('Reservation')),
-        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')),
+        FieldSet('status', 'user_id', 'unit_count_min', 'unit_count_max', name=_('Reservation')),
+        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'group_id', 'rack_id', name=_('Rack')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
@@ -491,10 +508,17 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
         label=_('Location'),
         null_option='None'
     )
+    group_id = DynamicModelMultipleChoiceField(
+        queryset=RackGroup.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Rack group')
+    )
     rack_id = DynamicModelMultipleChoiceField(
         queryset=Rack.objects.all(),
         required=False,
         query_params={
+            'group_id': '$group_id',
             'site_id': '$site_id',
             'location_id': '$location_id',
         },
@@ -510,6 +534,14 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
         required=False,
         label=_('User')
     )
+    unit_count_min = forms.IntegerField(
+        required=False,
+        label=_("Minimum U's")
+    )
+    unit_count_max = forms.IntegerField(
+        required=False,
+        label=_("Maximum U's")
+    )
     tag = TagFilterField(model)
 
 
@@ -1161,12 +1193,24 @@ class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     tag = TagFilterField(model)
 
 
+class CableBundleFilterForm(PrimaryModelFilterSetForm):
+    model = CableBundle
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('name', name=_('Attributes')),
+    )
+    tag = TagFilterField(model)
+
+
 class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
     model = Cable
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
-        FieldSet('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')),
+        FieldSet(
+            'type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', 'bundle_id',
+            name=_('Attributes'),
+        ),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
@@ -1248,6 +1292,11 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
     )
+    bundle_id = DynamicModelMultipleChoiceField(
+        queryset=CableBundle.objects.all(),
+        required=False,
+        label=_('Bundle'),
+    )
     tag = TagFilterField(model)
 
 
@@ -1841,7 +1890,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
     model = ModuleBay
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('name', 'label', 'position', name=_('Attributes')),
+        FieldSet('name', 'label', 'position', 'enabled', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
             'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
@@ -1849,31 +1898,41 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
         ),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
-    tag = TagFilterField(model)
     position = forms.CharField(
         label=_('Position'),
         required=False
     )
+    enabled = forms.NullBooleanField(
+        label=_('Enabled'),
+        required=False,
+        widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
+    )
+    tag = TagFilterField(model)
 
 
 class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
     model = ModuleBayTemplate
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('name', 'label', 'position', name=_('Attributes')),
+        FieldSet('name', 'label', 'position', 'enabled', name=_('Attributes')),
         FieldSet('device_type_id', 'module_type_id', name=_('Device')),
     )
     position = forms.CharField(
         label=_('Position'),
         required=False,
     )
+    enabled = forms.NullBooleanField(
+        label=_('Enabled'),
+        required=False,
+        widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
+    )
 
 
 class DeviceBayFilterForm(DeviceComponentFilterForm):
     model = DeviceBay
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('name', 'label', name=_('Attributes')),
+        FieldSet('name', 'label', 'enabled', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
             'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
@@ -1881,6 +1940,11 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
         ),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
+    enabled = forms.NullBooleanField(
+        label=_('Enabled'),
+        required=False,
+        widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
+    )
     tag = TagFilterField(model)
 
 
@@ -1888,9 +1952,14 @@ class DeviceBayTemplateFilterForm(DeviceComponentTemplateFilterForm):
     model = DeviceBayTemplate
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('name', 'label', name=_('Attributes')),
+        FieldSet('name', 'label', 'enabled', name=_('Attributes')),
         FieldSet('device_type_id', name=_('Device')),
     )
+    enabled = forms.NullBooleanField(
+        label=_('Enabled'),
+        required=False,
+        widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
+    )
 
 
 class InventoryItemFilterForm(DeviceComponentFilterForm):

+ 50 - 13
netbox/dcim/forms/model_forms.py

@@ -39,6 +39,7 @@ from wireless.models import WirelessLAN, WirelessLANGroup
 from .common import InterfaceCommonForm, ModuleCommonForm
 
 __all__ = (
+    'CableBundleForm',
     'CableForm',
     'ConsolePortForm',
     'ConsolePortTemplateForm',
@@ -74,6 +75,7 @@ __all__ = (
     'PowerPortForm',
     'PowerPortTemplateForm',
     'RackForm',
+    'RackGroupForm',
     'RackReservationForm',
     'RackRoleForm',
     'RackTypeForm',
@@ -232,6 +234,18 @@ class LocationForm(TenancyForm, NestedGroupModelForm):
         )
 
 
+class RackGroupForm(OrganizationalModelForm):
+    fieldsets = (
+        FieldSet('name', 'slug', 'description', 'tags', name=_('Rack Group')),
+    )
+
+    class Meta:
+        model = RackGroup
+        fields = [
+            'name', 'slug', 'description', 'owner', 'comments', 'tags',
+        ]
+
+
 class RackRoleForm(OrganizationalModelForm):
     fieldsets = (
         FieldSet('name', 'slug', 'color', 'description', 'tags', name=_('Rack Role')),
@@ -289,6 +303,11 @@ class RackForm(TenancyForm, PrimaryModelForm):
             'site_id': '$site'
         }
     )
+    group = DynamicModelChoiceField(
+        label=_('Rack Group'),
+        queryset=RackGroup.objects.all(),
+        required=False
+    )
     role = DynamicModelChoiceField(
         label=_('Role'),
         queryset=RackRole.objects.all(),
@@ -304,7 +323,7 @@ class RackForm(TenancyForm, PrimaryModelForm):
 
     fieldsets = (
         FieldSet(
-            'site', 'location', 'name', 'status', 'role', 'rack_type', 'description', 'airflow', 'tags',
+            'site', 'location', 'group', 'name', 'status', 'role', 'rack_type', 'description', 'airflow', 'tags',
             name=_('Rack')
         ),
         FieldSet('facility_id', 'serial', 'asset_tag', name=_('Inventory Control')),
@@ -314,7 +333,7 @@ class RackForm(TenancyForm, PrimaryModelForm):
     class Meta:
         model = Rack
         fields = [
-            'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
+            'site', 'location', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
             'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
             'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight',
             'weight_unit', 'description', 'owner', 'comments', 'tags',
@@ -784,7 +803,7 @@ class ModuleForm(ModuleCommonForm, PrimaryModelForm):
             'device_id': '$device',
         },
         context={
-            'disabled': 'installed_module',
+            'disabled': '_occupied',
         },
     )
     module_type = DynamicModelChoiceField(
@@ -838,6 +857,17 @@ def get_termination_type_choices():
     ])
 
 
+class CableBundleForm(PrimaryModelForm):
+
+    fieldsets = (
+        FieldSet('name', 'description', 'tags', name=_('Cable Bundle')),
+    )
+
+    class Meta:
+        model = CableBundle
+        fields = ['name', 'description', 'owner', 'comments', 'tags']
+
+
 class CableForm(TenancyForm, PrimaryModelForm):
     a_terminations_type = forms.ChoiceField(
         choices=get_termination_type_choices,
@@ -851,12 +881,17 @@ class CableForm(TenancyForm, PrimaryModelForm):
         widget=HTMXSelect(),
         label=_('Type')
     )
+    bundle = DynamicModelChoiceField(
+        queryset=CableBundle.objects.all(),
+        required=False,
+        label=_('Bundle'),
+    )
 
     class Meta:
         model = Cable
         fields = [
             'a_terminations_type', 'b_terminations_type', 'type', 'status', 'profile', 'tenant_group', 'tenant',
-            'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
+            'bundle', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
         ]
 
 
@@ -1063,7 +1098,9 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
         self.fields['name'].help_text = _(
             "Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range are not "
             "supported (example: <code>[ge,xe]-0/0/[0-9]</code>). The token <code>{module}</code>, if present, will be "
-            "automatically replaced with the position value when creating a new module."
+            "automatically replaced with the position value when creating a new module. "
+            "The token <code>{vc_position}</code> will be replaced with the device's Virtual Chassis position "
+            "(use <code>{vc_position:1}</code> to specify a fallback (default is 0))"
         )
 
 
@@ -1224,26 +1261,26 @@ class ModuleBayTemplateForm(ModularComponentTemplateForm):
                 FieldSet('device_type', name=_('Device Type')),
                 FieldSet('module_type', name=_('Module Type')),
             ),
-            'name', 'label', 'position', 'description',
+            'name', 'label', 'position', 'enabled', 'description',
         ),
     )
 
     class Meta:
         model = ModuleBayTemplate
         fields = [
-            'device_type', 'module_type', 'name', 'label', 'position', 'description',
+            'device_type', 'module_type', 'name', 'label', 'position', 'enabled', 'description',
         ]
 
 
 class DeviceBayTemplateForm(ComponentTemplateForm):
     fieldsets = (
-        FieldSet('device_type', 'name', 'label', 'description'),
+        FieldSet('device_type', 'name', 'label', 'enabled', 'description'),
     )
 
     class Meta:
         model = DeviceBayTemplate
         fields = [
-            'device_type', 'name', 'label', 'description',
+            'device_type', 'name', 'label', 'enabled', 'description',
         ]
 
 
@@ -1689,25 +1726,25 @@ class RearPortForm(ModularDeviceComponentForm):
 
 class ModuleBayForm(ModularDeviceComponentForm):
     fieldsets = (
-        FieldSet('device', 'module', 'name', 'label', 'position', 'description', 'tags',),
+        FieldSet('device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'tags',),
     )
 
     class Meta:
         model = ModuleBay
         fields = [
-            'device', 'module', 'name', 'label', 'position', 'description', 'owner', 'tags',
+            'device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'owner', 'tags',
         ]
 
 
 class DeviceBayForm(DeviceComponentForm):
     fieldsets = (
-        FieldSet('device', 'name', 'label', 'description', 'tags',),
+        FieldSet('device', 'name', 'label', 'enabled', 'description', 'tags',),
     )
 
     class Meta:
         model = DeviceBay
         fields = [
-            'device', 'name', 'label', 'description', 'owner', 'tags',
+            'device', 'name', 'label', 'enabled', 'description', 'owner', 'tags',
         ]
 
 

+ 21 - 1
netbox/dcim/graphql/filters.py

@@ -63,6 +63,7 @@ if TYPE_CHECKING:
     from .enums import *
 
 __all__ = (
+    'CableBundleFilter',
     'CableFilter',
     'CableTerminationFilter',
     'ConsolePortFilter',
@@ -99,6 +100,7 @@ __all__ = (
     'PowerPortFilter',
     'PowerPortTemplateFilter',
     'RackFilter',
+    'RackGroupFilter',
     'RackReservationFilter',
     'RackRoleFilter',
     'RackTypeFilter',
@@ -112,6 +114,11 @@ __all__ = (
 )
 
 
+@strawberry_django.filter_type(models.CableBundle, lookups=True)
+class CableBundleFilter(PrimaryModelFilter):
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+
+
 @strawberry_django.filter_type(models.Cable, lookups=True)
 class CableFilter(TenancyFilterMixin, PrimaryModelFilter):
     type: BaseFilterLookup[Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
@@ -317,6 +324,7 @@ class DeviceFilter(
 
 @strawberry_django.filter_type(models.DeviceBay, lookups=True)
 class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter):
+    enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -325,7 +333,7 @@ class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter):
 
 @strawberry_django.filter_type(models.DeviceBayTemplate, lookups=True)
 class DeviceBayTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedModelFilter):
-    pass
+    enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True)
@@ -741,11 +749,13 @@ class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
     )
     parent_id: ID | None = strawberry_django.filter_field()
     position: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
 class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     position: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
 @strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
@@ -962,6 +972,10 @@ class RackFilter(
     location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
+    group: Annotated['RackGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
+    group_id: ID | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['RackStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
@@ -977,6 +991,11 @@ class RackFilter(
     )
 
 
+@strawberry_django.filter_type(models.RackGroup, lookups=True)
+class RackGroupFilter(OrganizationalModelFilter):
+    pass
+
+
 @strawberry_django.filter_type(models.RackReservation, lookups=True)
 class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
     rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
@@ -984,6 +1003,7 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
     units: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
+    unit_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user_id: ID | None = strawberry_django.filter_field()
     description: StrFilterLookup[str] | None = strawberry_django.filter_field()

+ 6 - 0
netbox/dcim/graphql/schema.py

@@ -9,6 +9,9 @@ class DCIMQuery:
     cable: CableType = strawberry_django.field()
     cable_list: list[CableType] = strawberry_django.field()
 
+    cable_bundle: CableBundleType = strawberry_django.field()
+    cable_bundle_list: list[CableBundleType] = strawberry_django.field()
+
     console_port: ConsolePortType = strawberry_django.field()
     console_port_list: list[ConsolePortType] = strawberry_django.field()
 
@@ -102,6 +105,9 @@ class DCIMQuery:
     power_port_template: PowerPortTemplateType = strawberry_django.field()
     power_port_template_list: list[PowerPortTemplateType] = strawberry_django.field()
 
+    rack_group: RackGroupType = strawberry_django.field()
+    rack_group_list: list[RackGroupType] = strawberry_django.field()
+
     rack_type: RackTypeType = strawberry_django.field()
     rack_type_list: list[RackTypeType] = strawberry_django.field()
 

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

@@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Annotated
 
 import strawberry
 import strawberry_django
+from django.db.models import Func, IntegerField
 
 from core.graphql.mixins import ChangelogMixin
 from dcim import models
@@ -39,6 +40,7 @@ if TYPE_CHECKING:
     from wireless.graphql.types import WirelessLANType, WirelessLinkType
 
 __all__ = (
+    'CableBundleType',
     'CableType',
     'ComponentType',
     'ConsolePortTemplateType',
@@ -73,6 +75,7 @@ __all__ = (
     'PowerPanelType',
     'PowerPortTemplateType',
     'PowerPortType',
+    'RackGroupType',
     'RackReservationType',
     'RackRoleType',
     'RackType',
@@ -126,6 +129,16 @@ class ModularComponentTemplateType(ComponentTemplateType):
 #
 
 
+@strawberry_django.type(
+    models.CableBundle,
+    fields='__all__',
+    filters=CableBundleFilter,
+    pagination=True
+)
+class CableBundleType(PrimaryObjectType):
+    cables: list[Annotated['CableType', strawberry.lazy('dcim.graphql.types')]]
+
+
 @strawberry_django.type(
     models.CableTermination,
     exclude=['termination_type', 'termination_id', '_device', '_rack', '_location', '_site'],
@@ -157,6 +170,7 @@ class CableTerminationType(NetBoxObjectType):
 class CableType(PrimaryObjectType):
     color: str
     tenant: Annotated['TenantType', strawberry.lazy('tenancy.graphql.types')] | None
+    bundle: Annotated['CableBundleType', strawberry.lazy('dcim.graphql.types')] | None
 
     terminations: list[CableTerminationType]
 
@@ -737,6 +751,17 @@ class PowerPortTemplateType(ModularComponentTemplateType):
     poweroutlet_templates: list[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
+@strawberry_django.type(
+    models.RackGroup,
+    fields='__all__',
+    filters=RackGroupFilter,
+    pagination=True
+)
+class RackGroupType(OrganizationalObjectType):
+
+    racks: list[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]]
+
+
 @strawberry_django.type(
     models.RackType,
     fields='__all__',
@@ -757,6 +782,7 @@ class RackTypeType(ImageAttachmentsMixin, PrimaryObjectType):
 class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObjectType):
     site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]
     location: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None
+    group: Annotated["RackGroupType", strawberry.lazy('dcim.graphql.types')] | None
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
     role: Annotated["RackRoleType", strawberry.lazy('dcim.graphql.types')] | None
 
@@ -779,6 +805,17 @@ class RackReservationType(PrimaryObjectType):
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
     user: Annotated["UserType", strawberry.lazy('users.graphql.types')]
 
+    @classmethod
+    def get_queryset(cls, queryset, info, **kwargs):
+        queryset = super().get_queryset(queryset, info, **kwargs)
+        return queryset.annotate(
+            unit_count=Func('units', function='CARDINALITY', output_field=IntegerField())
+        )
+
+    @strawberry.field
+    def unit_count(self) -> int:
+        return len(self.units)
+
 
 @strawberry_django.type(
     models.RackRole,

+ 5 - 1
netbox/dcim/migrations/0206_load_module_type_profiles.py

@@ -22,17 +22,21 @@ def load_initial_data(apps, schema_editor):
         'power_supply',
         'expansion_card'
     )
+    profile_objects = []
 
     for name in initial_profiles:
         file_path = DATA_FILES_PATH / f'{name}.json'
         with file_path.open('r') as f:
             data = json.load(f)
             try:
-                ModuleTypeProfile.objects.using(db_alias).create(**data)
+                profile = ModuleTypeProfile(**data)
+                profile_objects.append(profile)
             except Exception as e:
                 print(f"Error loading data from {file_path}")
                 raise e
 
+    ModuleTypeProfile.objects.using(db_alias).bulk_create(profile_objects)
+
 
 class Migration(migrations.Migration):
 

+ 109 - 0
netbox/dcim/migrations/0228_rack_group.py

@@ -0,0 +1,109 @@
+import django.db.models.deletion
+import taggit.managers
+from django.db import migrations, models
+
+import netbox.models.deletion
+import utilities.json
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0227_alter_interface_speed_bigint'),
+        ('extras', '0134_owner'),
+        ('users', '0015_owner'),
+    ]
+
+    operations = [
+        # Rename legacy database objects left over from when the RackGroup model was renamed
+        # to Location (v2.11) and Rack.group was renamed to Rack.location. Old installations
+        # retained the original names, which conflict with the new dcim_rackgroup table and
+        # dcim_rack.group_id column created by this migration. No-op on fresh installs.
+        migrations.RunSQL(
+            sql=[
+                "ALTER INDEX IF EXISTS dcim_rackgroup_pkey RENAME TO dcim_location_pkey",
+                "ALTER INDEX IF EXISTS dcim_rackgroup_parent_id_cc315105 RENAME TO dcim_location_parent_id_d77f3318",
+                "ALTER INDEX IF EXISTS dcim_rackgroup_site_id_13520e89 RENAME TO dcim_location_site_id_b55e975f",
+                "ALTER INDEX IF EXISTS dcim_rackgroup_slug_3f4582a7 RENAME TO dcim_location_slug_352c5472",
+                "ALTER INDEX IF EXISTS dcim_rackgroup_slug_3f4582a7_like RENAME TO dcim_location_slug_352c5472_like",
+                "ALTER INDEX IF EXISTS dcim_rackgroup_tree_id_9c2ad6f4 RENAME TO dcim_location_tree_id_5089ef14",
+                "ALTER SEQUENCE IF EXISTS dcim_rackgroup_id_seq RENAME TO dcim_location_id_seq",
+                # Rename the legacy index on dcim_rack from when Rack.group was renamed to
+                # Rack.location. The column was renamed but the index was not, so it still
+                # carries the old "group_id" name while indexing location_id. Its name
+                # collides with the new index Django creates for the new Rack.group FK.
+                "ALTER INDEX IF EXISTS dcim_rack_group_id_44e90ea9 RENAME TO dcim_rack_location_id_5f63ec31",
+            ],
+            reverse_sql=migrations.RunSQL.noop,
+        ),
+        # PostgreSQL does not support IF EXISTS on RENAME CONSTRAINT, so use a DO block.
+        # Target names match what a fresh v4.5.9 install produces (Django generates the FK
+        # constraint name as <table>_<col>_<hash>_fk_<ref_table>_<ref_col>, where the hash is
+        # md5(table + col)[:8] computed against the new dcim_location table name).
+        migrations.RunSQL(
+            sql="""
+                DO $$
+                DECLARE
+                    r RECORD;
+                BEGIN
+                    FOR r IN (
+                        SELECT old_name, new_name FROM (VALUES
+                            ('dcim_rackgroup_level_check', 'dcim_location_level_check'),
+                            ('dcim_rackgroup_lft_check', 'dcim_location_lft_check'),
+                            ('dcim_rackgroup_rght_check', 'dcim_location_rght_check'),
+                            ('dcim_rackgroup_tree_id_check', 'dcim_location_tree_id_check'),
+                            ('dcim_rackgroup_site_id_13520e89_fk',
+                                'dcim_location_site_id_b55e975f_fk_dcim_site_id')
+                        ) AS m(old_name, new_name)
+                        WHERE EXISTS (
+                            SELECT 1 FROM pg_constraint
+                            WHERE conrelid = to_regclass('dcim_location') AND conname = m.old_name
+                        )
+                    ) LOOP
+                        EXECUTE format('ALTER TABLE dcim_location RENAME CONSTRAINT %I TO %I',
+                            r.old_name, r.new_name);
+                    END LOOP;
+                END $$;
+            """,
+            reverse_sql=migrations.RunSQL.noop,
+        ),
+        migrations.CreateModel(
+            name='RackGroup',
+            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),
+                ),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('slug', models.SlugField(max_length=100, unique=True)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('comments', models.TextField(blank=True)),
+                (
+                    'owner',
+                    models.ForeignKey(
+                        blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
+                    ),
+                ),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'rack group',
+                'verbose_name_plural': 'rack groups',
+                'ordering': ('name',),
+            },
+            bases=(netbox.models.deletion.DeleteMixin, models.Model),
+        ),
+        migrations.AddField(
+            model_name='rack',
+            name='group',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.PROTECT,
+                related_name='racks',
+                to='dcim.rackgroup',
+            ),
+        ),
+    ]

+ 54 - 0
netbox/dcim/migrations/0229_cable_bundle.py

@@ -0,0 +1,54 @@
+import django.db.models.deletion
+import taggit.managers
+from django.db import migrations, models
+
+import netbox.models.deletion
+import utilities.json
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0228_rack_group'),
+        ('extras', '0134_owner'),
+        ('users', '0015_owner'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='CableBundle',
+            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)
+                 ),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('comments', models.TextField(blank=True)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('owner', models.ForeignKey(
+                    blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner')
+                 ),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'cable bundle',
+                'verbose_name_plural': 'cable bundles',
+                'ordering': ('name',),
+            },
+            bases=(netbox.models.deletion.DeleteMixin, models.Model),
+        ),
+        migrations.AddField(
+            model_name='cable',
+            name='bundle',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='cables',
+                to='dcim.cablebundle',
+                verbose_name='bundle',
+            ),
+        ),
+    ]

+ 30 - 0
netbox/dcim/migrations/0230_devicebay_modulebay_enabled.py

@@ -0,0 +1,30 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0229_cable_bundle'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='devicebay',
+            name='enabled',
+            field=models.BooleanField(default=True),
+        ),
+        migrations.AddField(
+            model_name='devicebaytemplate',
+            name='enabled',
+            field=models.BooleanField(default=True),
+        ),
+        migrations.AddField(
+            model_name='modulebay',
+            name='enabled',
+            field=models.BooleanField(default=True),
+        ),
+        migrations.AddField(
+            model_name='modulebaytemplate',
+            name='enabled',
+            field=models.BooleanField(default=True),
+        ),
+    ]

+ 23 - 0
netbox/dcim/migrations/0231_interface_rf_channel_frequency_precision.py

@@ -0,0 +1,23 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0230_devicebay_modulebay_enabled'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='interface',
+            name='rf_channel_frequency',
+            field=models.DecimalField(
+                blank=True,
+                decimal_places=3,
+                help_text='Populated by selected channel (if set)',
+                max_digits=8,
+                null=True,
+                verbose_name='channel frequency (MHz)',
+            ),
+        ),
+    ]

+ 78 - 0
netbox/dcim/migrations/0232_default_ordering_indexes.py

@@ -0,0 +1,78 @@
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('dcim', '0231_interface_rf_channel_frequency_precision'),
+        ('extras', '0136_customfield_validation_schema'),
+        ('ipam', '0088_rename_vlangroup_total_vlan_ids'),
+        ('tenancy', '0023_add_mptt_tree_indexes'),
+        ('users', '0015_owner'),
+        ('virtualization', '0054_virtualmachinetype'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.AddIndex(
+            model_name='consoleporttemplate',
+            index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_consol_device__101ed5_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='consoleserverporttemplate',
+            index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_consol_device__a901e6_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='device',
+            index=models.Index(fields=['name', 'id'], name='dcim_device_name_c27913_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='frontporttemplate',
+            index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_frontp_device__ec2ffb_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='interfacetemplate',
+            index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_interf_device__601012_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='macaddress',
+            index=models.Index(fields=['mac_address', 'id'], name='dcim_macadd_mac_add_f2662a_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='modulebaytemplate',
+            index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_module_device__9eabad_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='moduletype',
+            index=models.Index(fields=['profile', 'manufacturer', 'model'], name='dcim_module_profile_868277_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='poweroutlettemplate',
+            index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_powero_device__b83a8f_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='powerporttemplate',
+            index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_powerp_device__6c25da_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='rack',
+            index=models.Index(fields=['site', 'location', 'name', 'id'], name='dcim_rack_site_id_715040_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='rackreservation',
+            index=models.Index(fields=['created', 'id'], name='dcim_rackre_created_84f02e_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='rearporttemplate',
+            index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_rearpo_device__27f194_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='virtualchassis',
+            index=models.Index(fields=['name'], name='dcim_virtua_name_2dc5cd_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='virtualdevicecontext',
+            index=models.Index(fields=['name'], name='dcim_virtua_name_079d4d_idx'),
+        ),
+    ]

+ 15 - 0
netbox/dcim/migrations/0233_device_render_config_permission.py

@@ -0,0 +1,15 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0232_default_ordering_indexes'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='device',
+            options={'ordering': ('name', 'pk'), 'permissions': [('render_config', 'Render configuration')]},
+        ),
+    ]

+ 59 - 8
netbox/dcim/models/cables.py

@@ -1,5 +1,6 @@
 import itertools
 import logging
+from collections import Counter
 
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
@@ -8,6 +9,7 @@ from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.dispatch import Signal
+from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
 from core.models import ObjectType
@@ -29,6 +31,7 @@ from .device_components import FrontPort, PathEndpoint, PortMapping, RearPort
 
 __all__ = (
     'Cable',
+    'CableBundle',
     'CablePath',
     'CableTermination',
 )
@@ -38,6 +41,32 @@ logger = logging.getLogger(f'netbox.{__name__}')
 trace_paths = Signal()
 
 
+#
+# Cable bundles
+#
+
+class CableBundle(PrimaryModel):
+    """
+    A logical grouping of individual cables.
+    """
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        unique=True,
+    )
+
+    class Meta:
+        ordering = ('name',)
+        verbose_name = _('cable bundle')
+        verbose_name_plural = _('cable bundles')
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('dcim:cablebundle', args=[self.pk])
+
+
 #
 # Cables
 #
@@ -102,8 +131,16 @@ class Cable(PrimaryModel):
         blank=True,
         null=True
     )
+    bundle = models.ForeignKey(
+        to='dcim.CableBundle',
+        on_delete=models.SET_NULL,
+        related_name='cables',
+        blank=True,
+        null=True,
+        verbose_name=_('bundle'),
+    )
 
-    clone_fields = ('tenant', 'type', 'profile')
+    clone_fields = ('tenant', 'type', 'profile', 'bundle')
 
     class Meta:
         ordering = ('pk',)
@@ -871,25 +908,37 @@ class CablePath(models.Model):
                     # Build (termination, position) pairs by matching stacked positions
                     # to each termination's cable_positions. This correctly handles
                     # multiple terminations on different connectors of the same cable.
-                    remaining = list(positions)
+                    remaining = Counter(positions)
                     term_position_pairs = []
                     for term in terminations:
                         if term.cable_positions:
                             for cp in term.cable_positions:
-                                if cp in remaining:
+                                if remaining[cp]:
                                     term_position_pairs.append((term, cp))
-                                    remaining.remove(cp)
+                                    remaining[cp] -= 1
 
                     # Fallback for when positions don't match cable_positions
                     # (e.g., empty position stack yielding [None])
                     if not term_position_pairs:
                         term_position_pairs = [(terminations[0], pos) for pos in positions]
 
-                    for term, pos in term_position_pairs:
-                        peer, new_pos = cable_profile.get_peer_termination(term, pos)
-                        if peer not in remote_terminations:
+                    peer_results = cable_profile.get_peer_terminations(term_position_pairs)
+                    seen = set()
+                    for peer, new_pos in peer_results:
+                        # Deduplicate peer terminations by model type & PK.
+                        key = None if peer is None else (peer._meta.concrete_model, peer.pk)
+                        if key not in seen:
+                            seen.add(key)
                             remote_terminations.append(peer)
                         new_positions.append(new_pos)
+
+                    # If all peers resolved to None (no far-end terminations exist),
+                    # treat as an empty result so the path is recorded as incomplete
+                    # rather than falling through to the endpoint check with a stale
+                    # None entry.
+                    if remote_terminations and all(peer is None for peer in remote_terminations):
+                        remote_terminations = []
+
                     position_stack.append(new_positions)
 
                 # Legacy (positionless) behavior
@@ -909,7 +958,9 @@ class CablePath(models.Model):
                     if not q_filter:
                         break
 
-                    remote_cable_terminations = CableTermination.objects.filter(q_filter)
+                    remote_cable_terminations = CableTermination.objects.filter(q_filter).prefetch_related(
+                        'termination'
+                    )
                     remote_terminations = [ct.termination for ct in remote_cable_terminations]
             else:
                 # WirelessLink

+ 75 - 32
netbox/dcim/models/device_component_templates.py

@@ -144,6 +144,9 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
                 name='%(app_label)s_%(class)s_unique_module_type_name'
             ),
         )
+        indexes = (
+            models.Index(fields=('device_type', 'module_type', 'name')),  # Default ordering
+        )
 
     def to_objectchange(self, action):
         objectchange = super().to_objectchange(action)
@@ -166,15 +169,47 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
                 _("A component template must be associated with either a device type or a module type.")
             )
 
-    def resolve_name(self, module):
-        if MODULE_TOKEN not in self.name or not module:
-            return self.name
-        return resolve_module_placeholder(self.name, get_module_bay_positions(module.module_bay))
+    @staticmethod
+    def _resolve_vc_position(value: str, device) -> str:
+        """
+        Resolves {vc_position} and {vc_position:X} tokens.
 
-    def resolve_label(self, module):
-        if MODULE_TOKEN not in self.label or not module:
-            return self.label
-        return resolve_module_placeholder(self.label, get_module_bay_positions(module.module_bay))
+        If the device has a vc_position, replaces the token with that value.
+        Otherwise uses the explicit fallback X if given, else '0'.
+        """
+        def replacer(match):
+            explicit_fallback = match.group(1)
+            if (
+                device is not None
+                and device.virtual_chassis is not None
+                and device.vc_position is not None
+            ):
+                return str(device.vc_position)
+            return explicit_fallback if explicit_fallback is not None else '0'
+
+        return VC_POSITION_RE.sub(replacer, value)
+
+    def _resolve_all_placeholders(self, value, module=None, device=None):
+        has_module = MODULE_TOKEN in value
+        has_vc = VC_POSITION_RE.search(value) is not None
+        if not has_module and not has_vc:
+            return value
+        if has_module and module:
+            positions = get_module_bay_positions(module.module_bay)
+            value = resolve_module_placeholder(value, positions)
+        if has_vc:
+            resolved_device = (module.device if module else None) or device
+            value = self._resolve_vc_position(value, resolved_device)
+        return value
+
+    def resolve_name(self, module=None, device=None):
+        return self._resolve_all_placeholders(self.name, module, device)
+
+    def resolve_label(self, module=None, device=None):
+        return self._resolve_all_placeholders(self.label, module, device)
+
+    def resolve_position(self, module=None, device=None):
+        return self._resolve_all_placeholders(self.position, module, device)
 
 
 class ConsolePortTemplate(ModularComponentTemplateModel):
@@ -197,8 +232,8 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
 
     def instantiate(self, **kwargs):
         return self.component_model(
-            name=self.resolve_name(kwargs.get('module')),
-            label=self.resolve_label(kwargs.get('module')),
+            name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+            label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
             type=self.type,
             **kwargs
         )
@@ -232,8 +267,8 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
 
     def instantiate(self, **kwargs):
         return self.component_model(
-            name=self.resolve_name(kwargs.get('module')),
-            label=self.resolve_label(kwargs.get('module')),
+            name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+            label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
             type=self.type,
             **kwargs
         )
@@ -282,8 +317,8 @@ class PowerPortTemplate(ModularComponentTemplateModel):
 
     def instantiate(self, **kwargs):
         return self.component_model(
-            name=self.resolve_name(kwargs.get('module')),
-            label=self.resolve_label(kwargs.get('module')),
+            name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+            label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
             type=self.type,
             maximum_draw=self.maximum_draw,
             allocated_draw=self.allocated_draw,
@@ -370,13 +405,13 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
 
     def instantiate(self, **kwargs):
         if self.power_port:
-            power_port_name = self.power_port.resolve_name(kwargs.get('module'))
+            power_port_name = self.power_port.resolve_name(kwargs.get('module'), kwargs.get('device'))
             power_port = PowerPort.objects.get(name=power_port_name, **kwargs)
         else:
             power_port = None
         return self.component_model(
-            name=self.resolve_name(kwargs.get('module')),
-            label=self.resolve_label(kwargs.get('module')),
+            name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+            label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
             type=self.type,
             color=self.color,
             power_port=power_port,
@@ -476,8 +511,8 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
 
     def instantiate(self, **kwargs):
         return self.component_model(
-            name=self.resolve_name(kwargs.get('module')),
-            label=self.resolve_label(kwargs.get('module')),
+            name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+            label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
             type=self.type,
             enabled=self.enabled,
             mgmt_only=self.mgmt_only,
@@ -611,8 +646,8 @@ class FrontPortTemplate(ModularComponentTemplateModel):
 
     def instantiate(self, **kwargs):
         return self.component_model(
-            name=self.resolve_name(kwargs.get('module')),
-            label=self.resolve_label(kwargs.get('module')),
+            name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+            label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
             type=self.type,
             color=self.color,
             positions=self.positions,
@@ -675,8 +710,8 @@ class RearPortTemplate(ModularComponentTemplateModel):
 
     def instantiate(self, **kwargs):
         return self.component_model(
-            name=self.resolve_name(kwargs.get('module')),
-            label=self.resolve_label(kwargs.get('module')),
+            name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+            label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
             type=self.type,
             color=self.color,
             positions=self.positions,
@@ -705,6 +740,10 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
         blank=True,
         help_text=_('Identifier to reference when renaming installed components')
     )
+    enabled = models.BooleanField(
+        verbose_name=_('enabled'),
+        default=True,
+    )
 
     component_model = ModuleBay
 
@@ -712,16 +751,12 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
         verbose_name = _('module bay template')
         verbose_name_plural = _('module bay templates')
 
-    def resolve_position(self, module):
-        if MODULE_TOKEN not in self.position or not module:
-            return self.position
-        return resolve_module_placeholder(self.position, get_module_bay_positions(module.module_bay))
-
     def instantiate(self, **kwargs):
         return self.component_model(
-            name=self.resolve_name(kwargs.get('module')),
-            label=self.resolve_label(kwargs.get('module')),
-            position=self.resolve_position(kwargs.get('module')),
+            name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+            label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
+            position=self.resolve_position(kwargs.get('module'), kwargs.get('device')),
+            enabled=self.enabled,
             **kwargs
         )
     instantiate.do_not_call_in_templates = True
@@ -731,6 +766,7 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
             'name': self.name,
             'label': self.label,
             'position': self.position,
+            'enabled': self.enabled,
             'description': self.description,
         }
 
@@ -739,6 +775,11 @@ class DeviceBayTemplate(ComponentTemplateModel):
     """
     A template for a DeviceBay to be created for a new parent Device.
     """
+    enabled = models.BooleanField(
+        verbose_name=_('enabled'),
+        default=True,
+    )
+
     component_model = DeviceBay
 
     class Meta(ComponentTemplateModel.Meta):
@@ -749,7 +790,8 @@ class DeviceBayTemplate(ComponentTemplateModel):
         return self.component_model(
             device=device,
             name=self.name,
-            label=self.label
+            label=self.label,
+            enabled=self.enabled,
         )
     instantiate.do_not_call_in_templates = True
 
@@ -765,6 +807,7 @@ class DeviceBayTemplate(ComponentTemplateModel):
         return {
             'name': self.name,
             'label': self.label,
+            'enabled': self.enabled,
             'description': self.description,
         }
 

+ 36 - 4
netbox/dcim/models/device_components.py

@@ -882,8 +882,8 @@ class Interface(
         verbose_name=_('wireless channel')
     )
     rf_channel_frequency = models.DecimalField(
-        max_digits=7,
-        decimal_places=2,
+        max_digits=8,
+        decimal_places=3,
         blank=True,
         null=True,
         verbose_name=_('channel frequency (MHz)'),
@@ -1332,10 +1332,14 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
         blank=True,
         help_text=_('Identifier to reference when renaming installed components')
     )
+    enabled = models.BooleanField(
+        verbose_name=_('enabled'),
+        default=True,
+    )
 
     objects = TreeManager()
 
-    clone_fields = ('device',)
+    clone_fields = ('device', 'enabled')
 
     class Meta(ModularComponentModel.Meta):
         # Empty tuple triggers Django migration detection for MPTT indexes
@@ -1374,6 +1378,13 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
             self.parent = None
         super().save(*args, **kwargs)
 
+    @property
+    def _occupied(self):
+        """
+        Indicates whether the module bay is occupied by a module.
+        """
+        return bool(not self.enabled or hasattr(self, 'installed_module'))
+
 
 class DeviceBay(ComponentModel, TrackingModelMixin):
     """
@@ -1386,8 +1397,12 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
         blank=True,
         null=True
     )
+    enabled = models.BooleanField(
+        verbose_name=_('enabled'),
+        default=True,
+    )
 
-    clone_fields = ('device',)
+    clone_fields = ('device', 'enabled')
 
     class Meta(ComponentModel.Meta):
         verbose_name = _('device bay')
@@ -1402,6 +1417,16 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
                 device_type=self.device.device_type
             ))
 
+        # Prevent installing a device into a disabled bay
+        if self.installed_device and not self.enabled:
+            current_installed_device_id = (
+                DeviceBay.objects.filter(pk=self.pk).values_list('installed_device_id', flat=True).first()
+            )
+            if self.pk is None or current_installed_device_id != self.installed_device_id:
+                raise ValidationError({
+                    'installed_device': _("Cannot install a device in a disabled device bay.")
+                })
+
         # Cannot install a device into itself, obviously
         if self.installed_device and getattr(self, 'device', None) == self.installed_device:
             raise ValidationError(_("Cannot install a device into itself."))
@@ -1416,6 +1441,13 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
                     ).format(bay=current_bay)
                 })
 
+    @property
+    def _occupied(self):
+        """
+        Indicates whether the device bay is occupied by a child device.
+        """
+        return bool(not self.enabled or self.installed_device_id)
+
 
 #
 # Inventory items

+ 40 - 2
netbox/dcim/models/devices.py

@@ -26,6 +26,7 @@ from netbox.config import ConfigItem
 from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from netbox.models.mixins import WeightMixin
+from utilities.exceptions import AbortRequest
 from utilities.fields import ColorField, CounterCacheField
 from utilities.prefetch import get_prefetchable_fields
 from utilities.tracking import TrackingModelMixin
@@ -745,6 +746,9 @@ class Device(
 
     class Meta:
         ordering = ('name', 'pk')  # Name may be null
+        indexes = (
+            models.Index(fields=('name', 'id')),  # Default ordering
+        )
         constraints = (
             models.UniqueConstraint(
                 Lower('name'), 'site', 'tenant',
@@ -767,6 +771,9 @@ class Device(
         )
         verbose_name = _('device')
         verbose_name_plural = _('devices')
+        permissions = [
+            ('render_config', 'Render configuration'),
+        ]
 
     def __str__(self):
         if self.label and self.asset_tag:
@@ -957,6 +964,20 @@ class Device(
                 ).format(virtual_chassis=self.vc_master_for)
             })
 
+    def _check_duplicate_component_names(self, components):
+        """
+        Check for duplicate component names after resolving {vc_position} placeholders.
+        Raises AbortRequest if duplicates are found.
+        """
+        names = [c.name for c in components]
+        duplicates = {n for n in names if names.count(n) > 1}
+        if duplicates:
+            raise AbortRequest(
+                _("Component name conflict after resolving {{vc_position}}: {names}").format(
+                    names=', '.join(duplicates)
+                )
+            )
+
     def _instantiate_components(self, queryset, bulk_create=True):
         """
         Instantiate components for the device from the specified component templates.
@@ -971,6 +992,10 @@ class Device(
             components = [obj.instantiate(device=self) for obj in queryset]
             if not components:
                 return
+
+            # Check for duplicate names after resolution {vc_position}
+            self._check_duplicate_component_names(components)
+
             # Set default values for any applicable custom fields
             if cf_defaults := CustomField.objects.get_defaults_for_model(model):
                 for component in components:
@@ -995,8 +1020,14 @@ class Device(
                     update_fields=None
                 )
         else:
-            for obj in queryset:
-                component = obj.instantiate(device=self)
+            components = [obj.instantiate(device=self) for obj in queryset]
+            if not components:
+                return
+
+            # Check for duplicate names after resolution {vc_position}
+            self._check_duplicate_component_names(components)
+
+            for component in components:
                 # Set default values for any applicable custom fields
                 if cf_defaults := CustomField.objects.get_defaults_for_model(model):
                     component.custom_field_data = cf_defaults
@@ -1168,6 +1199,9 @@ class VirtualChassis(PrimaryModel):
 
     class Meta:
         ordering = ['name']
+        indexes = (
+            models.Index(fields=('name',)),  # Default ordering
+        )
         verbose_name = _('virtual chassis')
         verbose_name_plural = _('virtual chassis')
 
@@ -1274,6 +1308,9 @@ class VirtualDeviceContext(PrimaryModel):
                 name='%(app_label)s_%(class)s_device_name'
             ),
         )
+        indexes = (
+            models.Index(fields=('name',)),  # Default ordering
+        )
         verbose_name = _('virtual device context')
         verbose_name_plural = _('virtual device contexts')
 
@@ -1340,6 +1377,7 @@ class MACAddress(PrimaryModel):
     class Meta:
         ordering = ('mac_address', 'pk')
         indexes = (
+            models.Index(fields=('mac_address', 'id')),  # Default ordering
             models.Index(fields=('assigned_object_type', 'assigned_object_id')),
         )
         verbose_name = _('MAC address')

+ 11 - 0
netbox/dcim/models/modules.py

@@ -113,6 +113,9 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
                 name='%(app_label)s_%(class)s_unique_manufacturer_model'
             ),
         )
+        indexes = (
+            models.Index(fields=('profile', 'manufacturer', 'model')),  # Default ordering
+        )
         verbose_name = _('module type')
         verbose_name_plural = _('module types')
 
@@ -266,6 +269,14 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
                 )
             )
 
+        # Prevent module from being installed in a disabled bay
+        if hasattr(self, 'module_bay') and self.module_bay and not self.module_bay.enabled:
+            current_module_bay_id = Module.objects.filter(pk=self.pk).values_list('module_bay_id', flat=True).first()
+            if self.pk is None or current_module_bay_id != self.module_bay_id:
+                raise ValidationError({
+                    'module_bay': _("Cannot install a module in a disabled module bay.")
+                })
+
         # Check for recursion
         module = self
         module_bays = []

+ 35 - 1
netbox/dcim/models/racks.py

@@ -29,14 +29,30 @@ from .power import PowerFeed
 
 __all__ = (
     'Rack',
+    'RackGroup',
     'RackReservation',
     'RackRole',
     'RackType',
 )
 
+#
+# Rack Organization
+#
+
+
+class RackGroup(OrganizationalModel):
+    """
+    Racks can be grouped by physical placement within a Location.
+    """
+
+    class Meta:
+        ordering = ('name',)
+        verbose_name = _('rack group')
+        verbose_name_plural = _('rack groups')
+
 
 #
-# Rack Types
+# Rack Base
 #
 
 class RackBase(WeightMixin, PrimaryModel):
@@ -123,6 +139,10 @@ class RackBase(WeightMixin, PrimaryModel):
         abstract = True
 
 
+#
+# Rack Types
+#
+
 class RackType(ImageAttachmentsMixin, RackBase):
     """
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
@@ -290,6 +310,14 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
         blank=True,
         null=True
     )
+    group = models.ForeignKey(
+        to='dcim.RackGroup',
+        on_delete=models.PROTECT,
+        related_name='racks',
+        blank=True,
+        null=True,
+        help_text=_('physical grouping')
+    )
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         on_delete=models.PROTECT,
@@ -362,6 +390,9 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
                 name='%(app_label)s_%(class)s_unique_location_facility_id'
             ),
         )
+        indexes = (
+            models.Index(fields=('site', 'location', 'name', 'id')),  # Default ordering
+        )
         verbose_name = _('rack')
         verbose_name_plural = _('racks')
 
@@ -710,6 +741,9 @@ class RackReservation(PrimaryModel):
 
     class Meta:
         ordering = ['created', 'pk']
+        indexes = (
+            models.Index(fields=('created', 'id')),  # Default ordering
+        )
         verbose_name = _('rack reservation')
         verbose_name_plural = _('rack reservations')
 

+ 23 - 0
netbox/dcim/search.py

@@ -3,6 +3,17 @@ from netbox.search import SearchIndex, register_search
 from . import models
 
 
+@register_search
+class CableBundleIndex(SearchIndex):
+    model = models.CableBundle
+    fields = (
+        ('name', 100),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('description',)
+
+
 @register_search
 class CableIndex(SearchIndex):
     model = models.Cable
@@ -315,6 +326,18 @@ class RackReservationIndex(SearchIndex):
     display_attrs = ('rack', 'tenant', 'user', 'description')
 
 
+@register_search
+class RackGroupIndex(SearchIndex):
+    model = models.RackGroup
+    fields = (
+        ('name', 100),
+        ('slug', 110),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('description',)
+
+
 @register_search
 class RackRoleIndex(SearchIndex):
     model = models.RackRole

+ 29 - 2
netbox/dcim/tables/cables.py

@@ -4,13 +4,14 @@ from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 from django_tables2.utils import Accessor
 
-from dcim.models import Cable
+from dcim.models import Cable, CableBundle
 from netbox.tables import PrimaryModelTable, columns
 from tenancy.tables import TenancyColumnsMixin
 
 from .template_code import CABLE_LENGTH
 
 __all__ = (
+    'CableBundleTable',
     'CableTable',
 )
 
@@ -119,6 +120,10 @@ class CableTable(TenancyColumnsMixin, PrimaryModelTable):
         verbose_name=_('Color Name'),
         orderable=False
     )
+    bundle = tables.Column(
+        verbose_name=_('Bundle'),
+        linkify=True,
+    )
     tags = columns.TagColumn(
         url_name='dcim:cable_list'
     )
@@ -128,8 +133,30 @@ class CableTable(TenancyColumnsMixin, PrimaryModelTable):
         fields = (
             'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
             'location_a', 'location_b', 'site_a', 'site_b', 'status', 'profile', 'type', 'tenant', 'tenant_group',
-            'color', 'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
+            'color', 'color_name', 'bundle', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',
         )
+
+
+class CableBundleTable(PrimaryModelTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True,
+    )
+    cable_count = tables.Column(
+        verbose_name=_('Cables'),
+    )
+    tags = columns.TagColumn(
+        url_name='dcim:cablebundle_list'
+    )
+
+    class Meta(PrimaryModelTable.Meta):
+        model = CableBundle
+        fields = (
+            'pk', 'id', 'name', 'cable_count', 'description', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'id', 'name', 'cable_count', 'description',
+        )

+ 23 - 11
netbox/dcim/tables/devices.py

@@ -908,6 +908,9 @@ class DeviceBayTable(DeviceComponentTable):
             'args': [Accessor('device_id')],
         }
     )
+    enabled = columns.BooleanColumn(
+        verbose_name=_('Enabled'),
+    )
     status = tables.TemplateColumn(
         verbose_name=_('Status'),
         template_code=DEVICEBAY_STATUS,
@@ -945,12 +948,12 @@ class DeviceBayTable(DeviceComponentTable):
     class Meta(DeviceComponentTable.Meta):
         model = models.DeviceBay
         fields = (
-            'pk', 'id', 'name', 'device', 'label', 'status', 'description', 'installed_device', 'installed_role',
-            'installed_device_type', 'installed_description', 'installed_serial', 'installed_asset_tag', 'tags',
-            'created', 'last_updated',
+            'pk', 'id', 'name', 'device', 'label', 'enabled', 'status', 'description', 'installed_device',
+            'installed_role', 'installed_device_type', 'installed_description', 'installed_serial',
+            'installed_asset_tag', 'tags', 'created', 'last_updated',
         )
 
-        default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
+        default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'status', 'installed_device', 'description')
 
 
 class DeviceDeviceBayTable(DeviceBayTable):
@@ -960,6 +963,9 @@ class DeviceDeviceBayTable(DeviceBayTable):
                       '"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
         attrs={'td': {'class': 'text-nowrap'}}
     )
+    enabled = columns.BooleanColumn(
+        verbose_name=_('Enabled'),
+    )
     actions = columns.ActionsColumn(
         extra_buttons=DEVICEBAY_BUTTONS
     )
@@ -967,9 +973,9 @@ class DeviceDeviceBayTable(DeviceBayTable):
     class Meta(DeviceComponentTable.Meta):
         model = models.DeviceBay
         fields = (
-            'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
+            'pk', 'id', 'name', 'label', 'enabled', 'status', 'installed_device', 'description', 'tags', 'actions',
         )
-        default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description')
+        default_columns = ('pk', 'name', 'label', 'enabled', 'status', 'installed_device', 'description')
 
 
 class ModuleBayTable(ModularDeviceComponentTable):
@@ -980,6 +986,9 @@ class ModuleBayTable(ModularDeviceComponentTable):
             'args': [Accessor('device_id')],
         }
     )
+    enabled = columns.BooleanColumn(
+        verbose_name=_('Enabled'),
+    )
     parent = tables.Column(
         linkify=True,
         verbose_name=_('Parent'),
@@ -1008,11 +1017,11 @@ class ModuleBayTable(ModularDeviceComponentTable):
     class Meta(ModularDeviceComponentTable.Meta):
         model = models.ModuleBay
         fields = (
-            'pk', 'id', 'name', 'device', 'parent', 'label', 'position', 'installed_module', 'module_status',
+            'pk', 'id', 'name', 'device', 'enabled', 'parent', 'label', 'position', 'installed_module', 'module_status',
             'module_serial', 'module_asset_tag', 'description', 'tags',
         )
         default_columns = (
-            'pk', 'name', 'device', 'parent', 'label', 'installed_module', 'module_status', 'description',
+            'pk', 'name', 'device', 'enabled', 'parent', 'label', 'installed_module', 'module_status', 'description',
         )
 
     def render_parent_bay(self, value):
@@ -1027,6 +1036,9 @@ class DeviceModuleBayTable(ModuleBayTable):
         verbose_name=_('Name'),
         linkify=True,
     )
+    enabled = columns.BooleanColumn(
+        verbose_name=_('Enabled'),
+    )
     actions = columns.ActionsColumn(
         extra_buttons=MODULEBAY_BUTTONS
     )
@@ -1034,10 +1046,10 @@ class DeviceModuleBayTable(ModuleBayTable):
     class Meta(ModuleBayTable.Meta):
         model = models.ModuleBay
         fields = (
-            'pk', 'id', 'parent', 'name', 'label', 'position', 'installed_module', 'module_status', 'module_serial',
-            'module_asset_tag', 'description', 'tags', 'actions',
+            'pk', 'id', 'parent', 'name', 'label', 'enabled', 'position', 'installed_module', 'module_status',
+            'module_serial', 'module_asset_tag', 'description', 'tags', 'actions',
         )
-        default_columns = ('pk', 'name', 'label', 'installed_module', 'module_status', 'description')
+        default_columns = ('pk', 'name', 'label', 'enabled', 'installed_module', 'module_status', 'description')
 
 
 class InventoryItemTable(DeviceComponentTable):

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