Przeglądaj źródła

Merge branch 'refs/heads/main' into 20490-restrict-script-permissions

Brian Tiemann 1 dzień temu
rodzic
commit
1dfd1a5db4
100 zmienionych plików z 4060 dodań i 2233 usunięć
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 1 1
      .github/ISSUE_TEMPLATE/03-performance.yaml
  4. 4 5
      base_requirements.txt
  5. 828 33
      contrib/openapi.json
  6. 12 3
      docs/configuration/data-validation.md
  7. 42 0
      docs/configuration/required-parameters.md
  8. 1 1
      docs/development/release-checklist.md
  9. 49 5
      docs/integrations/graphql-api.md
  10. 85 0
      docs/release-notes/version-4.5.md
  11. 40 3
      netbox/circuits/filtersets.py
  12. 2 2
      netbox/circuits/forms/model_forms.py
  13. 16 1
      netbox/core/data_backends.py
  14. 15 5
      netbox/core/filtersets.py
  15. 1 0
      netbox/core/models/files.py
  16. 2 0
      netbox/core/models/jobs.py
  17. 24 17
      netbox/core/signals.py
  18. 116 0
      netbox/core/tests/test_data_backends.py
  19. 2 2
      netbox/core/tests/test_filtersets.py
  20. 19 2
      netbox/core/views.py
  21. 4 2
      netbox/dcim/base_filtersets.py
  22. 173 23
      netbox/dcim/filtersets.py
  23. 49 0
      netbox/dcim/migrations/0226_add_mptt_tree_indexes.py
  24. 10 0
      netbox/dcim/models/cables.py
  25. 3 0
      netbox/dcim/models/device_components.py
  26. 6 0
      netbox/dcim/models/devices.py
  27. 1 1
      netbox/dcim/models/racks.py
  28. 9 0
      netbox/dcim/models/sites.py
  29. 2 0
      netbox/dcim/signals.py
  30. 48 26
      netbox/dcim/tables/devices.py
  31. 4 1
      netbox/dcim/tests/test_cablepaths.py
  32. 4 6
      netbox/dcim/tests/test_filtersets.py
  33. 11 3
      netbox/dcim/ui/panels.py
  34. 2 1
      netbox/dcim/views.py
  35. 3 9
      netbox/extras/api/customfields.py
  36. 2 1
      netbox/extras/dashboard/widgets.py
  37. 49 23
      netbox/extras/events.py
  38. 51 20
      netbox/extras/filtersets.py
  39. 14 1
      netbox/extras/models/customfields.py
  40. 2 0
      netbox/extras/models/scripts.py
  41. 2 1
      netbox/extras/signals.py
  42. 13 2
      netbox/extras/tables/tables.py
  43. 1 1
      netbox/extras/tests/test_conditions.py
  44. 33 2
      netbox/extras/tests/test_event_rules.py
  45. 17 17
      netbox/extras/tests/test_filtersets.py
  46. 1 1
      netbox/extras/tests/test_models.py
  47. 1 1
      netbox/extras/tests/test_utils.py
  48. 52 9
      netbox/ipam/filtersets.py
  49. 3 2
      netbox/ipam/migrations/0070_vlangroup_vlan_id_ranges.py
  50. 3 1
      netbox/ipam/models/services.py
  51. 2 2
      netbox/ipam/tests/test_filtersets.py
  52. 2 1
      netbox/netbox/api/fields.py
  53. 2 1
      netbox/netbox/api/serializers/base.py
  54. 36 3
      netbox/netbox/api/viewsets/__init__.py
  55. 9 5
      netbox/netbox/api/viewsets/mixins.py
  56. 6 11
      netbox/netbox/filtersets.py
  57. 5 4
      netbox/netbox/forms/bulk_import.py
  58. 7 5
      netbox/netbox/forms/filtersets.py
  59. 5 3
      netbox/netbox/forms/mixins.py
  60. 50 0
      netbox/netbox/graphql/pagination.py
  61. 4 0
      netbox/netbox/models/__init__.py
  62. 16 5
      netbox/netbox/models/features.py
  63. 6 4
      netbox/netbox/search/backends.py
  64. 31 11
      netbox/netbox/settings.py
  65. 8 5
      netbox/netbox/tables/tables.py
  66. 143 20
      netbox/netbox/tests/test_graphql.py
  67. 61 1
      netbox/netbox/tests/test_model_features.py
  68. 1 1
      netbox/netbox/ui/attrs.py
  69. 5 7
      netbox/netbox/views/generic/bulk_views.py
  70. 0 0
      netbox/project-static/dist/netbox.css
  71. 0 0
      netbox/project-static/dist/netbox.js
  72. 0 0
      netbox/project-static/dist/netbox.js.map
  73. 7 7
      netbox/project-static/package.json
  74. 9 7
      netbox/project-static/src/bs.ts
  75. 23 0
      netbox/project-static/styles/custom/_misc.scss
  76. 10 0
      netbox/project-static/styles/overrides/_tabler.scss
  77. 258 242
      netbox/project-static/yarn.lock
  78. 2 2
      netbox/release.yaml
  79. 1 1
      netbox/templates/base/layout.html
  80. 1 1
      netbox/templates/core/configrevision.html
  81. 3 3
      netbox/templates/core/inc/config_data.html
  82. 1 0
      netbox/templates/dcim/devicetype/attrs/height.html
  83. 34 0
      netbox/templates/virtualization/panels/virtual_machine_resources.html
  84. 0 198
      netbox/templates/virtualization/virtualmachine.html
  85. 10 0
      netbox/templates/virtualization/virtualmachine/attrs/ipaddress.html
  86. 2 2
      netbox/templates/virtualization/vminterface.html
  87. 11 2
      netbox/tenancy/filtersets.py
  88. 23 0
      netbox/tenancy/migrations/0023_add_mptt_tree_indexes.py
  89. 3 0
      netbox/tenancy/models/contacts.py
  90. 3 0
      netbox/tenancy/models/tenants.py
  91. 2 0
      netbox/tenancy/tests/test_filtersets.py
  92. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  93. 294 297
      netbox/translations/cs/LC_MESSAGES/django.po
  94. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  95. 292 297
      netbox/translations/da/LC_MESSAGES/django.po
  96. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  97. 294 299
      netbox/translations/de/LC_MESSAGES/django.po
  98. 247 250
      netbox/translations/en/LC_MESSAGES/django.po
  99. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  100. 297 300
      netbox/translations/es/LC_MESSAGES/django.po

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

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

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

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

+ 1 - 1
.github/ISSUE_TEMPLATE/03-performance.yaml

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

+ 4 - 5
base_requirements.txt

@@ -27,9 +27,7 @@ django-graphiql-debug-toolbar
 django-htmx
 
 # Modified Preorder Tree Traversal (recursive nesting of objects)
-# https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst
-# v0.18.0 introduces errant migrations which need to be resolved
-django-mptt==0.17.0
+django-mptt
 
 # Context managers for PostgreSQL advisory locks
 # https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
@@ -85,7 +83,7 @@ drf-spectacular-sidecar
 feedparser
 
 # WSGI HTTP server
-# https://docs.gunicorn.org/en/latest/news.html
+# https://gunicorn.org/news/
 gunicorn
 
 # Platform-agnostic template rendering engine
@@ -159,7 +157,8 @@ strawberry-graphql
 
 # Strawberry GraphQL Django extension
 # https://github.com/strawberry-graphql/strawberry-django/releases
-strawberry-graphql-django
+# Blocked by #21450
+strawberry-graphql-django==0.75.0
 
 # SVG image rendering (used for rack elevations)
 # https://github.com/mozman/svgwrite/blob/master/NEWS.rst

Plik diff jest za duży
+ 828 - 33
contrib/openapi.json


+ 12 - 3
docs/configuration/data-validation.md

@@ -8,7 +8,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
 
 ```python
 CUSTOM_VALIDATORS = {
-    "dcim.site": [
+    "dcim.Site": [
         {
             "name": {
                 "min_length": 5,
@@ -17,12 +17,15 @@ CUSTOM_VALIDATORS = {
         },
         "my_plugin.validators.Validator1"
     ],
-    "dcim.device": [
+    "dcim.Device": [
         "my_plugin.validators.Validator1"
     ]
 }
 ```
 
+!!! info "Case-Insensitive Model Names"
+    Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.
+
 ---
 
 ## FIELD_CHOICES
@@ -53,6 +56,9 @@ FIELD_CHOICES = {
 }
 ```
 
+!!! info "Case-Insensitive Field Identifiers"
+    Field identifiers are case-insensitive. Both `dcim.Site.status` and `dcim.site.status` are valid and equivalent.
+
 The following model fields support configurable choices:
 
 * `circuits.Circuit.status`
@@ -98,7 +104,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
 
 ```python
 PROTECTION_RULES = {
-    "dcim.site": [
+    "dcim.Site": [
         {
             "status": {
                 "eq": "decommissioning"
@@ -108,3 +114,6 @@ PROTECTION_RULES = {
     ]
 }
 ```
+
+!!! info "Case-Insensitive Model Names"
+    Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.

+ 42 - 0
docs/configuration/required-parameters.md

@@ -200,6 +200,48 @@ REDIS = {
 !!! note
     It is permissible to use Sentinel for only one database and not the other.
 
+### SSL Configuration
+
+If you need to configure SSL/TLS for Redis beyond the basic `SSL`, `CA_CERT_PATH`, and `INSECURE_SKIP_TLS_VERIFY` options (for example, client certificates, a specific TLS version, or custom ciphers), you can pass additional parameters via the `KWARGS` key in either the `tasks` or `caching` subsection.
+
+NetBox already maps `CA_CERT_PATH` to `ssl_ca_certs` and (for caching) `INSECURE_SKIP_TLS_VERIFY` to `ssl_cert_reqs`; only add `KWARGS` when you need to override or extend those settings (for example, to supply client certificates or restrict TLS version or ciphers).
+
+* `KWARGS` - Optional dictionary of additional SSL/TLS (or other) parameters passed to the Redis client. These are passed directly to the underlying Redis client: for `tasks` to [redis-py](https://redis-py.readthedocs.io/en/stable/connections.html), and for `caching` to the [django-redis](https://github.com/jazzband/django-redis#configure-as-cache-backend) connection pool.
+
+Example:
+
+```python
+REDIS = {
+    'tasks': {
+        'HOST': 'redis.example.com',
+        'PORT': 1234,
+        'SSL': True,
+        'CA_CERT_PATH': '/etc/ssl/certs/ca.crt',
+        'KWARGS': {
+            'ssl_certfile': '/path/to/client-cert.pem',
+            'ssl_keyfile': '/path/to/client-key.pem',
+            'ssl_min_version': ssl.TLSVersion.TLSv1_2,
+            'ssl_ciphers': 'HIGH:!aNULL',
+        },
+    },
+    'caching': {
+        'HOST': 'redis.example.com',
+        'PORT': 1234,
+        'SSL': True,
+        'CA_CERT_PATH': '/etc/ssl/certs/ca.crt',
+        'KWARGS': {
+            'ssl_certfile': '/path/to/client-cert.pem',
+            'ssl_keyfile': '/path/to/client-key.pem',
+            'ssl_min_version': ssl.TLSVersion.TLSv1_2,
+            'ssl_ciphers': 'HIGH:!aNULL',
+        },
+    }
+}
+```
+
+!!! note
+    If you use `ssl.TLSVersion` in your configuration (e.g. `ssl_min_version`), add `import ssl` at the top of your configuration file.
+
 ---
 
 ## SECRET_KEY

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

@@ -144,7 +144,7 @@ Then, compile these portable (`.po`) files for use in the application:
 
 * Update the version number and published date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
 * Copy the version number from `release.yaml` to `pyproject.toml` in the project root.
-* Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`.
+* Update the example version numbers in the feature request, bug report, and performance templates under `.github/ISSUE_TEMPLATES/`.
 * Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
 
 !!! tip

+ 49 - 5
docs/integrations/graphql-api.md

@@ -133,24 +133,68 @@ The field "class_type" is an easy way to distinguish what type of object it is w
 
 ## Pagination
 
-Queries can be paginated by specifying pagination in the query and supplying an offset and optionaly a limit in the query.  If no limit is given, a default of 100 is used.  Queries are not paginated unless requested in the query. An example paginated query is shown below:
+The GraphQL API supports two types of pagination. Offset-based pagination operates using an offset relative to the first record in a set, specified by the `offset` parameter. For example, the response to a request specifying an offset of 100 will contain the 101st and later matching records. Offset-based pagination feels very natural, but its performance can suffer when dealing with large data sets due to the overhead involved in calculating the relative offset.
+
+The alternative approach is cursor-based pagination, which operates using absolute (rather than relative) primary key values. (These are the numeric IDs assigned to each object in the database.) When using cursor-based pagination, the response will contain records with a primary key greater than or equal to the specified start value, up to the maximum number of results. This strategy requires keeping track of the last seen primary key from each response when paginating through data, but is extremely performant. The cursor is specified by passing the starting object ID via the `start` parameter.
+
+To ensure consistent ordering, objects will always be ordered by their primary keys when cursor-based pagination is used.
+
+!!! note "Cursor-based pagination was introduced in NetBox v4.5.2."
+
+Both pagination strategies support passing an optional `limit` parameter. In both approaches, this specifies the maximum number of objects to include in the response. If no limit is specified, a default value of 100 is used.
+
+### Offset Pagination
+
+The first page will have an `offset` of zero, or the `offset` parameter will be omitted:
 
 ```
 query {
-  device_list(pagination: { offset: 0, limit: 20 }) {
+  device_list(pagination: {offset: 0, limit: 20}) {
     id
   }
 }
 ```
 
-## Authentication
+The second page will have an offset equal to the size of the first page. If the number of records is less than the specified limit, there are no more records to process. For example, if a request specifies a `limit` of 20 but returns only 13 records, we can conclude that this is the final page of records.
+
+```
+query {
+  device_list(pagination: {offset: 20, limit: 20}) {
+    id
+  }
+}
+```
+
+### Cursor Pagination
+
+Set the `start` value to zero to fetch the first page. Note that if the `start` parameter is omitted, offset-based pagination will be used by default.
 
-NetBox's GraphQL API uses the same API authentication tokens as its REST API. Authentication tokens are included with requests by attaching an `Authorization` HTTP header in the following form:
+```
+query {
+  device_list(pagination: {start: 0, limit: 20}) {
+    id
+  }
+}
+```
+
+To determine the `start` value for the next page, add 1 to the primary key (`id`) of the last record in the previous page.
+
+For example, if the ID of the last record in the previous response was 123, we would specify a `start` value of 124:
 
 ```
-Authorization: Token $TOKEN
+query {
+  device_list(pagination: {start: 124, limit: 20}) {
+    id
+  }
+}
 ```
 
+This will return up to 20 records with an ID greater than or equal to 124.
+
+## Authentication
+
+NetBox's GraphQL API uses the same API authentication tokens as its REST API. See the [REST API authentication](./rest-api.md#authentication) documentation for further detail.
+
 ## Disabling the GraphQL API
 
 If not needed, the GraphQL API can be disabled by setting the [`GRAPHQL_ENABLED`](../configuration/graphql-api.md#graphql_enabled) configuration parameter to False and restarting NetBox.

+ 85 - 0
docs/release-notes/version-4.5.md

@@ -1,5 +1,90 @@
 # NetBox v4.5
 
+## v4.5.3 (2026-02-17)
+
+### Enhancements
+
+* [#19129](https://github.com/netbox-community/netbox/issues/19129) - Improve display of multiple MAC addresses within interfaces table
+* [#20981](https://github.com/netbox-community/netbox/issues/20981) - Enhance JSON rendering for custom validators and protection rules in config revision view
+* [#21240](https://github.com/netbox-community/netbox/issues/21240) - Add support for configuring Redis `KWARGS` parameters
+* [#21257](https://github.com/netbox-community/netbox/issues/21257) - `ContentTypeFilter` now accepts multiple values
+* [#21266](https://github.com/netbox-community/netbox/issues/21266) - Add table columns representing installed devices to the device bays table
+* [#21267](https://github.com/netbox-community/netbox/issues/21267) - Normalize device height formatting in rack units (display "0U")
+* [#21268](https://github.com/netbox-community/netbox/issues/21268) - Add device type details panel to device view
+* [#21337](https://github.com/netbox-community/netbox/issues/21337) - Show the assigned platform's parent on the virtual machine UI view
+
+### Performance Improvements
+
+* [#20211](https://github.com/netbox-community/netbox/issues/20211) - Use thumbnails for image attachment hover previews to improve page load performance
+* [#21016](https://github.com/netbox-community/netbox/issues/21016) - Restore missing SQL indexes for MPTT fields
+* [#21196](https://github.com/netbox-community/netbox/issues/21196) - `q` filter should match on primary IP only for IP address values when filtering devices/VMs
+* [#21420](https://github.com/netbox-community/netbox/issues/21420) - Improve query performance of `ContentTypeFilter`
+* [#21421](https://github.com/netbox-community/netbox/issues/21421) - Eliminate extraneous application of `DISTINCT` to queries for `MultipleChoiceFilter`
+
+### Bug Fixes
+
+* [#20435](https://github.com/netbox-community/netbox/issues/20435) - Fix navigation menu margin issue when scrollbar appears
+* [#21127](https://github.com/netbox-community/netbox/issues/21127) - Ensure assigned cable paths are cleared when removing terminations from a cable
+* [#21277](https://github.com/netbox-community/netbox/issues/21277) - Record pre-change snapshot when adding cluster members via UI
+* [#21320](https://github.com/netbox-community/netbox/issues/21320) - Avoid validation failures when site or optional fields are missing during rack import
+* [#21354](https://github.com/netbox-community/netbox/issues/21354) - Fix base URL in Swagger when `BASE_PATH` is set
+* [#21358](https://github.com/netbox-community/netbox/issues/21358) - Token list in UI cannot be ordered by token value
+* [#21371](https://github.com/netbox-community/netbox/issues/21371) - Fix `KeyError` exception when triggering a webhook from an event rule
+* [#21375](https://github.com/netbox-community/netbox/issues/21375) - Address failure condition in `ipam.0070_vlangroup_vlan_id_ranges` migration
+* [#21390](https://github.com/netbox-community/netbox/issues/21390) - Avoid creating "empty" changelog records for related objects when processing manyo-to-many relations
+* [#21397](https://github.com/netbox-community/netbox/issues/21397) - Correct rendering of owner field in CircuitType edit form
+* [#21412](https://github.com/netbox-community/netbox/issues/21412) - Avoid `AttributeError` exception on initialization when a plugin has local imports in `__init__.py`
+
+---
+
+## v4.5.2 (2026-02-03)
+
+### Enhancements
+
+* [#15801](https://github.com/netbox-community/netbox/issues/15801) - Add link peer and connection columns to the VLAN device interfaces table
+* [#19221](https://github.com/netbox-community/netbox/issues/19221) - Truncate long image attachment filenames in the UI
+* [#19869](https://github.com/netbox-community/netbox/issues/19869) - Display peer connections for LAG member interfaces
+* [#20052](https://github.com/netbox-community/netbox/issues/20052) - Increase logging level of error message when a custom script fails to load
+* [#20172](https://github.com/netbox-community/netbox/issues/20172) - Add `cabled` filter for interfaces in GraphQL API
+* [#21081](https://github.com/netbox-community/netbox/issues/21081) - Add owner group table columns & filters across all supported object list views
+* [#21088](https://github.com/netbox-community/netbox/issues/21088) - Add max depth and max length dropdowns for child prefix views
+* [#21110](https://github.com/netbox-community/netbox/issues/21110) - Support cursor-based pagination in GraphQL API
+* [#21201](https://github.com/netbox-community/netbox/issues/21201) - Pre-populate GenericForeignKey form fields when cloning
+* [#21209](https://github.com/netbox-community/netbox/issues/21209) - Ignore case sensitivity for configuration parameters which specify an app label and model name
+* [#21228](https://github.com/netbox-community/netbox/issues/21228) - Support image attachments for rack types
+* [#21244](https://github.com/netbox-community/netbox/issues/21244) - Enable omitting specific fields from REST API responses with `?omit=` parameter
+
+### Performance Improvements
+
+* [#21249](https://github.com/netbox-community/netbox/issues/21249) - Avoid extraneous user query when no event rules are present
+* [#21259](https://github.com/netbox-community/netbox/issues/21259) - Cache ObjectType lookups for the duration of a request
+* [#21260](https://github.com/netbox-community/netbox/issues/21260) - Defer object serialization for events pipeline processing
+* [#21263](https://github.com/netbox-community/netbox/issues/21263) - Prefetch related objects after creating/updating objects via REST API
+* [#21300](https://github.com/netbox-community/netbox/issues/21300) - Cache custom field lookups for the duration of a request
+* [#21302](https://github.com/netbox-community/netbox/issues/21302) - Avoid redundant uniqueness checks in ValidatedModelSerializer
+* [#21303](https://github.com/netbox-community/netbox/issues/21303) - Cache post-change snapshot on each instance after serialization
+* [#21327](https://github.com/netbox-community/netbox/issues/21327) - Always leverage `get_by_natural_key()` to resolve ContentTypes
+
+### Bug Fixes
+
+* [#20212](https://github.com/netbox-community/netbox/issues/20212) - Fix support for image attachment thumbnails when using S3 storage
+* [#20383](https://github.com/netbox-community/netbox/issues/20383) - When editing a device, clearing the assigned unit should also clear the rack face selection
+* [#20902](https://github.com/netbox-community/netbox/issues/20902) - Avoid `SyncError` exception when Git URL contains an embedded username
+* [#20977](https://github.com/netbox-community/netbox/issues/20977) - "Run again" button should respect script variable defaults
+* [#21115](https://github.com/netbox-community/netbox/issues/21115) - Include `attribute_data` in ModuleType YAML export
+* [#21129](https://github.com/netbox-community/netbox/issues/21129) - Store queue name on the Job model to ensure deletion of associated RQ task when a non-default queue is used
+* [#21168](https://github.com/netbox-community/netbox/issues/21168) - Fix Application Service cloning to preserve parent object
+* [#21173](https://github.com/netbox-community/netbox/issues/21173) - Ensure all plugin menu items are registered regardless of initialization order
+* [#21176](https://github.com/netbox-community/netbox/issues/21176) - Remove checkboxes from IP ranges in mixed-type tables
+* [#21202](https://github.com/netbox-community/netbox/issues/21202) - Fix scoped form cloning clearing the `scope` field when `scope_type` changes
+* [#21214](https://github.com/netbox-community/netbox/issues/21214) - Clean up AutoSyncRecord when detaching from DataSource
+* [#21242](https://github.com/netbox-community/netbox/issues/21242) - Navigation menu items for authentication should not require `staff_only` permission
+* [#21254](https://github.com/netbox-community/netbox/issues/21254) - Fix `AttributeError` exception when checking for latest release
+* [#21262](https://github.com/netbox-community/netbox/issues/21262) - Assigned scope should be replicated when cloning a prefix
+* [#21269](https://github.com/netbox-community/netbox/issues/21269) - Fix replication of front/rear port assignments from the module type when installing a module
+
+---
+
 ## v4.5.1 (2026-01-20)
 
 ### Enhancements

+ 40 - 3
netbox/circuits/filtersets.py

@@ -9,7 +9,7 @@ from ipam.models import ASN
 from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
 from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
 from utilities.filters import (
-    ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
+    MultiValueCharFilter, MultiValueContentTypeFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
 )
 from utilities.filtersets import register_filterset
 from .choices import *
@@ -99,11 +99,13 @@ class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
 class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
     provider_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Provider.objects.all(),
+        distinct=False,
         label=_('Provider (ID)'),
     )
     provider = django_filters.ModelMultipleChoiceFilter(
         field_name='provider__slug',
         queryset=Provider.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Provider (slug)'),
     )
@@ -127,11 +129,13 @@ class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
 class ProviderNetworkFilterSet(PrimaryModelFilterSet):
     provider_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Provider.objects.all(),
+        distinct=False,
         label=_('Provider (ID)'),
     )
     provider = django_filters.ModelMultipleChoiceFilter(
         field_name='provider__slug',
         queryset=Provider.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Provider (slug)'),
     )
@@ -163,22 +167,26 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
 class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     provider_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Provider.objects.all(),
+        distinct=False,
         label=_('Provider (ID)'),
     )
     provider = django_filters.ModelMultipleChoiceFilter(
         field_name='provider__slug',
         queryset=Provider.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Provider (slug)'),
     )
     provider_account_id = django_filters.ModelMultipleChoiceFilter(
         field_name='provider_account',
         queryset=ProviderAccount.objects.all(),
+        distinct=False,
         label=_('Provider account (ID)'),
     )
     provider_account = django_filters.ModelMultipleChoiceFilter(
         field_name='provider_account__account',
         queryset=Provider.objects.all(),
+        distinct=False,
         to_field_name='account',
         label=_('Provider account (account)'),
     )
@@ -189,16 +197,19 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt
     )
     type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=CircuitType.objects.all(),
+        distinct=False,
         label=_('Circuit type (ID)'),
     )
     type = django_filters.ModelMultipleChoiceFilter(
         field_name='type__slug',
         queryset=CircuitType.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Circuit type (slug)'),
     )
     status = django_filters.MultipleChoiceFilter(
         choices=CircuitStatusChoices,
+        distinct=False,
         null_value=None
     )
     region_id = TreeNodeMultipleChoiceFilter(
@@ -245,10 +256,12 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt
     )
     termination_a_id = django_filters.ModelMultipleChoiceFilter(
         queryset=CircuitTermination.objects.all(),
+        distinct=False,
         label=_('Termination A (ID)'),
     )
     termination_z_id = django_filters.ModelMultipleChoiceFilter(
         queryset=CircuitTermination.objects.all(),
+        distinct=False,
         label=_('Termination A (ID)'),
     )
 
@@ -279,9 +292,10 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
     )
     circuit_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Circuit.objects.all(),
+        distinct=False,
         label=_('Circuit'),
     )
-    termination_type = ContentTypeFilter()
+    termination_type = MultiValueContentTypeFilter()
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         field_name='_region',
@@ -310,12 +324,14 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
+        distinct=False,
         field_name='_site',
         label=_('Site (ID)'),
     )
     site = django_filters.ModelMultipleChoiceFilter(
         field_name='_site__slug',
         queryset=Site.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Site (slug)'),
     )
@@ -334,17 +350,20 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
     )
     provider_network_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ProviderNetwork.objects.all(),
+        distinct=False,
         field_name='_provider_network',
         label=_('ProviderNetwork (ID)'),
     )
     provider_id = django_filters.ModelMultipleChoiceFilter(
         field_name='circuit__provider_id',
         queryset=Provider.objects.all(),
+        distinct=False,
         label=_('Provider (ID)'),
     )
     provider = django_filters.ModelMultipleChoiceFilter(
         field_name='circuit__provider__slug',
         queryset=Provider.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Provider (slug)'),
     )
@@ -381,7 +400,7 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
         method='search',
         label=_('Search'),
     )
-    member_type = ContentTypeFilter()
+    member_type = MultiValueContentTypeFilter()
     circuit = MultiValueCharFilter(
         method='filter_circuit',
         field_name='cid',
@@ -414,11 +433,13 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
     )
     group_id = django_filters.ModelMultipleChoiceFilter(
         queryset=CircuitGroup.objects.all(),
+        distinct=False,
         label=_('Circuit group (ID)'),
     )
     group = django_filters.ModelMultipleChoiceFilter(
         field_name='group__slug',
         queryset=CircuitGroup.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Circuit group (slug)'),
     )
@@ -488,41 +509,49 @@ class VirtualCircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     provider_id = django_filters.ModelMultipleChoiceFilter(
         field_name='provider_network__provider',
         queryset=Provider.objects.all(),
+        distinct=False,
         label=_('Provider (ID)'),
     )
     provider = django_filters.ModelMultipleChoiceFilter(
         field_name='provider_network__provider__slug',
         queryset=Provider.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Provider (slug)'),
     )
     provider_account_id = django_filters.ModelMultipleChoiceFilter(
         field_name='provider_account',
         queryset=ProviderAccount.objects.all(),
+        distinct=False,
         label=_('Provider account (ID)'),
     )
     provider_account = django_filters.ModelMultipleChoiceFilter(
         field_name='provider_account__account',
         queryset=Provider.objects.all(),
+        distinct=False,
         to_field_name='account',
         label=_('Provider account (account)'),
     )
     provider_network_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ProviderNetwork.objects.all(),
+        distinct=False,
         label=_('Provider network (ID)'),
     )
     type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VirtualCircuitType.objects.all(),
+        distinct=False,
         label=_('Virtual circuit type (ID)'),
     )
     type = django_filters.ModelMultipleChoiceFilter(
         field_name='type__slug',
         queryset=VirtualCircuitType.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Virtual circuit type (slug)'),
     )
     status = django_filters.MultipleChoiceFilter(
         choices=CircuitStatusChoices,
+        distinct=False,
         null_value=None
     )
 
@@ -548,41 +577,49 @@ class VirtualCircuitTerminationFilterSet(NetBoxModelFilterSet):
     )
     virtual_circuit_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VirtualCircuit.objects.all(),
+        distinct=False,
         label=_('Virtual circuit'),
     )
     role = django_filters.MultipleChoiceFilter(
         choices=VirtualCircuitTerminationRoleChoices,
+        distinct=False,
         null_value=None
     )
     provider_id = django_filters.ModelMultipleChoiceFilter(
         field_name='virtual_circuit__provider_network__provider',
         queryset=Provider.objects.all(),
+        distinct=False,
         label=_('Provider (ID)'),
     )
     provider = django_filters.ModelMultipleChoiceFilter(
         field_name='virtual_circuit__provider_network__provider__slug',
         queryset=Provider.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Provider (slug)'),
     )
     provider_account_id = django_filters.ModelMultipleChoiceFilter(
         field_name='virtual_circuit__provider_account',
         queryset=ProviderAccount.objects.all(),
+        distinct=False,
         label=_('Provider account (ID)'),
     )
     provider_account = django_filters.ModelMultipleChoiceFilter(
         field_name='virtual_circuit__provider_account__account',
         queryset=ProviderAccount.objects.all(),
+        distinct=False,
         to_field_name='account',
         label=_('Provider account (account)'),
     )
     provider_network_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ProviderNetwork.objects.all(),
+        distinct=False,
         field_name='virtual_circuit__provider_network',
         label=_('Provider network (ID)'),
     )
     interface_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Interface.objects.all(),
+        distinct=False,
         field_name='interface',
         label=_('Interface (ID)'),
     )

+ 2 - 2
netbox/circuits/forms/model_forms.py

@@ -91,13 +91,13 @@ class ProviderNetworkForm(PrimaryModelForm):
 
 class CircuitTypeForm(OrganizationalModelForm):
     fieldsets = (
-        FieldSet('name', 'slug', 'color', 'description', 'owner', 'tags'),
+        FieldSet('name', 'slug', 'color', 'description', 'tags'),
     )
 
     class Meta:
         model = CircuitType
         fields = [
-            'name', 'slug', 'color', 'description', 'comments', 'tags',
+            'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
         ]
 
 

+ 16 - 1
netbox/core/data_backends.py

@@ -21,11 +21,24 @@ __all__ = (
     'GitBackend',
     'LocalBackend',
     'S3Backend',
+    'url_has_embedded_credentials',
 )
 
 logger = logging.getLogger('netbox.data_backends')
 
 
+def url_has_embedded_credentials(url):
+    """
+    Check if a URL contains embedded credentials (username in the URL).
+
+    URLs like 'https://user@bitbucket.org/...' have embedded credentials.
+    This is used to avoid passing explicit credentials to dulwich when the
+    URL already contains them, which would cause authentication conflicts.
+    """
+    parsed = urlparse(url)
+    return bool(parsed.username)
+
+
 @register_data_backend()
 class LocalBackend(DataBackend):
     name = 'local'
@@ -102,7 +115,9 @@ class GitBackend(DataBackend):
             clone_args['pool_manager'] = ProxyPoolManager(self.socks_proxy)
 
         if self.url_scheme in ('http', 'https'):
-            if self.params.get('username'):
+            # Only pass explicit credentials if URL doesn't already contain embedded username
+            # to avoid credential conflicts (see #20902)
+            if not url_has_embedded_credentials(self.url) and self.params.get('username'):
                 clone_args.update(
                     {
                         "username": self.params.get('username'),

+ 15 - 5
netbox/core/filtersets.py

@@ -6,7 +6,7 @@ from django.utils.translation import gettext as _
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, PrimaryModelFilterSet
 from netbox.utils import get_data_backend_choices
 from users.models import User
-from utilities.filters import ContentTypeFilter
+from utilities.filters import MultiValueContentTypeFilter
 from utilities.filtersets import register_filterset
 from .choices import *
 from .models import *
@@ -25,14 +25,17 @@ __all__ = (
 class DataSourceFilterSet(PrimaryModelFilterSet):
     type = django_filters.MultipleChoiceFilter(
         choices=get_data_backend_choices,
+        distinct=False,
         null_value=None
     )
     status = django_filters.MultipleChoiceFilter(
         choices=DataSourceStatusChoices,
+        distinct=False,
         null_value=None
     )
     sync_interval = django_filters.MultipleChoiceFilter(
         choices=JobIntervalChoices,
+        distinct=False,
         null_value=None
     )
 
@@ -57,11 +60,13 @@ class DataFileFilterSet(ChangeLoggedModelFilterSet):
     )
     source_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data source (ID)'),
     )
     source = django_filters.ModelMultipleChoiceFilter(
         field_name='source__name',
         queryset=DataSource.objects.all(),
+        distinct=False,
         to_field_name='name',
         label=_('Data source (name)'),
     )
@@ -86,9 +91,10 @@ class JobFilterSet(BaseFilterSet):
     )
     object_type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ObjectType.objects.with_feature('jobs'),
+        distinct=False,
         field_name='object_type_id',
     )
-    object_type = ContentTypeFilter()
+    object_type = MultiValueContentTypeFilter()
     created = django_filters.DateTimeFilter()
     created__before = django_filters.DateTimeFilter(
         field_name='created',
@@ -127,6 +133,7 @@ class JobFilterSet(BaseFilterSet):
     )
     status = django_filters.MultipleChoiceFilter(
         choices=JobStatusChoices,
+        distinct=False,
         null_value=None
     )
     queue_name = django_filters.CharFilter()
@@ -180,18 +187,21 @@ class ObjectChangeFilterSet(BaseFilterSet):
         label=_('Search'),
     )
     time = django_filters.DateTimeFromToRangeFilter()
-    changed_object_type = ContentTypeFilter()
+    changed_object_type = MultiValueContentTypeFilter()
     changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=ContentType.objects.all()
+        queryset=ContentType.objects.all(),
+        distinct=False,
     )
-    related_object_type = ContentTypeFilter()
+    related_object_type = MultiValueContentTypeFilter()
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
+        distinct=False,
         label=_('User (ID)'),
     )
     user = django_filters.ModelMultipleChoiceFilter(
         field_name='user__username',
         queryset=User.objects.all(),
+        distinct=False,
         to_field_name='username',
         label=_('User name'),
     )

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

@@ -89,6 +89,7 @@ class ManagedFile(SyncedDataMixin, models.Model):
 
             with storage.open(self.full_path, 'wb+') as new_file:
                 new_file.write(self.data_file.data)
+    sync_data.alters_data = True
 
     @cached_property
     def storage(self):

+ 2 - 0
netbox/core/models/jobs.py

@@ -216,6 +216,7 @@ class Job(models.Model):
 
         # Send signal
         job_start.send(self)
+    start.alters_data = True
 
     def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None):
         """
@@ -245,6 +246,7 @@ class Job(models.Model):
 
         # Send signal
         job_end.send(self)
+    terminate.alters_data = True
 
     def log(self, record: logging.LogRecord):
         """

+ 24 - 17
netbox/core/signals.py

@@ -18,6 +18,7 @@ from extras.events import enqueue_event
 from extras.models import Tag
 from extras.utils import run_validators
 from netbox.config import get_config
+from utilities.data import get_config_value_ci
 from netbox.context import current_request, events_queue
 from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public
 from utilities.exceptions import AbortRequest
@@ -168,7 +169,7 @@ def handle_deleted_object(sender, instance, **kwargs):
     # to queueing any events for the object being deleted, in case a validation error is
     # raised, causing the deletion to fail.
     model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
-    validators = get_config().PROTECTION_RULES.get(model_name, [])
+    validators = get_config_value_ci(get_config().PROTECTION_RULES, model_name, default=[])
     try:
         run_validators(instance, validators)
     except ValidationError as e:
@@ -208,22 +209,28 @@ def handle_deleted_object(sender, instance, **kwargs):
     # for the forward direction of the relationship, ensuring that the change is recorded.
     # Similarly, for many-to-one relationships, we set the value on the related object to None
     # and save it to trigger a change record on that object.
-    for relation in instance._meta.related_objects:
-        if type(relation) not in [ManyToManyRel, ManyToOneRel]:
-            continue
-        related_model = relation.related_model
-        related_field_name = relation.remote_field.name
-        if not issubclass(related_model, ChangeLoggingMixin):
-            # We only care about triggering the m2m_changed signal for models which support
-            # change logging
-            continue
-        for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
-            obj.snapshot()  # Ensure the change record includes the "before" state
-            if type(relation) is ManyToManyRel:
-                getattr(obj, related_field_name).remove(instance)
-            elif type(relation) is ManyToOneRel and relation.null and relation.on_delete not in (CASCADE, RESTRICT):
-                setattr(obj, related_field_name, None)
-                obj.save()
+    #
+    # Skip this for private models (e.g. CablePath) whose lifecycle is an internal
+    # implementation detail. Django's on_delete handlers (e.g. SET_NULL) already take
+    # care of the database integrity; recording changelog entries for the related
+    # objects would be spurious. (Ref: #21390)
+    if not getattr(instance, '_netbox_private', False):
+        for relation in instance._meta.related_objects:
+            if type(relation) not in [ManyToManyRel, ManyToOneRel]:
+                continue
+            related_model = relation.related_model
+            related_field_name = relation.remote_field.name
+            if not issubclass(related_model, ChangeLoggingMixin):
+                # We only care about triggering the m2m_changed signal for models which support
+                # change logging
+                continue
+            for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
+                obj.snapshot()  # Ensure the change record includes the "before" state
+                if type(relation) is ManyToManyRel:
+                    getattr(obj, related_field_name).remove(instance)
+                elif type(relation) is ManyToOneRel and relation.null and relation.on_delete not in (CASCADE, RESTRICT):
+                    setattr(obj, related_field_name, None)
+                    obj.save()
 
     # Enqueue the object for event processing
     queue = events_queue.get()

+ 116 - 0
netbox/core/tests/test_data_backends.py

@@ -0,0 +1,116 @@
+from unittest import skipIf
+from unittest.mock import patch
+
+from django.test import TestCase
+
+from core.data_backends import url_has_embedded_credentials
+
+try:
+    import dulwich  # noqa: F401
+    DULWICH_AVAILABLE = True
+except ImportError:
+    DULWICH_AVAILABLE = False
+
+
+class URLEmbeddedCredentialsTests(TestCase):
+    def test_url_with_embedded_username(self):
+        self.assertTrue(url_has_embedded_credentials('https://myuser@bitbucket.org/workspace/repo.git'))
+
+    def test_url_without_embedded_username(self):
+        self.assertFalse(url_has_embedded_credentials('https://bitbucket.org/workspace/repo.git'))
+
+    def test_url_with_username_and_password(self):
+        self.assertTrue(url_has_embedded_credentials('https://user:pass@bitbucket.org/workspace/repo.git'))
+
+    def test_various_providers_with_embedded_username(self):
+        urls = [
+            'https://user@bitbucket.org/workspace/repo.git',
+            'https://user@github.com/owner/repo.git',
+            'https://deploy-key@gitlab.com/group/project.git',
+            'http://user@internal-git.example.com/repo.git',
+        ]
+        for url in urls:
+            with self.subTest(url=url):
+                self.assertTrue(url_has_embedded_credentials(url))
+
+    def test_various_providers_without_embedded_username(self):
+        """Various Git providers without embedded usernames."""
+        urls = [
+            'https://bitbucket.org/workspace/repo.git',
+            'https://github.com/owner/repo.git',
+            'https://gitlab.com/group/project.git',
+            'http://internal-git.example.com/repo.git',
+        ]
+        for url in urls:
+            with self.subTest(url=url):
+                self.assertFalse(url_has_embedded_credentials(url))
+
+    def test_ssh_url(self):
+        # git@host:path format doesn't parse as having a username in the traditional sense
+        self.assertFalse(url_has_embedded_credentials('git@github.com:owner/repo.git'))
+
+    def test_file_url(self):
+        self.assertFalse(url_has_embedded_credentials('file:///path/to/repo'))
+
+
+@skipIf(not DULWICH_AVAILABLE, "dulwich is not installed")
+class GitBackendCredentialIntegrationTests(TestCase):
+    """
+    Integration tests that verify GitBackend correctly applies credential logic.
+
+    These tests require dulwich to be installed and verify the full integration
+    of the credential handling in GitBackend.fetch().
+    """
+
+    def _get_clone_kwargs(self, url, **params):
+        from core.data_backends import GitBackend
+
+        backend = GitBackend(url=url, **params)
+
+        with patch('dulwich.porcelain.clone') as mock_clone, \
+             patch('dulwich.porcelain.NoneStream'):
+            try:
+                with backend.fetch():
+                    pass
+            except Exception:
+                pass
+
+            if mock_clone.called:
+                return mock_clone.call_args.kwargs
+            return {}
+
+    def test_url_with_embedded_username_skips_explicit_credentials(self):
+        kwargs = self._get_clone_kwargs(
+            url='https://myuser@bitbucket.org/workspace/repo.git',
+            username='myuser',
+            password='my-api-key'
+        )
+
+        self.assertEqual(kwargs.get('username'), None)
+        self.assertEqual(kwargs.get('password'), None)
+
+    def test_url_without_embedded_username_passes_explicit_credentials(self):
+        kwargs = self._get_clone_kwargs(
+            url='https://bitbucket.org/workspace/repo.git',
+            username='myuser',
+            password='my-api-key'
+        )
+
+        self.assertEqual(kwargs.get('username'), 'myuser')
+        self.assertEqual(kwargs.get('password'), 'my-api-key')
+
+    def test_url_with_embedded_username_no_explicit_credentials(self):
+        kwargs = self._get_clone_kwargs(
+            url='https://myuser@bitbucket.org/workspace/repo.git'
+        )
+
+        self.assertEqual(kwargs.get('username'), None)
+        self.assertEqual(kwargs.get('password'), None)
+
+    def test_public_repo_no_credentials(self):
+        kwargs = self._get_clone_kwargs(
+            url='https://github.com/public/repo.git'
+        )
+
+        self.assertEqual(kwargs.get('username'), None)
+        self.assertEqual(kwargs.get('password'), None)

+ 2 - 2
netbox/core/tests/test_filtersets.py

@@ -237,9 +237,9 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
     def test_changed_object_type(self):
-        params = {'changed_object_type': 'dcim.site'}
+        params = {'changed_object_type': ['dcim.site']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
-        params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
+        params = {'changed_object_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 

+ 19 - 2
netbox/core/views.py

@@ -1,6 +1,7 @@
 import json
 import platform
 
+from copy import deepcopy
 from django import __version__ as django_version
 from django.conf import settings
 from django.contrib import messages
@@ -310,6 +311,22 @@ class ConfigRevisionListView(generic.ObjectListView):
 class ConfigRevisionView(generic.ObjectView):
     queryset = ConfigRevision.objects.all()
 
+    def get_extra_context(self, request, instance):
+        """
+        Retrieve additional context for a given request and instance.
+        """
+        # Copy the revision data to avoid modifying the original
+        config = deepcopy(instance.data or {})
+
+        # Serialize any JSON-based classes
+        for attr in ['CUSTOM_VALIDATORS', 'DEFAULT_USER_PREFERENCES', 'PROTECTION_RULES']:
+            if attr in config:
+                config[attr] = json.dumps(config[attr], cls=ConfigJSONEncoder, indent=4)
+
+        return {
+            'config': config,
+        }
+
 
 @register_model_view(ConfigRevision, 'add', detail=False)
 class ConfigRevisionEditView(generic.ObjectEditView):
@@ -617,8 +634,8 @@ class SystemView(UserPassesTestMixin, View):
             response['Content-Disposition'] = 'attachment; filename="netbox.json"'
             return response
 
-        # Serialize any CustomValidator classes
-        for attr in ['CUSTOM_VALIDATORS', 'PROTECTION_RULES']:
+        # Serialize any JSON-based classes
+        for attr in ['CUSTOM_VALIDATORS', 'DEFAULT_USER_PREFERENCES', 'PROTECTION_RULES']:
             if hasattr(config, attr) and getattr(config, attr, None):
                 setattr(config, attr, json.dumps(getattr(config, attr), cls=ConfigJSONEncoder, indent=4))
 

+ 4 - 2
netbox/dcim/base_filtersets.py

@@ -2,7 +2,7 @@ import django_filters
 
 from django.utils.translation import gettext as _
 from netbox.filtersets import BaseFilterSet
-from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
+from utilities.filters import MultiValueContentTypeFilter, TreeNodeMultipleChoiceFilter
 from .models import *
 
 __all__ = (
@@ -14,7 +14,7 @@ class ScopedFilterSet(BaseFilterSet):
     """
     Provides additional filtering functionality for location, site, etc.. for Scoped models.
     """
-    scope_type = ContentTypeFilter()
+    scope_type = MultiValueContentTypeFilter()
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         field_name='_region',
@@ -43,12 +43,14 @@ class ScopedFilterSet(BaseFilterSet):
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
+        distinct=False,
         field_name='_site',
         label=_('Site (ID)'),
     )
     site = django_filters.ModelMultipleChoiceFilter(
         field_name='_site__slug',
         queryset=Site.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Site (slug)'),
     )

Plik diff jest za duży
+ 173 - 23
netbox/dcim/filtersets.py


+ 49 - 0
netbox/dcim/migrations/0226_add_mptt_tree_indexes.py

@@ -0,0 +1,49 @@
+# Generated by Django 5.2.10 on 2026-02-13 13:36
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('dcim', '0225_gfk_indexes'),
+        ('extras', '0134_owner'),
+        ('tenancy', '0022_add_comments_to_organizationalmodel'),
+        ('users', '0015_owner'),
+    ]
+
+    operations = [
+        migrations.AddIndex(
+            model_name='devicerole',
+            index=models.Index(fields=['tree_id', 'lft'], name='dcim_devicerole_tree_id_lfbf11'),
+        ),
+        migrations.AddIndex(
+            model_name='inventoryitem',
+            index=models.Index(fields=['tree_id', 'lft'], name='dcim_inventoryitem_tree_id975c'),
+        ),
+        migrations.AddIndex(
+            model_name='inventoryitemtemplate',
+            index=models.Index(fields=['tree_id', 'lft'], name='dcim_inventoryitemtemplatedee0'),
+        ),
+        migrations.AddIndex(
+            model_name='location',
+            index=models.Index(fields=['tree_id', 'lft'], name='dcim_location_tree_id_lft_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='modulebay',
+            index=models.Index(fields=['tree_id', 'lft'], name='dcim_modulebay_tree_id_lft_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='platform',
+            index=models.Index(fields=['tree_id', 'lft'], name='dcim_platform_tree_id_lft_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='region',
+            index=models.Index(fields=['tree_id', 'lft'], name='dcim_region_tree_id_lft_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='sitegroup',
+            index=models.Index(fields=['tree_id', 'lft'], name='dcim_sitegroup_tree_id_lft_idx'),
+        ),
+    ]

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

@@ -657,6 +657,16 @@ class CablePath(models.Model):
         origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
         origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk)
 
+    def delete(self, *args, **kwargs):
+        # Mirror save() - clear _path on origins to prevent stale references
+        # in table views that render _path.destinations
+        if self.path:
+            origin_model = self.origin_type.model_class()
+            origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
+            origin_model.objects.filter(pk__in=origin_ids, _path=self.pk).update(_path=None)
+
+        super().delete(*args, **kwargs)
+
     @property
     def origin_type(self):
         if self.path:

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

@@ -1263,6 +1263,9 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
     clone_fields = ('device',)
 
     class Meta(ModularComponentModel.Meta):
+        # Empty tuple triggers Django migration detection for MPTT indexes
+        # (see #21016, django-mptt/django-mptt#682)
+        indexes = ()
         constraints = (
             models.UniqueConstraint(
                 fields=('device', 'module', 'name'),

+ 6 - 0
netbox/dcim/models/devices.py

@@ -401,6 +401,9 @@ class DeviceRole(NestedGroupModel):
 
     class Meta:
         ordering = ('name',)
+        # Empty tuple triggers Django migration detection for MPTT indexes
+        # (see #21016, django-mptt/django-mptt#682)
+        indexes = ()
         constraints = (
             models.UniqueConstraint(
                 fields=('parent', 'name'),
@@ -452,6 +455,9 @@ class Platform(NestedGroupModel):
 
     class Meta:
         ordering = ('name',)
+        # Empty tuple triggers Django migration detection for MPTT indexes
+        # (see #21016, django-mptt/django-mptt#682)
+        indexes = ()
         verbose_name = _('platform')
         verbose_name_plural = _('platforms')
         constraints = (

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

@@ -373,7 +373,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
         super().clean()
 
         # Validate location/site assignment
-        if self.site and self.location and self.location.site != self.site:
+        if self.site_id and self.location_id and self.location.site_id != self.site_id:
             raise ValidationError(_("Assigned location must belong to parent site ({site}).").format(site=self.site))
 
         # Validate outer dimensions and unit

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

@@ -44,6 +44,9 @@ class Region(ContactsMixin, NestedGroupModel):
     )
 
     class Meta:
+        # Empty tuple triggers Django migration detection for MPTT indexes
+        # (see #21016, django-mptt/django-mptt#682)
+        indexes = ()
         constraints = (
             models.UniqueConstraint(
                 fields=('parent', 'name'),
@@ -100,6 +103,9 @@ class SiteGroup(ContactsMixin, NestedGroupModel):
     )
 
     class Meta:
+        # Empty tuple triggers Django migration detection for MPTT indexes
+        # (see #21016, django-mptt/django-mptt#682)
+        indexes = ()
         constraints = (
             models.UniqueConstraint(
                 fields=('parent', 'name'),
@@ -318,6 +324,9 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel):
 
     class Meta:
         ordering = ['site', 'name']
+        # Empty tuple triggers Django migration detection for MPTT indexes
+        # (see #21016, django-mptt/django-mptt#682)
+        indexes = ()
         constraints = (
             models.UniqueConstraint(
                 fields=('site', 'parent', 'name'),

+ 2 - 0
netbox/dcim/signals.py

@@ -170,6 +170,8 @@ def nullify_connected_endpoints(instance, **kwargs):
         # Remove the deleted CableTermination if it's one of the path's originating nodes
         if instance.termination in cablepath.origins:
             cablepath.origins.remove(instance.termination)
+            # Clear _path on the removed origin to prevent stale connection display
+            model.objects.filter(pk=instance.termination_id, _path=cablepath.pk).update(_path=None)
         cablepath.retrace()
 
 

+ 48 - 26
netbox/dcim/tables/devices.py

@@ -584,6 +584,15 @@ class BaseInterfaceTable(NetBoxTable):
         orderable=False,
         verbose_name=_('IP Addresses')
     )
+    primary_mac_address = tables.Column(
+        verbose_name=_('Primary MAC'),
+        linkify=True
+    )
+    mac_addresses = columns.ManyToManyColumn(
+        orderable=False,
+        linkify_item=True,
+        verbose_name=_('MAC Addresses')
+    )
     fhrp_groups = tables.TemplateColumn(
         accessor=Accessor('fhrp_group_assignments'),
         template_code=INTERFACE_FHRPGROUPS,
@@ -615,10 +624,6 @@ class BaseInterfaceTable(NetBoxTable):
         verbose_name=_('Q-in-Q SVLAN'),
         linkify=True
     )
-    primary_mac_address = tables.Column(
-        verbose_name=_('MAC Address'),
-        linkify=True
-    )
 
     def value_ip_addresses(self, value):
         return ",".join([str(obj.address) for obj in value.all()])
@@ -681,11 +686,12 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
         model = models.Interface
         fields = (
             'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
-            'speed', 'speed_formatted', 'duplex', 'mode', 'primary_mac_address', 'wwn', 'poe_mode', 'poe_type',
-            'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
-            'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
-            'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
-            'qinq_svlan', 'inventory_items', 'created', 'last_updated', 'vlan_translation_policy'
+            'speed', 'speed_formatted', 'duplex', 'mode', 'mac_addresses', 'primary_mac_address', 'wwn',
+            'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
+            'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer',
+            'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups',
+            'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'inventory_items', 'created', 'last_updated',
+            'vlan_translation_policy',
         )
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
 
@@ -746,10 +752,11 @@ class DeviceInterfaceTable(InterfaceTable):
         model = models.Interface
         fields = (
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag',
-            'mgmt_only', 'mtu', 'mode', 'primary_mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
-            'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
-            'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses',
-            'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'actions',
+            'mgmt_only', 'mtu', 'mode', 'mac_addresses', 'primary_mac_address', 'wwn', 'rf_role', 'rf_channel',
+            'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
+            'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf',
+            'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
+            'actions',
         )
         default_columns = (
             'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
@@ -880,24 +887,36 @@ class DeviceBayTable(DeviceComponentTable):
             'args': [Accessor('device_id')],
         }
     )
-    role = columns.ColoredLabelColumn(
-        accessor=Accessor('installed_device__role'),
-        verbose_name=_('Role')
-    )
-    device_type = tables.Column(
-        accessor=Accessor('installed_device__device_type'),
-        linkify=True,
-        verbose_name=_('Type')
-    )
     status = tables.TemplateColumn(
         verbose_name=_('Status'),
         template_code=DEVICEBAY_STATUS,
         order_by=Accessor('installed_device__status')
     )
     installed_device = tables.Column(
-        verbose_name=_('Installed device'),
+        verbose_name=_('Installed Device'),
         linkify=True
     )
+    installed_role = columns.ColoredLabelColumn(
+        accessor=Accessor('installed_device__role'),
+        verbose_name=_('Installed Role')
+    )
+    installed_device_type = tables.Column(
+        accessor=Accessor('installed_device__device_type'),
+        linkify=True,
+        verbose_name=_('Installed Type')
+    )
+    installed_description = tables.Column(
+        accessor=Accessor('installed_device__description'),
+        verbose_name=_('Installed Description')
+    )
+    installed_serial = tables.Column(
+        accessor=Accessor('installed_device__serial'),
+        verbose_name=_('Installed Serial')
+    )
+    installed_asset_tag = tables.Column(
+        accessor=Accessor('installed_device__asset_tag'),
+        verbose_name=_('Installed Asset Tag')
+    )
     tags = columns.TagColumn(
         url_name='dcim:devicebay_list'
     )
@@ -905,8 +924,9 @@ class DeviceBayTable(DeviceComponentTable):
     class Meta(DeviceComponentTable.Meta):
         model = models.DeviceBay
         fields = (
-            'pk', 'id', 'name', 'device', 'label', 'status', 'role', 'device_type', 'installed_device', 'description',
-            'tags', 'created', 'last_updated',
+            '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',
         )
 
         default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
@@ -1199,4 +1219,6 @@ class MACAddressTable(PrimaryModelTable):
             'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description', 'is_primary',
             'comments', 'tags', 'created', 'last_updated',
         )
-        default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description')
+        default_columns = (
+            'pk', 'mac_address', 'is_primary', 'assigned_object_parent', 'assigned_object', 'description',
+        )

+ 4 - 1
netbox/dcim/tests/test_cablepaths.py

@@ -2806,7 +2806,6 @@ class LegacyCablePathTests(CablePathTestCase):
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
 
-        # Create cables 1
         cable1 = Cable(
             a_terminations=[interface1],
             b_terminations=[interface2, interface3]
@@ -2838,6 +2837,10 @@ class LegacyCablePathTests(CablePathTestCase):
             is_active=True
         )
 
+        # Verify _path is cleared on removed interface (#21127)
+        interface3.refresh_from_db()
+        self.assertPathIsNotSet(interface3)
+
     def test_401_exclude_midspan_devices(self):
         """
         [IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2]

+ 4 - 6
netbox/dcim/tests/test_filtersets.py

@@ -6251,7 +6251,7 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_component_type(self):
-        params = {'component_type': 'dcim.interface'}
+        params = {'component_type': ['dcim.interface']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     def test_status(self):
@@ -6723,10 +6723,8 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
     def test_termination_types(self):
-        params = {'termination_a_type': 'dcim.consoleport'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        # params = {'termination_b_type': 'dcim.consoleserverport'}
-        # self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'termination_a_type': ['dcim.consoleport', 'dcim.consoleserverport']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_termination_ids(self):
         interface_ids = CableTermination.objects.filter(
@@ -6734,7 +6732,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
             cable_end='A'
         ).values_list('termination_id', flat=True)
         params = {
-            'termination_a_type': 'dcim.interface',
+            'termination_a_type': ['dcim.interface'],
             'termination_a_id': list(interface_ids),
         }
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)

+ 11 - 3
netbox/dcim/ui/panels.py

@@ -90,7 +90,6 @@ class DevicePanel(panels.ObjectAttributesPanel):
     parent_device = attrs.TemplatedAttr('parent_bay', template_name='dcim/device/attrs/parent_device.html')
     gps_coordinates = attrs.GPSCoordinatesAttr()
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
-    device_type = attrs.RelatedObjectAttr('device_type', linkify=True, grouped_by='manufacturer')
     description = attrs.TextAttr('description')
     airflow = attrs.ChoiceAttr('airflow')
     serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
@@ -122,10 +121,19 @@ class DeviceManagementPanel(panels.ObjectAttributesPanel):
     cluster = attrs.RelatedObjectAttr('cluster', linkify=True)
 
 
+class DeviceDeviceTypePanel(panels.ObjectAttributesPanel):
+    title = _('Device Type')
+
+    manufacturer = attrs.RelatedObjectAttr('device_type.manufacturer', linkify=True)
+    model = attrs.RelatedObjectAttr('device_type', linkify=True)
+    height = attrs.TemplatedAttr('device_type.u_height', template_name='dcim/devicetype/attrs/height.html')
+    front_image = attrs.ImageAttr('device_type.front_image')
+    rear_image = attrs.ImageAttr('device_type.rear_image')
+
+
 class DeviceDimensionsPanel(panels.ObjectAttributesPanel):
     title = _('Dimensions')
 
-    height = attrs.TextAttr('device_type.u_height', format_string='{}U')
     total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html')
 
 
@@ -135,7 +143,7 @@ class DeviceTypePanel(panels.ObjectAttributesPanel):
     part_number = attrs.TextAttr('part_number')
     default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True)
     description = attrs.TextAttr('description')
-    height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height'))
+    height = attrs.TemplatedAttr('u_height', template_name='dcim/devicetype/attrs/height.html')
     exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization')
     full_depth = attrs.BooleanAttr('is_full_depth')
     weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')

+ 2 - 1
netbox/dcim/views.py

@@ -13,7 +13,6 @@ from django.utils.translation import gettext_lazy as _
 from django.views.generic import View
 
 from circuits.models import Circuit, CircuitTermination
-from dcim.ui import panels
 from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
@@ -44,6 +43,7 @@ from .choices import DeviceFaceChoices, InterfaceModeChoices
 from .models import *
 from .models.device_components import PortMapping
 from .object_actions import BulkAddComponents, BulkDisconnect
+from .ui import panels
 
 CABLE_TERMINATION_TYPES = {
     'dcim.consoleport': ConsolePort,
@@ -2470,6 +2470,7 @@ class DeviceView(generic.ObjectView):
                 ],
             ),
             ImageAttachmentsPanel(),
+            panels.DeviceDeviceTypePanel(),
             panels.DeviceDimensionsPanel(),
             TemplatePanel('dcim/panels/device_rack_elevations.html'),
         ],

+ 3 - 9
netbox/extras/api/customfields.py

@@ -4,7 +4,6 @@ from drf_spectacular.utils import extend_schema_field
 from rest_framework.fields import Field
 from rest_framework.serializers import ValidationError
 
-from core.models import ObjectType
 from extras.choices import CustomFieldTypeChoices
 from extras.constants import CUSTOMFIELD_EMPTY_VALUES
 from extras.models import CustomField
@@ -24,13 +23,9 @@ class CustomFieldDefaultValues:
     def __call__(self, serializer_field):
         self.model = serializer_field.parent.Meta.model
 
-        # Retrieve the CustomFields for the parent model
-        object_type = ObjectType.objects.get_for_model(self.model)
-        fields = CustomField.objects.filter(object_types=object_type)
-
-        # Populate the default value for each CustomField
+        # Populate the default value for each CustomField on the model
         value = {}
-        for field in fields:
+        for field in CustomField.objects.get_for_model(self.model):
             if field.default is not None:
                 value[field.name] = field.default
             else:
@@ -47,8 +42,7 @@ class CustomFieldsDataField(Field):
         Cache CustomFields assigned to this model to avoid redundant database queries
         """
         if not hasattr(self, '_custom_fields'):
-            object_type = ObjectType.objects.get_for_model(self.parent.Meta.model)
-            self._custom_fields = CustomField.objects.filter(object_types=object_type)
+            self._custom_fields = CustomField.objects.get_for_model(self.parent.Meta.model)
         return self._custom_fields
 
     def to_representation(self, obj):

+ 2 - 1
netbox/extras/dashboard/widgets.py

@@ -75,10 +75,11 @@ def get_bookmarks_object_type_choices():
 def get_models_from_content_types(content_types):
     """
     Return a list of models corresponding to the given content types, identified by natural key.
+    Accepts both lowercase (e.g. "dcim.site") and PascalCase (e.g. "dcim.Site") model names.
     """
     models = []
     for content_type_id in content_types:
-        app_label, model_name = content_type_id.split('.')
+        app_label, model_name = content_type_id.lower().split('.')
         try:
             content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
             if content_type.model_class():

+ 49 - 23
netbox/extras/events.py

@@ -51,18 +51,26 @@ def serialize_for_event(instance):
 
 
 def get_snapshots(instance, event_type):
-    snapshots = {
+    """
+    Return a dictionary of pre- and post-change snapshots for the given instance.
+    """
+    if event_type == OBJECT_DELETED:
+        # Post-change snapshot must be empty for deleted objects
+        postchange_snapshot = None
+    elif hasattr(instance, '_postchange_snapshot'):
+        # Use the cached post-change snapshot if one is available
+        postchange_snapshot = instance._postchange_snapshot
+    elif hasattr(instance, 'serialize_object'):
+        # Use model's serialize_object() method if defined
+        postchange_snapshot = instance.serialize_object()
+    else:
+        # Fall back to the serialize_object() utility function
+        postchange_snapshot = serialize_object(instance)
+
+    return {
         'prechange': getattr(instance, '_prechange_snapshot', None),
-        'postchange': None,
+        'postchange': postchange_snapshot,
     }
-    if event_type != OBJECT_DELETED:
-        # Use model's serialize_object() method if defined; fall back to serialize_object() utility function
-        if hasattr(instance, 'serialize_object'):
-            snapshots['postchange'] = instance.serialize_object()
-        else:
-            snapshots['postchange'] = serialize_object(instance)
-
-    return snapshots
 
 
 def enqueue_event(queue, instance, request, event_type):
@@ -105,6 +113,17 @@ def enqueue_event(queue, instance, request, event_type):
 def process_event_rules(event_rules, object_type, event):
     """
     Process a list of EventRules against an event.
+
+    Notes on event sources:
+    - Object change events (created/updated/deleted) are enqueued via
+      enqueue_event() during an HTTP request.
+      These events include a request object and legacy request
+      attributes (e.g. username, request_id) for backward compatibility.
+    - Job lifecycle events (JOB_STARTED/JOB_COMPLETED) are emitted by
+      job_start/job_end signal handlers and may not include a request
+      context.
+      Consumers must not assume that fields like `username` are always
+      present.
     """
 
     for event_rule in event_rules:
@@ -124,16 +143,22 @@ def process_event_rules(event_rules, object_type, event):
             queue_name = get_config().QUEUE_MAPPINGS.get('webhook', RQ_QUEUE_DEFAULT)
             rq_queue = get_queue(queue_name)
 
+            # For job lifecycle events, `username` may be absent because
+            # there is no request context.
+            # Prefer the associated user object when present, falling
+            # back to the legacy username attribute.
+            username = getattr(event.get('user'), 'username', None) or event.get('username')
+
             # Compile the task parameters
             params = {
-                "event_rule": event_rule,
-                "object_type": object_type,
-                "event_type": event['event_type'],
-                "data": event_data,
-                "snapshots": event.get('snapshots'),
-                "timestamp": timezone.now().isoformat(),
-                "username": event['username'],
-                "retry": get_rq_retry()
+                'event_rule': event_rule,
+                'object_type': object_type,
+                'event_type': event['event_type'],
+                'data': event_data,
+                'snapshots': event.get('snapshots'),
+                'timestamp': timezone.now().isoformat(),
+                'username': username,
+                'retry': get_rq_retry(),
             }
             if 'request' in event:
                 # Exclude FILES - webhooks don't need uploaded files,
@@ -150,11 +175,12 @@ def process_event_rules(event_rules, object_type, event):
 
             # Enqueue a Job to record the script's execution
             from extras.jobs import ScriptJob
+
             params = {
-                "instance": event_rule.action_object,
-                "name": script.name,
-                "user": event['user'],
-                "data": event_data
+                'instance': event_rule.action_object,
+                'name': script.name,
+                'user': event['user'],
+                'data': event_data,
             }
             if 'snapshots' in event:
                 params['snapshots'] = event['snapshots']
@@ -171,7 +197,7 @@ def process_event_rules(event_rules, object_type, event):
                 object_type=object_type,
                 object_id=event_data['id'],
                 object_repr=event_data.get('display'),
-                event_type=event['event_type']
+                event_type=event['event_type'],
             )
 
         else:

+ 51 - 20
netbox/extras/filtersets.py

@@ -10,7 +10,7 @@ from tenancy.models import Tenant, TenantGroup
 from users.filterset_mixins import OwnerFilterMixin
 from users.models import Group, User
 from utilities.filters import (
-    ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
+    MultiValueCharFilter, MultiValueContentTypeFilter, MultiValueNumberFilter
 )
 from utilities.filtersets import register_filterset
 from virtualization.models import Cluster, ClusterGroup, ClusterType
@@ -49,6 +49,7 @@ class ScriptFilterSet(BaseFilterSet):
     )
     module_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ScriptModule.objects.all(),
+        distinct=False,
         label=_('Script module (ID)'),
     )
 
@@ -71,7 +72,8 @@ class WebhookFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
         label=_('Search'),
     )
     http_method = django_filters.MultipleChoiceFilter(
-        choices=WebhookHttpMethodChoices
+        choices=WebhookHttpMethodChoices,
+        distinct=False,
     )
     payload_url = MultiValueCharFilter(
         lookup_expr='icontains'
@@ -104,16 +106,17 @@ class EventRuleFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
         queryset=ObjectType.objects.all(),
         field_name='object_types'
     )
-    object_type = ContentTypeFilter(
+    object_type = MultiValueContentTypeFilter(
         field_name='object_types'
     )
     event_type = MultiValueCharFilter(
         method='filter_event_type'
     )
     action_type = django_filters.MultipleChoiceFilter(
-        choices=EventRuleActionChoices
+        choices=EventRuleActionChoices,
+        distinct=False,
     )
-    action_object_type = ContentTypeFilter()
+    action_object_type = MultiValueContentTypeFilter()
     action_object_id = MultiValueNumberFilter()
 
     class Meta:
@@ -142,26 +145,30 @@ class CustomFieldFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
         label=_('Search'),
     )
     type = django_filters.MultipleChoiceFilter(
-        choices=CustomFieldTypeChoices
+        choices=CustomFieldTypeChoices,
+        distinct=False,
     )
     object_type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ObjectType.objects.all(),
         field_name='object_types'
     )
-    object_type = ContentTypeFilter(
+    object_type = MultiValueContentTypeFilter(
         field_name='object_types'
     )
     related_object_type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ObjectType.objects.all(),
+        distinct=False,
         field_name='related_object_type'
     )
-    related_object_type = ContentTypeFilter()
+    related_object_type = MultiValueContentTypeFilter()
     choice_set_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=CustomFieldChoiceSet.objects.all()
+        queryset=CustomFieldChoiceSet.objects.all(),
+        distinct=False,
     )
     choice_set = django_filters.ModelMultipleChoiceFilter(
         field_name='choice_set__name',
         queryset=CustomFieldChoiceSet.objects.all(),
+        distinct=False,
         to_field_name='name'
     )
 
@@ -224,7 +231,7 @@ class CustomLinkFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
         queryset=ObjectType.objects.all(),
         field_name='object_types'
     )
-    object_type = ContentTypeFilter(
+    object_type = MultiValueContentTypeFilter(
         field_name='object_types'
     )
 
@@ -255,15 +262,17 @@ class ExportTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
         queryset=ObjectType.objects.all(),
         field_name='object_types'
     )
-    object_type = ContentTypeFilter(
+    object_type = MultiValueContentTypeFilter(
         field_name='object_types'
     )
     data_source_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data source (ID)'),
     )
     data_file_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data file (ID)'),
     )
 
@@ -294,16 +303,18 @@ class SavedFilterFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
         queryset=ObjectType.objects.all(),
         field_name='object_types'
     )
-    object_type = ContentTypeFilter(
+    object_type = MultiValueContentTypeFilter(
         field_name='object_types'
     )
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
+        distinct=False,
         label=_('User (ID)'),
     )
     user = django_filters.ModelMultipleChoiceFilter(
         field_name='user__username',
         queryset=User.objects.all(),
+        distinct=False,
         to_field_name='username',
         label=_('User (name)'),
     )
@@ -345,18 +356,21 @@ class TableConfigFilterSet(ChangeLoggedModelFilterSet):
     )
     object_type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ObjectType.objects.all(),
+        distinct=False,
         field_name='object_type'
     )
-    object_type = ContentTypeFilter(
+    object_type = MultiValueContentTypeFilter(
         field_name='object_type'
     )
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
+        distinct=False,
         label=_('User (ID)'),
     )
     user = django_filters.ModelMultipleChoiceFilter(
         field_name='user__username',
         queryset=User.objects.all(),
+        distinct=False,
         to_field_name='username',
         label=_('User (name)'),
     )
@@ -395,14 +409,16 @@ class TableConfigFilterSet(ChangeLoggedModelFilterSet):
 class BookmarkFilterSet(BaseFilterSet):
     created = django_filters.DateTimeFilter()
     object_type_id = MultiValueNumberFilter()
-    object_type = ContentTypeFilter()
+    object_type = MultiValueContentTypeFilter()
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
+        distinct=False,
         label=_('User (ID)'),
     )
     user = django_filters.ModelMultipleChoiceFilter(
         field_name='user__username',
         queryset=User.objects.all(),
+        distinct=False,
         to_field_name='username',
         label=_('User (name)'),
     )
@@ -462,7 +478,7 @@ class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
         method='search',
         label=_('Search'),
     )
-    object_type = ContentTypeFilter()
+    object_type = MultiValueContentTypeFilter()
 
     class Meta:
         model = ImageAttachment
@@ -481,22 +497,26 @@ class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
 @register_filterset
 class JournalEntryFilterSet(NetBoxModelFilterSet):
     created = django_filters.DateTimeFromToRangeFilter()
-    assigned_object_type = ContentTypeFilter()
+    assigned_object_type = MultiValueContentTypeFilter()
     assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=ContentType.objects.all()
+        queryset=ContentType.objects.all(),
+        distinct=False,
     )
     created_by_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
+        distinct=False,
         label=_('User (ID)'),
     )
     created_by = django_filters.ModelMultipleChoiceFilter(
         field_name='created_by__username',
         queryset=User.objects.all(),
+        distinct=False,
         to_field_name='username',
         label=_('User (name)'),
     )
     kind = django_filters.MultipleChoiceFilter(
-        choices=JournalEntryKindChoices
+        choices=JournalEntryKindChoices,
+        distinct=False,
     )
 
     class Meta:
@@ -576,19 +596,22 @@ class TaggedItemFilterSet(BaseFilterSet):
         method='search',
         label=_('Search'),
     )
-    object_type = ContentTypeFilter(
+    object_type = MultiValueContentTypeFilter(
         field_name='content_type'
     )
     object_type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ContentType.objects.all(),
+        distinct=False,
         field_name='content_type_id'
     )
     tag_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Tag.objects.all()
+        queryset=Tag.objects.all(),
+        distinct=False,
     )
     tag = django_filters.ModelMultipleChoiceFilter(
         field_name='tag__slug',
         queryset=Tag.objects.all(),
+        distinct=False,
         to_field_name='slug',
     )
 
@@ -614,10 +637,12 @@ class ConfigContextProfileFilterSet(PrimaryModelFilterSet):
     )
     data_source_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data source (ID)'),
     )
     data_file_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data file (ID)'),
     )
 
@@ -645,11 +670,13 @@ class ConfigContextFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
     )
     profile_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ConfigContextProfile.objects.all(),
+        distinct=False,
         label=_('Profile (ID)'),
     )
     profile = django_filters.ModelMultipleChoiceFilter(
         field_name='profile__name',
         queryset=ConfigContextProfile.objects.all(),
+        distinct=False,
         to_field_name='name',
         label=_('Profile (name)'),
     )
@@ -786,10 +813,12 @@ class ConfigContextFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
     )
     data_source_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data source (ID)'),
     )
     data_file_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data file (ID)'),
     )
 
@@ -815,10 +844,12 @@ class ConfigTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
     )
     data_source_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data source (ID)'),
     )
     data_file_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data file (ID)'),
     )
     tag = TagFilter()

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

@@ -19,6 +19,7 @@ from django.utils.translation import gettext_lazy as _
 from core.models import ObjectType
 from extras.choices import *
 from extras.data import CHOICE_SETS
+from netbox.context import query_cache
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
 from netbox.models.mixins import OwnerMixin
@@ -58,8 +59,20 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
         """
         Return all CustomFields assigned to the given model.
         """
+        # Check the request cache before hitting the database
+        cache = query_cache.get()
+        if cache is not None:
+            if custom_fields := cache['custom_fields'].get(model._meta.model):
+                return custom_fields
+
         content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
-        return self.get_queryset().filter(object_types=content_type)
+        custom_fields = self.get_queryset().filter(object_types=content_type)
+
+        # Populate the request cache to avoid redundant lookups
+        if cache is not None:
+            cache['custom_fields'][model._meta.model] = custom_fields
+
+        return custom_fields
 
     def get_defaults_for_model(self, model):
         """

+ 2 - 0
netbox/extras/models/scripts.py

@@ -178,9 +178,11 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
                 name=name,
                 is_executable=True,
             )
+    sync_classes.alters_data = True
 
     def sync_data(self):
         super().sync_data()
+    sync_data.alters_data = True
 
     def save(self, *args, **kwargs):
         self.file_root = ManagedFileRootPathChoices.SCRIPTS

+ 2 - 1
netbox/extras/signals.py

@@ -9,6 +9,7 @@ from extras.models import EventRule, Notification, Subscription
 from netbox.config import get_config
 from netbox.models.features import has_feature
 from netbox.signals import post_clean
+from utilities.data import get_config_value_ci
 from utilities.exceptions import AbortRequest
 from .models import CustomField, TaggedItem
 from .utils import run_validators
@@ -65,7 +66,7 @@ def run_save_validators(sender, instance, **kwargs):
     Run any custom validation rules for the model prior to calling save().
     """
     model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
-    validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
+    validators = get_config_value_ci(get_config().CUSTOM_VALIDATORS, model_name, default=[])
 
     run_validators(instance, validators)
 

+ 13 - 2
netbox/extras/tables/tables.py

@@ -39,9 +39,20 @@ __all__ = (
 )
 
 IMAGEATTACHMENT_IMAGE = """
+{% load thumbnail %}
 {% if record.image %}
-  <a href="{{ record.image.url }}" target="_blank" class="image-preview" data-bs-placement="top">
-    <i class="mdi mdi-image"></i></a>
+  {% thumbnail record.image "400x400" as tn %}
+    <a href="{{ record.get_absolute_url }}"
+       class="image-preview"
+       data-preview-url="{{ tn.url }}"
+       data-bs-placement="left"
+       title="{{ record.filename }}"
+       rel="noopener noreferrer"
+       target="_blank"
+       aria-label="{{ record.filename }}">
+      <i class="mdi mdi-image"></i>
+    </a>
+  {% endthumbnail %}
 {% endif %}
 <a href="{{ record.get_absolute_url }}">{{ record.filename|truncate_middle:16 }}</a>
 """

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

@@ -304,7 +304,7 @@ class ConditionSetTest(TestCase):
         Test Event Rule with incorrect condition (key "foo" is wrong). Must return false.
         """
 
-        ct = ContentType.objects.get(app_label='extras', model='webhook')
+        ct = ContentType.objects.get_by_natural_key('extras', 'webhook')
         site_ct = ContentType.objects.get_for_model(Site)
         webhook = Webhook.objects.create(name='Webhook 100', payload_url='http://example.com/?1', http_method='POST')
         form = EventRuleForm({

+ 33 - 2
netbox/extras/tests/test_event_rules.py

@@ -1,6 +1,6 @@
 import json
 import uuid
-from unittest.mock import patch
+from unittest.mock import Mock, patch
 
 import django_rq
 from django.http import HttpResponse
@@ -15,7 +15,8 @@ from dcim.choices import SiteStatusChoices
 from dcim.models import Site
 from extras.choices import EventRuleActionChoices
 from extras.events import enqueue_event, flush_events, serialize_for_event
-from extras.models import EventRule, Tag, Webhook
+from extras.models import EventRule, Script, Tag, Webhook
+from extras.signals import process_job_end_event_rules
 from extras.webhooks import generate_signature, send_webhook
 from netbox.context_managers import event_tracking
 from utilities.testing import APITestCase
@@ -395,6 +396,36 @@ class EventRuleTest(APITestCase):
         with patch.object(Session, 'send', dummy_send):
             send_webhook(**job.kwargs)
 
+    def test_job_completed_webhook_username_fallback(self):
+        """
+        Ensure job_end event processing can enqueue a webhook even when the EventContext
+        lacks legacy request attributes (e.g. `username`).
+
+        The job_start/job_end signal receivers only populate `user` and `data`, so webhook
+        processing must derive the username from the user object (or tolerate it being unset).
+        """
+        script_type = ObjectType.objects.get_for_model(Script)
+        webhook_type = ObjectType.objects.get_for_model(Webhook)
+        webhook = Webhook.objects.get(name='Webhook 1')
+        event_rule = EventRule.objects.create(
+            name='Event Rule Job Completed',
+            event_types=[JOB_COMPLETED],
+            action_type=EventRuleActionChoices.WEBHOOK,
+            action_object_type=webhook_type,
+            action_object_id=webhook.pk,
+        )
+        event_rule.object_types.set([script_type])
+        # Mimic the `core.job_end` signal sender expected by extras.signals.process_job_end_event_rules
+        # (notably: no request, and thus no legacy `username`)
+        sender = Mock(object_type=script_type, data={}, user=self.user)
+        process_job_end_event_rules(sender)
+        self.assertEqual(self.queue.count, 1)
+        job = self.queue.jobs[0]
+        self.assertEqual(job.kwargs['event_rule'], event_rule)
+        self.assertEqual(job.kwargs['event_type'], JOB_COMPLETED)
+        self.assertEqual(job.kwargs['object_type'], script_type)
+        self.assertEqual(job.kwargs['username'], self.user.username)
+
     def test_duplicate_triggers(self):
         """
         Test for erroneous duplicate event triggers resulting from saving an object multiple times

+ 17 - 17
netbox/extras/tests/test_filtersets.py

@@ -111,13 +111,13 @@ class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_object_type(self):
-        params = {'object_type': 'dcim.site'}
+        params = {'object_type': ['dcim.site']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         params = {'object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     def test_related_object_type(self):
-        params = {'related_object_type': 'dcim.site'}
+        params = {'related_object_type': ['dcim.site']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         params = {'related_object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -348,7 +348,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_object_type(self):
-        params = {'object_type': 'dcim.region'}
+        params = {'object_type': ['dcim.region']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         params = {'object_type_id': [ObjectType.objects.get_for_model(Region).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -417,7 +417,7 @@ class CustomLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_object_type(self):
-        params = {'object_type': 'dcim.site'}
+        params = {'object_type': ['dcim.site']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -508,7 +508,7 @@ class SavedFilterTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_object_type(self):
-        params = {'object_type': 'dcim.site'}
+        params = {'object_type': ['dcim.site']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -600,7 +600,7 @@ class BookmarkTestCase(TestCase, BaseFilterSetTests):
         Bookmark.objects.bulk_create(bookmarks)
 
     def test_object_type(self):
-        params = {'object_type': 'dcim.site'}
+        params = {'object_type': ['dcim.site']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         params = {'object_type_id': [ContentType.objects.get_for_model(Site).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
@@ -663,7 +663,7 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_object_type(self):
-        params = {'object_type': 'dcim.site'}
+        params = {'object_type': ['dcim.site']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -697,8 +697,8 @@ class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests):
     @classmethod
     def setUpTestData(cls):
 
-        site_ct = ContentType.objects.get(app_label='dcim', model='site')
-        rack_ct = ContentType.objects.get(app_label='dcim', model='rack')
+        site_ct = ContentType.objects.get_by_natural_key('dcim', 'site')
+        rack_ct = ContentType.objects.get_by_natural_key('dcim', 'rack')
 
         sites = (
             Site(name='Site 1', slug='site-1'),
@@ -757,12 +757,12 @@ class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_object_type(self):
-        params = {'object_type': 'dcim.site'}
+        params = {'object_type': ['dcim.site']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_object_type_id_and_object_id(self):
         params = {
-            'object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk,
+            'object_type_id': ContentType.objects.get_by_natural_key('dcim', 'site').pk,
             'object_id': [Site.objects.first().pk],
         }
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -845,14 +845,14 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
     def test_assigned_object_type(self):
-        params = {'assigned_object_type': 'dcim.site'}
+        params = {'assigned_object_type': ['dcim.site']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
-        params = {'assigned_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
+        params = {'assigned_object_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
     def test_assigned_object(self):
         params = {
-            'assigned_object_type': 'dcim.site',
+            'assigned_object_type': ['dcim.site'],
             'assigned_object_id': [Site.objects.first().pk],
         }
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1426,15 +1426,15 @@ class TaggedItemFilterSetTestCase(TestCase):
 
     def test_object_type(self):
         object_type = ObjectType.objects.get_for_model(Site)
-        params = {'object_type': 'dcim.site'}
+        params = {'object_type': ['dcim.site']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         params = {'object_type_id': [object_type.pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
-    def test_object_id(self):
+    def test_object(self):
         site_ids = Site.objects.values_list('pk', flat=True)
         params = {
-            'object_type': 'dcim.site',
+            'object_type': ['dcim.site'],
             'object_id': site_ids[:2],
         }
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

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

@@ -17,7 +17,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMac
 class ImageAttachmentTests(TestCase):
     @classmethod
     def setUpTestData(cls):
-        cls.ct_rack = ContentType.objects.get(app_label='dcim', model='rack')
+        cls.ct_rack = ContentType.objects.get_by_natural_key('dcim', 'rack')
         cls.image_content = b''
 
     def _stub_image_attachment(self, object_id, image_filename, name=None):

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

@@ -27,7 +27,7 @@ class ImageUploadTests(TestCase):
     def setUpTestData(cls):
         # We only need a ContentType with model="rack" for the prefix;
         # this doesn't require creating a Rack object.
-        cls.ct_rack = ContentType.objects.get(app_label='dcim', model='rack')
+        cls.ct_rack = ContentType.objects.get_by_natural_key('dcim', 'rack')
 
     def _stub_instance(self, object_id=12, name=None):
         """

+ 52 - 9
netbox/ipam/filtersets.py

@@ -16,7 +16,8 @@ from netbox.filtersets import (
 )
 from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
 from utilities.filters import (
-    ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
+    MultiValueCharFilter, MultiValueContentTypeFilter, MultiValueNumberFilter, NumericArrayFilter,
+    TreeNodeMultipleChoiceFilter,
 )
 from utilities.filtersets import register_filterset
 from virtualization.models import VirtualMachine, VMInterface
@@ -166,11 +167,13 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
     )
     rir_id = django_filters.ModelMultipleChoiceFilter(
         queryset=RIR.objects.all(),
+        distinct=False,
         label=_('RIR (ID)'),
     )
     rir = django_filters.ModelMultipleChoiceFilter(
         field_name='rir__slug',
         queryset=RIR.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('RIR (slug)'),
     )
@@ -206,11 +209,13 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
 class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
     rir_id = django_filters.ModelMultipleChoiceFilter(
         queryset=RIR.objects.all(),
+        distinct=False,
         label=_('RIR (ID)'),
     )
     rir = django_filters.ModelMultipleChoiceFilter(
         field_name='rir__slug',
         queryset=RIR.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('RIR (slug)'),
     )
@@ -232,11 +237,13 @@ class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
 class ASNFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     rir_id = django_filters.ModelMultipleChoiceFilter(
         queryset=RIR.objects.all(),
+        distinct=False,
         label=_('RIR (ID)'),
     )
     rir = django_filters.ModelMultipleChoiceFilter(
         field_name='rir__slug',
         queryset=RIR.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('RIR (slug)'),
     )
@@ -342,11 +349,13 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
     )
     vrf_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VRF.objects.all(),
+        distinct=False,
         label=_('VRF'),
     )
     vrf = django_filters.ModelMultipleChoiceFilter(
         field_name='vrf__rd',
         queryset=VRF.objects.all(),
+        distinct=False,
         to_field_name='rd',
         label=_('VRF (RD)'),
     )
@@ -364,17 +373,20 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
     vlan_group_id = django_filters.ModelMultipleChoiceFilter(
         field_name='vlan__group',
         queryset=VLANGroup.objects.all(),
+        distinct=False,
         to_field_name='id',
         label=_('VLAN Group (ID)'),
     )
     vlan_group = django_filters.ModelMultipleChoiceFilter(
         field_name='vlan__group__slug',
         queryset=VLANGroup.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('VLAN Group (slug)'),
     )
     vlan_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VLAN.objects.all(),
+        distinct=False,
         label=_('VLAN (ID)'),
     )
     vlan_vid = django_filters.NumberFilter(
@@ -383,16 +395,19 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
     )
     role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Role.objects.all(),
+        distinct=False,
         label=_('Role (ID)'),
     )
     role = django_filters.ModelMultipleChoiceFilter(
         field_name='role__slug',
         queryset=Role.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Role (slug)'),
     )
     status = django_filters.MultipleChoiceFilter(
         choices=PrefixStatusChoices,
+        distinct=False,
         null_value=None
     )
 
@@ -486,26 +501,31 @@ class IPRangeFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt
     )
     vrf_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VRF.objects.all(),
+        distinct=False,
         label=_('VRF'),
     )
     vrf = django_filters.ModelMultipleChoiceFilter(
         field_name='vrf__rd',
         queryset=VRF.objects.all(),
+        distinct=False,
         to_field_name='rd',
         label=_('VRF (RD)'),
     )
     role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Role.objects.all(),
+        distinct=False,
         label=_('Role (ID)'),
     )
     role = django_filters.ModelMultipleChoiceFilter(
         field_name='role__slug',
         queryset=Role.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Role (slug)'),
     )
     status = django_filters.MultipleChoiceFilter(
         choices=IPRangeStatusChoices,
+        distinct=False,
         null_value=None
     )
     parent = MultiValueCharFilter(
@@ -588,11 +608,13 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
     )
     vrf_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VRF.objects.all(),
+        distinct=False,
         label=_('VRF'),
     )
     vrf = django_filters.ModelMultipleChoiceFilter(
         field_name='vrf__rd',
         queryset=VRF.objects.all(),
+        distinct=False,
         to_field_name='rd',
         label=_('VRF (RD)'),
     )
@@ -607,7 +629,7 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
         to_field_name='rd',
         label=_('VRF (RD)'),
     )
-    assigned_object_type = ContentTypeFilter()
+    assigned_object_type = MultiValueContentTypeFilter()
     device = MultiValueCharFilter(
         method='filter_device',
         field_name='name',
@@ -665,10 +687,12 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
     )
     status = django_filters.MultipleChoiceFilter(
         choices=IPAddressStatusChoices,
+        distinct=False,
         null_value=None
     )
     role = django_filters.MultipleChoiceFilter(
-        choices=IPAddressRoleChoices
+        choices=IPAddressRoleChoices,
+        distinct=False,
     )
     service_id = django_filters.ModelMultipleChoiceFilter(
         field_name='services',
@@ -678,6 +702,7 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
     nat_inside_id = django_filters.ModelMultipleChoiceFilter(
         field_name='nat_inside',
         queryset=IPAddress.objects.all(),
+        distinct=False,
         label=_('NAT inside IP address (ID)'),
     )
 
@@ -799,10 +824,12 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
 @register_filterset
 class FHRPGroupFilterSet(PrimaryModelFilterSet):
     protocol = django_filters.MultipleChoiceFilter(
-        choices=FHRPGroupProtocolChoices
+        choices=FHRPGroupProtocolChoices,
+        distinct=False,
     )
     auth_type = django_filters.MultipleChoiceFilter(
-        choices=FHRPGroupAuthTypeChoices
+        choices=FHRPGroupAuthTypeChoices,
+        distinct=False,
     )
     related_ip = django_filters.ModelMultipleChoiceFilter(
         queryset=IPAddress.objects.all(),
@@ -846,9 +873,10 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet):
 
 @register_filterset
 class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
-    interface_type = ContentTypeFilter()
+    interface_type = MultiValueContentTypeFilter()
     group_id = django_filters.ModelMultipleChoiceFilter(
         queryset=FHRPGroup.objects.all(),
+        distinct=False,
         label=_('Group (ID)'),
     )
     device = MultiValueCharFilter(
@@ -901,7 +929,7 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
 
 @register_filterset
 class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
-    scope_type = ContentTypeFilter()
+    scope_type = MultiValueContentTypeFilter()
     region = django_filters.NumberFilter(
         method='filter_scope'
     )
@@ -979,36 +1007,43 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
+        distinct=False,
         label=_('Site (ID)'),
     )
     site = django_filters.ModelMultipleChoiceFilter(
         field_name='site__slug',
         queryset=Site.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Site (slug)'),
     )
     group_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VLANGroup.objects.all(),
+        distinct=False,
         label=_('Group (ID)'),
     )
     group = django_filters.ModelMultipleChoiceFilter(
         field_name='group__slug',
         queryset=VLANGroup.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Group'),
     )
     role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Role.objects.all(),
+        distinct=False,
         label=_('Role (ID)'),
     )
     role = django_filters.ModelMultipleChoiceFilter(
         field_name='role__slug',
         queryset=Role.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Role (slug)'),
     )
     status = django_filters.MultipleChoiceFilter(
         choices=VLANStatusChoices,
+        distinct=False,
         null_value=None
     )
     available_at_site = django_filters.ModelChoiceFilter(
@@ -1024,10 +1059,12 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         method='get_for_virtualmachine'
     )
     qinq_role = django_filters.MultipleChoiceFilter(
-        choices=VLANQinQRoleChoices
+        choices=VLANQinQRoleChoices,
+        distinct=False,
     )
     qinq_svlan_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VLAN.objects.all(),
+        distinct=False,
         label=_('Q-in-Q SVLAN (ID)'),
     )
     qinq_svlan_vid = MultiValueNumberFilter(
@@ -1122,11 +1159,13 @@ class VLANTranslationPolicyFilterSet(PrimaryModelFilterSet):
 class VLANTranslationRuleFilterSet(NetBoxModelFilterSet):
     policy_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VLANTranslationPolicy.objects.all(),
+        distinct=False,
         label=_('VLAN Translation Policy (ID)'),
     )
     policy = django_filters.ModelMultipleChoiceFilter(
         field_name='policy__name',
         queryset=VLANTranslationPolicy.objects.all(),
+        distinct=False,
         to_field_name='name',
         label=_('VLAN Translation Policy (name)'),
     )
@@ -1173,7 +1212,7 @@ class ServiceTemplateFilterSet(PrimaryModelFilterSet):
 
 @register_filterset
 class ServiceFilterSet(ContactModelFilterSet, PrimaryModelFilterSet):
-    parent_object_type = ContentTypeFilter()
+    parent_object_type = MultiValueContentTypeFilter()
     device = MultiValueCharFilter(
         method='filter_device',
         field_name='name',
@@ -1265,22 +1304,26 @@ class PrimaryIPFilterSet(django_filters.FilterSet):
     primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
         field_name='primary_ip4',
         queryset=IPAddress.objects.all(),
+        distinct=False,
         label=_('Primary IPv4 (ID)'),
     )
     primary_ip4 = django_filters.ModelMultipleChoiceFilter(
         field_name='primary_ip4__address',
         queryset=IPAddress.objects.all(),
+        distinct=False,
         to_field_name='address',
         label=_('Primary IPv4 (address)'),
     )
     primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
         field_name='primary_ip6',
         queryset=IPAddress.objects.all(),
+        distinct=False,
         label=_('Primary IPv6 (ID)'),
     )
     primary_ip6 = django_filters.ModelMultipleChoiceFilter(
         field_name='primary_ip6__address',
         queryset=IPAddress.objects.all(),
+        distinct=False,
         to_field_name='address',
         label=_('Primary IPv6 (address)'),
     )

+ 3 - 2
netbox/ipam/migrations/0070_vlangroup_vlan_id_ranges.py

@@ -13,10 +13,11 @@ def set_vid_ranges(apps, schema_editor):
     VLANGroup = apps.get_model('ipam', 'VLANGroup')
     db_alias = schema_editor.connection.alias
 
-    for group in VLANGroup.objects.using(db_alias).all():
+    vlan_groups = VLANGroup.objects.using(db_alias).only('id', 'min_vid', 'max_vid')
+    for group in vlan_groups:
         group.vid_ranges = [NumericRange(group.min_vid, group.max_vid, bounds='[]')]
         group._total_vlan_ids = group.max_vid - group.min_vid + 1
-        group.save()
+    VLANGroup.objects.using(db_alias).bulk_update(vlan_groups, ['vid_ranges', '_total_vlan_ids'], batch_size=100)
 
 
 class Migration(migrations.Migration):

+ 3 - 1
netbox/ipam/models/services.py

@@ -87,7 +87,9 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel):
         help_text=_("The specific IP addresses (if any) to which this application service is bound")
     )
 
-    clone_fields = ['protocol', 'ports', 'description', 'parent', 'ipaddresses', ]
+    clone_fields = (
+        'protocol', 'ports', 'description', 'parent_object_type', 'parent_object_id', 'ipaddresses',
+    )
 
     class Meta:
         indexes = (

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

@@ -1572,12 +1572,12 @@ class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
     def test_interface_type(self):
-        params = {'interface_type': 'dcim.interface'}
+        params = {'interface_type': ['dcim.interface']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
     def test_interface(self):
         interfaces = Interface.objects.all()[:2]
-        params = {'interface_type': 'dcim.interface', 'interface_id': [interfaces[0].pk, interfaces[1].pk]}
+        params = {'interface_type': ['dcim.interface'], 'interface_id': [interfaces[0].pk, interfaces[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_priority(self):

+ 2 - 1
netbox/netbox/api/fields.py

@@ -1,3 +1,4 @@
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.utils.translation import gettext as _
@@ -109,7 +110,7 @@ class ContentTypeField(RelatedField):
     def to_internal_value(self, data):
         try:
             app_label, model = data.split('.')
-            return self.queryset.get(app_label=app_label, model=model)
+            return ContentType.objects.get_by_natural_key(app_label=app_label, model=model)
         except ObjectDoesNotExist:
             self.fail('does_not_exist', content_type=data)
         except (AttributeError, TypeError, ValueError):

+ 2 - 1
netbox/netbox/api/serializers/base.py

@@ -112,6 +112,7 @@ class ValidatedModelSerializer(BaseModelSerializer):
             for k, v in attrs.items():
                 setattr(instance, k, v)
         instance._m2m_values = m2m_values
-        instance.full_clean()
+        # Skip uniqueness validation of individual fields inside `full_clean()` (this is handled by the serializer)
+        instance.full_clean(validate_unique=False)
 
         return data

+ 36 - 3
netbox/netbox/api/viewsets/__init__.py

@@ -170,6 +170,28 @@ class NetBoxModelViewSet(
 
     # Creates
 
+    def create(self, request, *args, **kwargs):
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        bulk_create = getattr(serializer, 'many', False)
+        self.perform_create(serializer)
+
+        # After creating the instance(s), re-initialize the serializer with a queryset
+        # to ensure related objects are prefetched.
+        if bulk_create:
+            instance_pks = [obj.pk for obj in serializer.instance]
+            # Order by PK to ensure that the ordering of objects in the response
+            # matches the ordering of those in the request.
+            qs = self.get_queryset().filter(pk__in=instance_pks).order_by('pk')
+        else:
+            qs = self.get_queryset().get(pk=serializer.instance.pk)
+
+        # Re-serialize the instance(s) with prefetched data
+        serializer = self.get_serializer(qs, many=bulk_create)
+
+        headers = self.get_success_headers(serializer.data)
+        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+
     def perform_create(self, serializer):
         model = self.queryset.model
         logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
@@ -186,9 +208,20 @@ class NetBoxModelViewSet(
     # Updates
 
     def update(self, request, *args, **kwargs):
-        # Hotwire get_object() to ensure we save a pre-change snapshot
-        self.get_object = self.get_object_with_snapshot
-        return super().update(request, *args, **kwargs)
+        partial = kwargs.pop('partial', False)
+        instance = self.get_object_with_snapshot()
+        serializer = self.get_serializer(instance, data=request.data, partial=partial)
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+
+        # After updating the instance, re-initialize the serializer with a queryset
+        # to ensure related objects are prefetched.
+        qs = self.get_queryset().get(pk=serializer.instance.pk)
+
+        # Re-serialize the instance(s) with prefetched data
+        serializer = self.get_serializer(qs)
+
+        return Response(serializer.data)
 
     def perform_update(self, serializer):
         model = self.queryset.model

+ 9 - 5
netbox/netbox/api/viewsets/mixins.py

@@ -108,13 +108,17 @@ class BulkUpdateModelMixin:
             obj.pop('id'): obj for obj in request.data
         }
 
-        data = self.perform_bulk_update(qs, update_data, partial=partial)
+        object_pks = self.perform_bulk_update(qs, update_data, partial=partial)
 
-        return Response(data, status=status.HTTP_200_OK)
+        # Prefetch related objects for all updated instances
+        qs = self.get_queryset().filter(pk__in=object_pks)
+        serializer = self.get_serializer(qs, many=True)
+
+        return Response(serializer.data, status=status.HTTP_200_OK)
 
     def perform_bulk_update(self, objects, update_data, partial):
+        updated_pks = []
         with transaction.atomic(using=router.db_for_write(self.queryset.model)):
-            data_list = []
             for obj in objects:
                 data = update_data.get(obj.id)
                 if hasattr(obj, 'snapshot'):
@@ -122,9 +126,9 @@ class BulkUpdateModelMixin:
                 serializer = self.get_serializer(obj, data=data, partial=partial)
                 serializer.is_valid(raise_exception=True)
                 self.perform_update(serializer)
-                data_list.append(serializer.data)
+                updated_pks.append(obj.pk)
 
-            return data_list
+        return updated_pks
 
     def bulk_partial_update(self, request, *args, **kwargs):
         kwargs['partial'] = True

+ 6 - 11
netbox/netbox/filtersets.py

@@ -305,18 +305,13 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
-        # Dynamically add a Filter for each CustomField applicable to the parent model
-        custom_fields = CustomField.objects.filter(
-            object_types=ContentType.objects.get_for_model(self._meta.model)
-        ).exclude(
-            filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
-        )
-
         custom_field_filters = {}
-        for custom_field in custom_fields:
-            filter_name = f'cf_{custom_field.name}'
-            filter_instance = custom_field.to_filter()
-            if filter_instance:
+        for custom_field in CustomField.objects.get_for_model(self._meta.model):
+            if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_DISABLED:
+                # Skip disabled fields
+                continue
+            if filter_instance := custom_field.to_filter():
+                filter_name = f'cf_{custom_field.name}'
                 custom_field_filters[filter_name] = filter_instance
 
                 # Add relevant additional lookups

+ 5 - 4
netbox/netbox/forms/bulk_import.py

@@ -31,10 +31,11 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
     )
 
     def _get_custom_fields(self, content_type):
-        return CustomField.objects.filter(
-            object_types=content_type,
-            ui_editable=CustomFieldUIEditableChoices.YES
-        )
+        # Return only custom fields that are editable in the UI
+        return [
+            cf for cf in CustomField.objects.get_for_model(content_type.model_class())
+            if cf.ui_editable == CustomFieldUIEditableChoices.YES
+        ]
 
     def _get_form_field(self, customfield):
         return customfield.to_form_field(for_csv_import=True)

+ 7 - 5
netbox/netbox/forms/filtersets.py

@@ -1,5 +1,4 @@
 from django import forms
-from django.db.models import Q
 from django.utils.translation import gettext_lazy as _
 
 from extras.choices import *
@@ -35,10 +34,13 @@ class NetBoxModelFilterSetForm(FilterModifierMixin, CustomFieldsMixin, SavedFilt
     selector_fields = ('filter_id', 'q')
 
     def _get_custom_fields(self, content_type):
-        return super()._get_custom_fields(content_type).exclude(
-            Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
-            Q(type=CustomFieldTypeChoices.TYPE_JSON)
-        )
+        # Return only non-hidden custom fields for which filtering is enabled (excluding JSON fields)
+        return [
+            cf for cf in super()._get_custom_fields(content_type) if (
+                cf.filter_logic != CustomFieldFilterLogicChoices.FILTER_DISABLED and
+                cf.type != CustomFieldTypeChoices.TYPE_JSON
+            )
+        ]
 
     def _get_form_field(self, customfield):
         return customfield.to_form_field(

+ 5 - 3
netbox/netbox/forms/mixins.py

@@ -65,9 +65,11 @@ class CustomFieldsMixin:
         return ObjectType.objects.get_for_model(self.model)
 
     def _get_custom_fields(self, content_type):
-        return CustomField.objects.filter(object_types=content_type).exclude(
-            ui_editable=CustomFieldUIEditableChoices.HIDDEN
-        )
+        # Return only custom fields that are not hidden from the UI
+        return [
+            cf for cf in CustomField.objects.get_for_model(content_type.model_class())
+            if cf.ui_editable != CustomFieldUIEditableChoices.HIDDEN
+        ]
 
     def _get_form_field(self, customfield):
         return customfield.to_form_field()

+ 50 - 0
netbox/netbox/graphql/pagination.py

@@ -0,0 +1,50 @@
+import strawberry
+from strawberry.types.unset import UNSET
+from strawberry_django.pagination import _QS, apply
+
+__all__ = (
+    'OffsetPaginationInfo',
+    'OffsetPaginationInput',
+    'apply_pagination',
+)
+
+
+@strawberry.type
+class OffsetPaginationInfo:
+    offset: int = 0
+    limit: int | None = UNSET
+    start: int | None = UNSET
+
+
+@strawberry.input
+class OffsetPaginationInput(OffsetPaginationInfo):
+    """
+    Customized implementation of OffsetPaginationInput to support cursor-based pagination.
+    """
+    pass
+
+
+def apply_pagination(
+    self,
+    queryset: _QS,
+    pagination: OffsetPaginationInput | None = None,
+    *,
+    related_field_id: str | None = None,
+) -> _QS:
+    """
+    Replacement for the `apply_pagination()` method on StrawberryDjangoField to support cursor-based pagination.
+    """
+    if pagination is not None and pagination.start not in (None, UNSET):
+        if pagination.offset:
+            raise ValueError('Cannot specify both `start` and `offset` in pagination.')
+        if pagination.start < 0:
+            raise ValueError('`start` must be greater than or equal to zero.')
+
+        # Filter the queryset to include only records with a primary key greater than or equal to the start value,
+        # and force ordering by primary key to ensure consistent pagination across all records.
+        queryset = queryset.filter(pk__gte=pagination.start).order_by('pk')
+
+        # Ignore `offset` when `start` is set
+        pagination.offset = 0
+
+    return apply(pagination, queryset, related_field_id=related_field_id)

+ 4 - 0
netbox/netbox/models/__init__.py

@@ -143,6 +143,10 @@ class NestedGroupModel(OwnerMixin, NetBoxModel, MPTTModel):
     """
     Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest
     recursively using MPTT. Within each parent, each child instance must have a unique name.
+
+    Note: django-mptt injects the (tree_id, lft) index dynamically, but Django's migration autodetector won't
+    detect it unless concrete subclasses explicitly declare Meta.indexes (even as an empty tuple). See #21016
+    and django-mptt/django-mptt#682.
     """
     parent = TreeForeignKey(
         to='self',

+ 16 - 5
netbox/netbox/models/features.py

@@ -2,7 +2,7 @@ import json
 from collections import defaultdict
 from functools import cached_property
 
-from django.contrib.contenttypes.fields import GenericRelation
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
 from django.core.validators import ValidationError
 from django.db import models
@@ -121,9 +121,11 @@ class ChangeLoggingMixin(DeleteMixin, models.Model):
         if hasattr(self, '_prechange_snapshot'):
             objectchange.prechange_data = self._prechange_snapshot
         if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE):
-            objectchange.postchange_data = self.serialize_object(exclude=exclude)
+            self._postchange_snapshot = self.serialize_object(exclude=exclude)
+            objectchange.postchange_data = self._postchange_snapshot
 
         return objectchange
+    to_objectchange.alters_data = True
 
 
 class CloningMixin(models.Model):
@@ -159,6 +161,13 @@ class CloningMixin(models.Model):
             elif field_value not in (None, ''):
                 attrs[field_name] = field_value
 
+        # Handle GenericForeignKeys. If the CT and ID fields are being cloned, also
+        # include the name of the GFK attribute itself, as this is what forms expect.
+        for field in self._meta.private_fields:
+            if isinstance(field, GenericForeignKey):
+                if field.ct_field in attrs and field.fk_field in attrs:
+                    attrs[field.name] = attrs[field.fk_field]
+
         # Include tags (if applicable)
         if is_taggable(self):
             attrs['tags'] = [tag.pk for tag in self.tags.all()]
@@ -317,9 +326,11 @@ class CustomFieldsMixin(models.Model):
                 raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
 
     def save(self, *args, **kwargs):
-        # Populate default values if omitted
-        for cf in self.custom_fields.filter(default__isnull=False):
-            if cf.name not in self.custom_field_data:
+        from extras.models import CustomField
+
+        # Populate default values for custom fields not already present in the object data
+        for cf in CustomField.objects.get_for_model(self):
+            if cf.name not in self.custom_field_data and cf.default is not None:
                 self.custom_field_data[cf.name] = cf.default
 
         super().save(*args, **kwargs)

+ 6 - 4
netbox/netbox/search/backends.py

@@ -187,7 +187,6 @@ class CachedValueSearchBackend(SearchBackend):
         return ret
 
     def cache(self, instances, indexer=None, remove_existing=True):
-        object_type = None
         custom_fields = None
 
         # Convert a single instance to an iterable
@@ -208,15 +207,18 @@ class CachedValueSearchBackend(SearchBackend):
                     except KeyError:
                         break
 
-                # Prefetch any associated custom fields
-                object_type = ObjectType.objects.get_for_model(indexer.model)
-                custom_fields = CustomField.objects.filter(object_types=object_type).exclude(search_weight=0)
+                # Prefetch any associated custom fields (excluding those with a zero search weight)
+                custom_fields = [
+                    cf for cf in CustomField.objects.get_for_model(indexer.model)
+                    if cf.search_weight > 0
+                ]
 
             # Wipe out any previously cached values for the object
             if remove_existing:
                 self.remove(instance)
 
             # Generate cache data
+            object_type = ObjectType.objects.get_for_model(indexer.model)
             for field in indexer.to_cache(instance, custom_fields=custom_fields):
                 buffer.append(
                     CachedValue(

+ 31 - 11
netbox/netbox/settings.py

@@ -11,7 +11,6 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError
 from django.core.validators import URLValidator
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
-from rest_framework.utils import field_mapping
 
 from core.exceptions import IncompatiblePluginError
 from netbox.config import PARAMS as CONFIG_PARAMS
@@ -25,15 +24,6 @@ from utilities.string import trailing_slash
 from .monkey import get_unique_validators
 
 
-#
-# Monkey-patching
-#
-
-# TODO: Remove this once #20547 has been implemented
-# Override DRF's get_unique_validators() function with our own (see bug #19302)
-field_mapping.get_unique_validators = get_unique_validators
-
-
 #
 # Environment setup
 #
@@ -399,6 +389,11 @@ if CACHING_REDIS_CA_CERT_PATH:
     CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
     CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS']['ssl_ca_certs'] = CACHING_REDIS_CA_CERT_PATH
 
+# Merge in KWARGS for additional parameters
+if caching_redis_kwargs := REDIS['caching'].get('KWARGS'):
+    CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
+    CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS'].update(caching_redis_kwargs)
+
 
 #
 # Sessions
@@ -764,7 +759,7 @@ SPECTACULAR_SETTINGS = {
     'COMPONENT_SPLIT_REQUEST': True,
     'REDOC_DIST': 'SIDECAR',
     'SERVERS': [{
-        'url': BASE_PATH,
+        'url': '',
         'description': 'NetBox',
     }],
     'SWAGGER_UI_DIST': 'SIDECAR',
@@ -808,6 +803,11 @@ if TASKS_REDIS_CA_CERT_PATH:
     RQ_PARAMS.setdefault('REDIS_CLIENT_KWARGS', {})
     RQ_PARAMS['REDIS_CLIENT_KWARGS']['ssl_ca_certs'] = TASKS_REDIS_CA_CERT_PATH
 
+# Merge in KWARGS for additional parameters
+if tasks_redis_kwargs := TASKS_REDIS.get('KWARGS'):
+    RQ_PARAMS.setdefault('REDIS_CLIENT_KWARGS', {})
+    RQ_PARAMS['REDIS_CLIENT_KWARGS'].update(tasks_redis_kwargs)
+
 # Define named RQ queues
 RQ_QUEUES = {
     RQ_QUEUE_HIGH: RQ_PARAMS,
@@ -950,6 +950,26 @@ for plugin_name in PLUGINS:
             raise ImproperlyConfigured(f"events_pipline in plugin: {plugin_name} must be a list or tuple")
 
 
+#
+# Monkey-patching
+#
+
+from rest_framework.utils import field_mapping  # noqa: E402
+from strawberry_django import pagination  # noqa: E402
+from strawberry_django.fields.field import StrawberryDjangoField  # noqa: E402
+from netbox.graphql.pagination import OffsetPaginationInput, apply_pagination  # noqa: E402
+
+# TODO: Remove this once #20547 has been implemented
+# Override DRF's get_unique_validators() function with our own (see bug #19302)
+field_mapping.get_unique_validators = get_unique_validators
+
+# Override strawberry-django's OffsetPaginationInput class to add the `start` parameter
+pagination.OffsetPaginationInput = OffsetPaginationInput
+
+# Patch StrawberryDjangoField to use our custom `apply_pagination()` method with support for cursor-based pagination
+StrawberryDjangoField.apply_pagination = apply_pagination
+
+
 # UNSUPPORTED FUNCTIONALITY: Import any local overrides.
 try:
     from .local_settings import *

+ 8 - 5
netbox/netbox/tables/tables.py

@@ -242,14 +242,17 @@ class NetBoxTable(BaseTable):
                 (name, deepcopy(column)) for name, column in registered_columns.items()
             ])
 
-        # Add custom field & custom link columns
-        object_type = ObjectType.objects.get_for_model(self._meta.model)
-        custom_fields = CustomField.objects.filter(
-            object_types=object_type
-        ).exclude(ui_visible=CustomFieldUIVisibleChoices.HIDDEN)
+        # Add columns for custom fields
+        custom_fields = [
+            cf for cf in CustomField.objects.get_for_model(self._meta.model)
+            if cf.ui_visible != CustomFieldUIVisibleChoices.HIDDEN
+        ]
         extra_columns.extend([
             (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
         ])
+
+        # Add columns for custom links
+        object_type = ObjectType.objects.get_for_model(self._meta.model)
         custom_links = CustomLink.objects.filter(object_types=object_type, enabled=True)
         extra_columns.extend([
             (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links

+ 143 - 20
netbox/netbox/tests/test_graphql.py

@@ -4,10 +4,8 @@ from django.test import override_settings
 from django.urls import reverse
 from rest_framework import status
 
-from core.models import ObjectType
 from dcim.choices import LocationStatusChoices
 from dcim.models import Site, Location
-from users.models import ObjectPermission
 from utilities.testing import disable_warnings, APITestCase, TestCase
 
 
@@ -45,17 +43,28 @@ class GraphQLTestCase(TestCase):
 
 class GraphQLAPITestCase(APITestCase):
 
-    @override_settings(LOGIN_REQUIRED=True)
-    def test_graphql_filter_objects(self):
-        """
-        Test the operation of filters for GraphQL API requests.
-        """
+    @classmethod
+    def setUpTestData(cls):
         sites = (
             Site(name='Site 1', slug='site-1'),
             Site(name='Site 2', slug='site-2'),
             Site(name='Site 3', slug='site-3'),
+            Site(name='Site 4', slug='site-4'),
+            Site(name='Site 5', slug='site-5'),
+            Site(name='Site 6', slug='site-6'),
+            Site(name='Site 7', slug='site-7'),
         )
         Site.objects.bulk_create(sites)
+
+    @override_settings(LOGIN_REQUIRED=True)
+    def test_graphql_filter_objects(self):
+        """
+        Test the operation of filters for GraphQL API requests.
+        """
+        self.add_permissions('dcim.view_site', 'dcim.view_location')
+        url = reverse('graphql')
+
+        sites = Site.objects.all()[:3]
         Location.objects.create(
             site=sites[0],
             name='Location 1',
@@ -75,18 +84,6 @@ class GraphQLAPITestCase(APITestCase):
             status=LocationStatusChoices.STATUS_ACTIVE
         ),
 
-        # Add object-level permission
-        obj_perm = ObjectPermission(
-            name='Test permission',
-            actions=['view']
-        )
-        obj_perm.save()
-        obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ObjectType.objects.get_for_model(Location))
-        obj_perm.object_types.add(ObjectType.objects.get_for_model(Site))
-
-        url = reverse('graphql')
-
         # A valid request should return the filtered list
         query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {id site {id}}}'
         response = self.client.post(url, data={'query': query}, format="json", **self.header)
@@ -133,10 +130,136 @@ class GraphQLAPITestCase(APITestCase):
         self.assertEqual(len(data['data']['location_list']), 0)
 
         # Removing the permissions from location should result in an empty locations list
-        obj_perm.object_types.remove(ObjectType.objects.get_for_model(Location))
+        self.remove_permissions('dcim.view_location')
         query = '{site(id: ' + str(sites[0].pk) + ') {id locations {id}}}'
         response = self.client.post(url, data={'query': query}, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         data = json.loads(response.content)
         self.assertNotIn('errors', data)
         self.assertEqual(len(data['data']['site']['locations']), 0)
+
+    def test_offset_pagination(self):
+        self.add_permissions('dcim.view_site')
+        url = reverse('graphql')
+
+        # Test `limit` only
+        query = """
+        {
+            site_list(pagination: {limit: 3}) {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['site_list']), 3)
+        self.assertEqual(data['data']['site_list'][0]['name'], 'Site 1')
+        self.assertEqual(data['data']['site_list'][1]['name'], 'Site 2')
+        self.assertEqual(data['data']['site_list'][2]['name'], 'Site 3')
+
+        # Test `offset` only
+        query = """
+        {
+            site_list(pagination: {offset: 3}) {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['site_list']), 4)
+        self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
+        self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
+        self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
+        self.assertEqual(data['data']['site_list'][3]['name'], 'Site 7')
+
+        # Test `offset` & `limit`
+        query = """
+        {
+            site_list(pagination: {offset: 3, limit: 3}) {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['site_list']), 3)
+        self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
+        self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
+        self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
+
+    def test_cursor_pagination(self):
+        self.add_permissions('dcim.view_site')
+        url = reverse('graphql')
+
+        # Page 1
+        query = """
+        {
+            site_list(pagination: {start: 0, limit: 3}) {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['site_list']), 3)
+        self.assertEqual(data['data']['site_list'][0]['name'], 'Site 1')
+        self.assertEqual(data['data']['site_list'][1]['name'], 'Site 2')
+        self.assertEqual(data['data']['site_list'][2]['name'], 'Site 3')
+
+        # Page 2
+        start_id = int(data['data']['site_list'][-1]['id']) + 1
+        query = """
+        {
+            site_list(pagination: {start: """ + str(start_id) + """, limit: 3}) {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['site_list']), 3)
+        self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
+        self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
+        self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
+
+        # Page 3
+        start_id = int(data['data']['site_list'][-1]['id']) + 1
+        query = """
+        {
+            site_list(pagination: {start: """ + str(start_id) + """, limit: 3}) {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['site_list']), 1)
+        self.assertEqual(data['data']['site_list'][0]['name'], 'Site 7')
+
+    def test_pagination_conflict(self):
+        url = reverse('graphql')
+        query = """
+        {
+            site_list(pagination: {start: 1, offset: 1}) {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertIn('errors', data)
+        self.assertEqual(data['errors'][0]['message'], 'Cannot specify both `start` and `offset` in pagination.')

+ 61 - 1
netbox/netbox/tests/test_model_features.py

@@ -1,18 +1,28 @@
+from unittest import skipIf
+
+from django.conf import settings
 from django.test import TestCase
 
 from core.models import AutoSyncRecord, DataSource
+from dcim.models import Site
 from extras.models import CustomLink
+from ipam.models import Prefix
 from netbox.models.features import get_model_features, has_feature, model_is_public
-from netbox.tests.dummy_plugin.models import DummyModel
 from taggit.models import Tag
 
 
 class ModelFeaturesTestCase(TestCase):
+    """
+    A test case class for verifying model features and utility functions.
+    """
 
+    @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
     def test_model_is_public(self):
         """
         Test that the is_public() utility function returns True for public models only.
         """
+        from netbox.tests.dummy_plugin.models import DummyModel
+
         # Public model
         self.assertFalse(hasattr(DataSource, '_netbox_private'))
         self.assertTrue(model_is_public(DataSource))
@@ -51,3 +61,53 @@ class ModelFeaturesTestCase(TestCase):
         features = get_model_features(CustomLink)
         self.assertIn('cloning', features)
         self.assertNotIn('bookmarks', features)
+
+    def test_cloningmixin_injects_gfk_attribute(self):
+        """
+        Tests the cloning mixin with GFK attribute injection in the `clone` method.
+
+        This test validates that the `clone` method correctly handles
+        and retains the General Foreign Key (GFK) attributes on an
+        object when the cloning fields are explicitly defined.
+        """
+        site = Site.objects.create(name='Test Site', slug='test-site')
+        prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=site)
+
+        original_clone_fields = getattr(Prefix, 'clone_fields', None)
+        try:
+            Prefix.clone_fields = ('scope_type', 'scope_id')
+            attrs = prefix.clone()
+
+            self.assertEqual(attrs['scope_type'], prefix.scope_type_id)
+            self.assertEqual(attrs['scope_id'], prefix.scope_id)
+            self.assertEqual(attrs['scope'], prefix.scope_id)
+        finally:
+            if original_clone_fields is None:
+                delattr(Prefix, 'clone_fields')
+            else:
+                Prefix.clone_fields = original_clone_fields
+
+    def test_cloningmixin_does_not_inject_gfk_attribute_if_incomplete(self):
+        """
+        Tests the cloning mixin with incomplete cloning fields does not inject the GFK attribute.
+
+        This test validates that the `clone` method correctly handles
+        the case where the cloning fields are incomplete, ensuring that
+        the generic foreign key (GFK) attribute is not injected during
+        the cloning process.
+        """
+        site = Site.objects.create(name='Test Site', slug='test-site')
+        prefix = Prefix.objects.create(prefix='10.0.0.0/24', scope=site)
+
+        original_clone_fields = getattr(Prefix, 'clone_fields', None)
+        try:
+            Prefix.clone_fields = ('scope_type',)
+            attrs = prefix.clone()
+
+            self.assertIn('scope_type', attrs)
+            self.assertNotIn('scope', attrs)
+        finally:
+            if original_clone_fields is None:
+                delattr(Prefix, 'clone_fields')
+            else:
+                Prefix.clone_fields = original_clone_fields

+ 1 - 1
netbox/netbox/ui/attrs.py

@@ -103,7 +103,7 @@ class TextAttr(ObjectAttribute):
     def get_value(self, obj):
         value = resolve_attr_path(obj, self.accessor)
         # Apply format string (if any)
-        if value and self.format_string:
+        if value is not None and value != '' and self.format_string:
             return self.format_string.format(value)
         return value
 

+ 5 - 7
netbox/netbox/views/generic/bulk_views.py

@@ -5,7 +5,6 @@ from copy import deepcopy
 
 from django.contrib import messages
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
 from django.db import IntegrityError, router, transaction
 from django.db.models import ManyToManyField, ProtectedError, RestrictedError
@@ -484,12 +483,11 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
             else:
                 instance = self.queryset.model()
 
-                # For newly created objects, apply any default custom field values
-                custom_fields = CustomField.objects.filter(
-                    object_types=ContentType.objects.get_for_model(self.queryset.model),
-                    ui_editable=CustomFieldUIEditableChoices.YES
-                )
-                for cf in custom_fields:
+                # For newly created objects, apply any default values for custom fields
+                for cf in CustomField.objects.get_for_model(self.queryset.model):
+                    if cf.ui_editable != CustomFieldUIEditableChoices.YES:
+                        # Skip custom fields which are not editable via the UI
+                        continue
                     field_name = f'cf_{cf.name}'
                     if field_name not in record:
                         record[field_name] = cf.default

Plik diff jest za duży
+ 0 - 0
netbox/project-static/dist/netbox.css


Plik diff jest za duży
+ 0 - 0
netbox/project-static/dist/netbox.js


Plik diff jest za duży
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 7 - 7
netbox/project-static/package.json

@@ -31,29 +31,29 @@
     "gridstack": "12.4.2",
     "htmx.org": "2.0.8",
     "query-string": "9.3.1",
-    "sass": "1.97.2",
+    "sass": "1.97.3",
     "tom-select": "2.4.3",
     "typeface-inter": "3.18.1",
     "typeface-roboto-mono": "1.1.13"
   },
   "devDependencies": {
-    "@eslint/compat": "^2.0.1",
+    "@eslint/compat": "^2.0.2",
     "@eslint/eslintrc": "^3.3.3",
     "@eslint/js": "^9.39.2",
     "@types/bootstrap": "5.2.10",
     "@types/cookie": "^1.0.0",
     "@types/node": "^24.10.1",
-    "@typescript-eslint/eslint-plugin": "^8.53.1",
-    "@typescript-eslint/parser": "^8.53.1",
-    "esbuild": "^0.27.2",
+    "@typescript-eslint/eslint-plugin": "^8.56.0",
+    "@typescript-eslint/parser": "^8.56.0",
+    "esbuild": "^0.27.3",
     "esbuild-sass-plugin": "^3.6.0",
     "eslint": "^9.39.2",
     "eslint-config-prettier": "^10.1.8",
     "eslint-import-resolver-typescript": "^4.4.4",
     "eslint-plugin-import": "^2.32.0",
     "eslint-plugin-prettier": "^5.5.5",
-    "globals": "^17.0.0",
-    "prettier": "^3.8.0",
+    "globals": "^17.3.0",
+    "prettier": "^3.8.1",
     "typescript": "^5.9.3"
   },
   "resolutions": {

+ 9 - 7
netbox/project-static/src/bs.ts

@@ -150,20 +150,22 @@ function initSidebarAccordions(): void {
  */
 function initImagePreview(): void {
   for (const element of getElements<HTMLAnchorElement>('a.image-preview')) {
-    // Generate a max-width that's a quarter of the screen's width (note - the actual element
-    // width will be slightly larger due to the popover body's padding).
-    const maxWidth = `${Math.round(window.innerWidth / 4)}px`;
+    // Prefer a thumbnail URL for the popover (so we don't preload full-size images),
+    // but fall back to the link target if no thumbnail was provided.
+    const previewUrl = element.dataset.previewUrl ?? element.href;
+    const image = createElement('img', { src: previewUrl });
 
-    // Create an image element that uses the linked image as its `src`.
-    const image = createElement('img', { src: element.href });
-    image.style.maxWidth = maxWidth;
+    // Ensure lazy loading and async decoding
+    image.loading = 'lazy';
+    image.decoding = 'async';
 
     // Create a container for the image.
     const content = createElement('div', null, null, [image]);
 
     // Initialize the Bootstrap Popper instance.
     new Popover(element, {
-      // Attach this custom class to the popover so that it styling can be controlled via CSS.
+      // Attach this custom class to the popover so that its styling
+      // can be controlled via CSS.
       customClass: 'image-preview-popover',
       trigger: 'hover',
       html: true,

+ 23 - 0
netbox/project-static/styles/custom/_misc.scss

@@ -89,6 +89,29 @@ img.plugin-icon {
   }
 }
 
+// Image preview popover (rendered for <a.image-preview> by initImagePreview())
+.image-preview-popover {
+  --bs-popover-max-width: clamp(240px, 25vw, 640px);
+
+  .popover-header {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+  .popover-body {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+  img {
+    display: block;
+    max-width: 100%;
+    max-height: clamp(160px, 33vh, 640px);
+    height: auto;
+  }
+}
+
+
 body[data-bs-theme=dark] {
   // Assuming icon is black/white line art, invert it and tone down brightness
   img.plugin-icon {

+ 10 - 0
netbox/project-static/styles/overrides/_tabler.scss

@@ -5,6 +5,16 @@
   font-variant-ligatures: none;
 }
 
+// TODO: Remove when Tabler releases fix for https://github.com/tabler/tabler/issues/2271
+// and NetBox upgrades to that version. Fix merged to Tabler dev branch in PR #2548.
+:root,
+:host {
+  @include media-breakpoint-up(lg) {
+    margin-left: 0;
+    scrollbar-gutter: stable;
+  }
+}
+
 // Restore default foreground & background colors for <pre> blocks
 pre {
   background-color: transparent;

+ 258 - 242
netbox/project-static/yarn.lock

@@ -24,135 +24,135 @@
   dependencies:
     tslib "^2.4.0"
 
-"@esbuild/aix-ppc64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz#521cbd968dcf362094034947f76fa1b18d2d403c"
-  integrity sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==
-
-"@esbuild/android-arm64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz#61ea550962d8aa12a9b33194394e007657a6df57"
-  integrity sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==
-
-"@esbuild/android-arm@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz#554887821e009dd6d853f972fde6c5143f1de142"
-  integrity sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==
-
-"@esbuild/android-x64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz#a7ce9d0721825fc578f9292a76d9e53334480ba2"
-  integrity sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==
-
-"@esbuild/darwin-arm64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz#2cb7659bd5d109803c593cfc414450d5430c8256"
-  integrity sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==
-
-"@esbuild/darwin-x64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz#e741fa6b1abb0cd0364126ba34ca17fd5e7bf509"
-  integrity sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==
-
-"@esbuild/freebsd-arm64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz#2b64e7116865ca172d4ce034114c21f3c93e397c"
-  integrity sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==
-
-"@esbuild/freebsd-x64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz#e5252551e66f499e4934efb611812f3820e990bb"
-  integrity sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==
-
-"@esbuild/linux-arm64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz#dc4acf235531cd6984f5d6c3b13dbfb7ddb303cb"
-  integrity sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==
-
-"@esbuild/linux-arm@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz#56a900e39240d7d5d1d273bc053daa295c92e322"
-  integrity sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==
-
-"@esbuild/linux-ia32@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz#d4a36d473360f6870efcd19d52bbfff59a2ed1cc"
-  integrity sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==
-
-"@esbuild/linux-loong64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz#fcf0ab8c3eaaf45891d0195d4961cb18b579716a"
-  integrity sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==
-
-"@esbuild/linux-mips64el@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz#598b67d34048bb7ee1901cb12e2a0a434c381c10"
-  integrity sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==
-
-"@esbuild/linux-ppc64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz#3846c5df6b2016dab9bc95dde26c40f11e43b4c0"
-  integrity sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==
-
-"@esbuild/linux-riscv64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz#173d4475b37c8d2c3e1707e068c174bb3f53d07d"
-  integrity sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==
-
-"@esbuild/linux-s390x@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz#f7a4790105edcab8a5a31df26fbfac1aa3dacfab"
-  integrity sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==
-
-"@esbuild/linux-x64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz#2ecc1284b1904aeb41e54c9ddc7fcd349b18f650"
-  integrity sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==
-
-"@esbuild/netbsd-arm64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz#e2863c2cd1501845995cb11adf26f7fe4be527b0"
-  integrity sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==
-
-"@esbuild/netbsd-x64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz#93f7609e2885d1c0b5a1417885fba8d1fcc41272"
-  integrity sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==
-
-"@esbuild/openbsd-arm64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz#a1985604a203cdc325fd47542e106fafd698f02e"
-  integrity sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==
-
-"@esbuild/openbsd-x64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz#8209e46c42f1ffbe6e4ef77a32e1f47d404ad42a"
-  integrity sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==
-
-"@esbuild/openharmony-arm64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz#8fade4441893d9cc44cbd7dcf3776f508ab6fb2f"
-  integrity sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==
-
-"@esbuild/sunos-x64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz#980d4b9703a16f0f07016632424fc6d9a789dfc2"
-  integrity sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==
-
-"@esbuild/win32-arm64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz#1c09a3633c949ead3d808ba37276883e71f6111a"
-  integrity sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==
-
-"@esbuild/win32-ia32@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz#1b1e3a63ad4bef82200fef4e369e0fff7009eee5"
-  integrity sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==
-
-"@esbuild/win32-x64@0.27.2":
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b"
-  integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==
+"@esbuild/aix-ppc64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2"
+  integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==
+
+"@esbuild/android-arm64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8"
+  integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==
+
+"@esbuild/android-arm@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b"
+  integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==
+
+"@esbuild/android-x64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac"
+  integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==
+
+"@esbuild/darwin-arm64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd"
+  integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==
+
+"@esbuild/darwin-x64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a"
+  integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==
+
+"@esbuild/freebsd-arm64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b"
+  integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==
+
+"@esbuild/freebsd-x64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead"
+  integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==
+
+"@esbuild/linux-arm64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6"
+  integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==
+
+"@esbuild/linux-arm@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11"
+  integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==
+
+"@esbuild/linux-ia32@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29"
+  integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==
+
+"@esbuild/linux-loong64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed"
+  integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==
+
+"@esbuild/linux-mips64el@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1"
+  integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==
+
+"@esbuild/linux-ppc64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78"
+  integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==
+
+"@esbuild/linux-riscv64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d"
+  integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==
+
+"@esbuild/linux-s390x@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d"
+  integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==
+
+"@esbuild/linux-x64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5"
+  integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==
+
+"@esbuild/netbsd-arm64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7"
+  integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==
+
+"@esbuild/netbsd-x64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b"
+  integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==
+
+"@esbuild/openbsd-arm64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5"
+  integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==
+
+"@esbuild/openbsd-x64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b"
+  integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==
+
+"@esbuild/openharmony-arm64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e"
+  integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==
+
+"@esbuild/sunos-x64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537"
+  integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==
+
+"@esbuild/win32-arm64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e"
+  integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==
+
+"@esbuild/win32-ia32@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c"
+  integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==
+
+"@esbuild/win32-x64@0.27.3":
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17"
+  integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==
 
 "@eslint-community/eslint-utils@^4.8.0":
   version "4.9.0"
@@ -173,12 +173,12 @@
   resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
   integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
 
-"@eslint/compat@^2.0.1":
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/@eslint/compat/-/compat-2.0.1.tgz#5894516f8ce9ba884f4d4ba5ecb6b6459b231144"
-  integrity sha512-yl/JsgplclzuvGFNqwNYV4XNPhP3l62ZOP9w/47atNAdmDtIFCx6X7CSk/SlWUuBGkT4Et/5+UD+WyvX2iiIWA==
+"@eslint/compat@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@eslint/compat/-/compat-2.0.2.tgz#fc1495688664861870f5e7ee56999dc252b6dd52"
+  integrity sha512-pR1DoD0h3HfF675QZx0xsyrsU8q70Z/plx7880NOhS02NuWLgBCOMDL787nUeQ7EWLkxv3bPQJaarjcPQb2Dwg==
   dependencies:
-    "@eslint/core" "^1.0.1"
+    "@eslint/core" "^1.1.0"
 
 "@eslint/config-array@^0.21.1":
   version "0.21.1"
@@ -203,10 +203,10 @@
   dependencies:
     "@types/json-schema" "^7.0.15"
 
-"@eslint/core@^1.0.1":
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.0.1.tgz#701ff760cbd279f9490bef0ce54095f4088d4def"
-  integrity sha512-r18fEAj9uCk+VjzGt2thsbOmychS+4kxI14spVNibUO2vqKX7obOG+ymZljAwuPZl+S3clPGwCwTDtrdqTiY6Q==
+"@eslint/core@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.1.0.tgz#51f5cd970e216fbdae6721ac84491f57f965836d"
+  integrity sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==
   dependencies:
     "@types/json-schema" "^7.0.15"
 
@@ -935,101 +935,101 @@
   dependencies:
     "@types/estree" "*"
 
-"@typescript-eslint/eslint-plugin@^8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz#f6640f6f8749b71d9ab457263939e8932a3c6b46"
-  integrity sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==
+"@typescript-eslint/eslint-plugin@^8.56.0":
+  version "8.56.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz#5aec3db807a6b8437ea5d5ebf7bd16b4119aba8d"
+  integrity sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==
   dependencies:
     "@eslint-community/regexpp" "^4.12.2"
-    "@typescript-eslint/scope-manager" "8.53.1"
-    "@typescript-eslint/type-utils" "8.53.1"
-    "@typescript-eslint/utils" "8.53.1"
-    "@typescript-eslint/visitor-keys" "8.53.1"
+    "@typescript-eslint/scope-manager" "8.56.0"
+    "@typescript-eslint/type-utils" "8.56.0"
+    "@typescript-eslint/utils" "8.56.0"
+    "@typescript-eslint/visitor-keys" "8.56.0"
     ignore "^7.0.5"
     natural-compare "^1.4.0"
     ts-api-utils "^2.4.0"
 
-"@typescript-eslint/parser@^8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.53.1.tgz#58d4a70cc2daee2becf7d4521d65ea1782d6ec68"
-  integrity sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==
+"@typescript-eslint/parser@^8.56.0":
+  version "8.56.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.56.0.tgz#8ecff1678b8b1a742d29c446ccf5eeea7f971d72"
+  integrity sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==
   dependencies:
-    "@typescript-eslint/scope-manager" "8.53.1"
-    "@typescript-eslint/types" "8.53.1"
-    "@typescript-eslint/typescript-estree" "8.53.1"
-    "@typescript-eslint/visitor-keys" "8.53.1"
+    "@typescript-eslint/scope-manager" "8.56.0"
+    "@typescript-eslint/types" "8.56.0"
+    "@typescript-eslint/typescript-estree" "8.56.0"
+    "@typescript-eslint/visitor-keys" "8.56.0"
     debug "^4.4.3"
 
-"@typescript-eslint/project-service@8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.53.1.tgz#4e47856a0b14a1ceb28b0294b4badef3be1e9734"
-  integrity sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==
+"@typescript-eslint/project-service@8.56.0":
+  version "8.56.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.56.0.tgz#bb8562fecd8f7922e676fc6a1189c20dd7991d73"
+  integrity sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==
   dependencies:
-    "@typescript-eslint/tsconfig-utils" "^8.53.1"
-    "@typescript-eslint/types" "^8.53.1"
+    "@typescript-eslint/tsconfig-utils" "^8.56.0"
+    "@typescript-eslint/types" "^8.56.0"
     debug "^4.4.3"
 
-"@typescript-eslint/scope-manager@8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.53.1.tgz#6c4b8c82cd45ae3b365afc2373636e166743a8fa"
-  integrity sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==
+"@typescript-eslint/scope-manager@8.56.0":
+  version "8.56.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz#604030a4c6433df3728effdd441d47f45a86edb4"
+  integrity sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==
   dependencies:
-    "@typescript-eslint/types" "8.53.1"
-    "@typescript-eslint/visitor-keys" "8.53.1"
+    "@typescript-eslint/types" "8.56.0"
+    "@typescript-eslint/visitor-keys" "8.56.0"
 
-"@typescript-eslint/tsconfig-utils@8.53.1", "@typescript-eslint/tsconfig-utils@^8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.1.tgz#efe80b8d019cd49e5a1cf46c2eb0cd2733076424"
-  integrity sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==
+"@typescript-eslint/tsconfig-utils@8.56.0", "@typescript-eslint/tsconfig-utils@^8.56.0":
+  version "8.56.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz#2538ce83cbc376e685487960cbb24b65fe2abc4e"
+  integrity sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==
 
-"@typescript-eslint/type-utils@8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.53.1.tgz#95de2651a96d580bf5c6c6089ddd694284d558ad"
-  integrity sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w==
+"@typescript-eslint/type-utils@8.56.0":
+  version "8.56.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz#72b4edc1fc73988998f1632b3ec99c2a66eaac6e"
+  integrity sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==
   dependencies:
-    "@typescript-eslint/types" "8.53.1"
-    "@typescript-eslint/typescript-estree" "8.53.1"
-    "@typescript-eslint/utils" "8.53.1"
+    "@typescript-eslint/types" "8.56.0"
+    "@typescript-eslint/typescript-estree" "8.56.0"
+    "@typescript-eslint/utils" "8.56.0"
     debug "^4.4.3"
     ts-api-utils "^2.4.0"
 
-"@typescript-eslint/types@8.53.1", "@typescript-eslint/types@^8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.53.1.tgz#101f203f0807a63216cceceedb815fabe21d5793"
-  integrity sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==
+"@typescript-eslint/types@8.56.0", "@typescript-eslint/types@^8.56.0":
+  version "8.56.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.56.0.tgz#a2444011b9a98ca13d70411d2cbfed5443b3526a"
+  integrity sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==
 
-"@typescript-eslint/typescript-estree@8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.1.tgz#b6dce2303c9e27e95b8dcd8c325868fff53e488f"
-  integrity sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==
+"@typescript-eslint/typescript-estree@8.56.0":
+  version "8.56.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz#fadbc74c14c5bac947db04980ff58bb178701c2e"
+  integrity sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==
   dependencies:
-    "@typescript-eslint/project-service" "8.53.1"
-    "@typescript-eslint/tsconfig-utils" "8.53.1"
-    "@typescript-eslint/types" "8.53.1"
-    "@typescript-eslint/visitor-keys" "8.53.1"
+    "@typescript-eslint/project-service" "8.56.0"
+    "@typescript-eslint/tsconfig-utils" "8.56.0"
+    "@typescript-eslint/types" "8.56.0"
+    "@typescript-eslint/visitor-keys" "8.56.0"
     debug "^4.4.3"
     minimatch "^9.0.5"
     semver "^7.7.3"
     tinyglobby "^0.2.15"
     ts-api-utils "^2.4.0"
 
-"@typescript-eslint/utils@8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.53.1.tgz#81fe6c343de288701b774f4d078382f567e6edaa"
-  integrity sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg==
+"@typescript-eslint/utils@8.56.0":
+  version "8.56.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.56.0.tgz#063ce6f702ec603de1b83ee795ed5e877d6f7841"
+  integrity sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==
   dependencies:
     "@eslint-community/eslint-utils" "^4.9.1"
-    "@typescript-eslint/scope-manager" "8.53.1"
-    "@typescript-eslint/types" "8.53.1"
-    "@typescript-eslint/typescript-estree" "8.53.1"
+    "@typescript-eslint/scope-manager" "8.56.0"
+    "@typescript-eslint/types" "8.56.0"
+    "@typescript-eslint/typescript-estree" "8.56.0"
 
-"@typescript-eslint/visitor-keys@8.53.1":
-  version "8.53.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.1.tgz#405f04959be22b9be364939af8ac19c3649b6eb7"
-  integrity sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==
+"@typescript-eslint/visitor-keys@8.56.0":
+  version "8.56.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz#7d6592ab001827d3ce052155edf7ecad19688d7d"
+  integrity sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==
   dependencies:
-    "@typescript-eslint/types" "8.53.1"
-    eslint-visitor-keys "^4.2.1"
+    "@typescript-eslint/types" "8.56.0"
+    eslint-visitor-keys "^5.0.0"
 
 "@unrs/resolver-binding-android-arm-eabi@1.11.1":
   version "1.11.1"
@@ -1772,37 +1772,37 @@ esbuild-sass-plugin@^3.6.0:
     resolve "^1.22.11"
     sass "^1.97.2"
 
-esbuild@^0.27.2:
-  version "0.27.2"
-  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.2.tgz#d83ed2154d5813a5367376bb2292a9296fc83717"
-  integrity sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==
+esbuild@^0.27.3:
+  version "0.27.3"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8"
+  integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==
   optionalDependencies:
-    "@esbuild/aix-ppc64" "0.27.2"
-    "@esbuild/android-arm" "0.27.2"
-    "@esbuild/android-arm64" "0.27.2"
-    "@esbuild/android-x64" "0.27.2"
-    "@esbuild/darwin-arm64" "0.27.2"
-    "@esbuild/darwin-x64" "0.27.2"
-    "@esbuild/freebsd-arm64" "0.27.2"
-    "@esbuild/freebsd-x64" "0.27.2"
-    "@esbuild/linux-arm" "0.27.2"
-    "@esbuild/linux-arm64" "0.27.2"
-    "@esbuild/linux-ia32" "0.27.2"
-    "@esbuild/linux-loong64" "0.27.2"
-    "@esbuild/linux-mips64el" "0.27.2"
-    "@esbuild/linux-ppc64" "0.27.2"
-    "@esbuild/linux-riscv64" "0.27.2"
-    "@esbuild/linux-s390x" "0.27.2"
-    "@esbuild/linux-x64" "0.27.2"
-    "@esbuild/netbsd-arm64" "0.27.2"
-    "@esbuild/netbsd-x64" "0.27.2"
-    "@esbuild/openbsd-arm64" "0.27.2"
-    "@esbuild/openbsd-x64" "0.27.2"
-    "@esbuild/openharmony-arm64" "0.27.2"
-    "@esbuild/sunos-x64" "0.27.2"
-    "@esbuild/win32-arm64" "0.27.2"
-    "@esbuild/win32-ia32" "0.27.2"
-    "@esbuild/win32-x64" "0.27.2"
+    "@esbuild/aix-ppc64" "0.27.3"
+    "@esbuild/android-arm" "0.27.3"
+    "@esbuild/android-arm64" "0.27.3"
+    "@esbuild/android-x64" "0.27.3"
+    "@esbuild/darwin-arm64" "0.27.3"
+    "@esbuild/darwin-x64" "0.27.3"
+    "@esbuild/freebsd-arm64" "0.27.3"
+    "@esbuild/freebsd-x64" "0.27.3"
+    "@esbuild/linux-arm" "0.27.3"
+    "@esbuild/linux-arm64" "0.27.3"
+    "@esbuild/linux-ia32" "0.27.3"
+    "@esbuild/linux-loong64" "0.27.3"
+    "@esbuild/linux-mips64el" "0.27.3"
+    "@esbuild/linux-ppc64" "0.27.3"
+    "@esbuild/linux-riscv64" "0.27.3"
+    "@esbuild/linux-s390x" "0.27.3"
+    "@esbuild/linux-x64" "0.27.3"
+    "@esbuild/netbsd-arm64" "0.27.3"
+    "@esbuild/netbsd-x64" "0.27.3"
+    "@esbuild/openbsd-arm64" "0.27.3"
+    "@esbuild/openbsd-x64" "0.27.3"
+    "@esbuild/openharmony-arm64" "0.27.3"
+    "@esbuild/sunos-x64" "0.27.3"
+    "@esbuild/win32-arm64" "0.27.3"
+    "@esbuild/win32-ia32" "0.27.3"
+    "@esbuild/win32-x64" "0.27.3"
 
 escape-string-regexp@^4.0.0:
   version "4.0.0"
@@ -1902,6 +1902,11 @@ eslint-visitor-keys@^4.2.1:
   resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz"
   integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
 
+eslint-visitor-keys@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz#b9aa1a74aa48c44b3ae46c1597ce7171246a94a9"
+  integrity sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==
+
 eslint@^9.39.2:
   version "9.39.2"
   resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.2.tgz#cb60e6d16ab234c0f8369a3fe7cc87967faf4b6c"
@@ -2184,10 +2189,10 @@ globals@^14.0.0:
   resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"
   integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
 
-globals@^17.0.0:
-  version "17.0.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-17.0.0.tgz#a4196d9cfeb4d627ba165b4647b1f5853bf90a30"
-  integrity sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==
+globals@^17.3.0:
+  version "17.3.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-17.3.0.tgz#8b96544c2fa91afada02747cc9731c002a96f3b9"
+  integrity sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==
 
 globalthis@^1.0.3, globalthis@^1.0.4:
   version "1.0.4"
@@ -2985,10 +2990,10 @@ prettier-linter-helpers@^1.0.1:
   dependencies:
     fast-diff "^1.1.2"
 
-prettier@^3.8.0:
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.0.tgz#f72cf71505133f40cfa2ef77a2668cdc558fcd69"
-  integrity sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==
+prettier@^3.8.1:
+  version "3.8.1"
+  resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
+  integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
 
 punycode.js@^2.3.1:
   version "2.3.1"
@@ -3172,7 +3177,18 @@ safe-regex-test@^1.1.0:
     es-errors "^1.3.0"
     is-regex "^1.2.1"
 
-sass@1.97.2, sass@^1.97.2:
+sass@1.97.3:
+  version "1.97.3"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.3.tgz#9cb59339514fa7e2aec592b9700953ac6e331ab2"
+  integrity sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==
+  dependencies:
+    chokidar "^4.0.0"
+    immutable "^5.0.2"
+    source-map-js ">=0.6.2 <2.0.0"
+  optionalDependencies:
+    "@parcel/watcher" "^2.4.1"
+
+sass@^1.97.2:
   version "1.97.2"
   resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.2.tgz#e515a319092fd2c3b015228e3094b40198bff0da"
   integrity sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==
@@ -3441,7 +3457,7 @@ toggle-selection@^1.0.6:
 
 tom-select@2.4.3:
   version "2.4.3"
-  resolved "https://registry.npmjs.org/tom-select/-/tom-select-2.4.3.tgz"
+  resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.3.tgz#1daa4131cd317de691f39eb5bf41148265986c1f"
   integrity sha512-MFFrMxP1bpnAMPbdvPCZk0KwYxLqhYZso39torcdoefeV/NThNyDu8dV96/INJ5XQVTL3O55+GqQ78Pkj5oCfw==
   dependencies:
     "@orchidjs/sifter" "^1.1.0"

+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.5.1"
+version: "4.5.3"
 edition: "Community"
-published: "2026-01-20"
+published: "2026-02-17"

+ 1 - 1
netbox/templates/base/layout.html

@@ -53,7 +53,7 @@ Blocks:
           {% nav %}
 
           {# Release info #}
-          <div class="text-muted text-center fs-5 my-3">
+          <div class="text-muted text-center fs-5 my-3 px-3">
             {{ settings.RELEASE.name }}
             {% if not settings.RELEASE.features.commercial and not settings.ISOLATED_DEPLOYMENT %}
               <div>

+ 1 - 1
netbox/templates/core/configrevision.html

@@ -33,7 +33,7 @@
     <div class="col col-md-12">
       <div class="card">
         <h2 class="card-header">{% trans "Configuration Data" %}</h2>
-        {% include 'core/inc/config_data.html' with config=object.data %}
+        {% include 'core/inc/config_data.html' %}
       </div>
 
       <div class="card">

+ 3 - 3
netbox/templates/core/inc/config_data.html

@@ -95,7 +95,7 @@
   <tr>
     <th scope="row" class="ps-3">{% trans "Custom validators" %}</th>
     {% if config.CUSTOM_VALIDATORS %}
-      <td><pre>{{ config.CUSTOM_VALIDATORS }}</pre></td>
+      <td><pre class="p-0">{{ config.CUSTOM_VALIDATORS }}</pre></td>
     {% else %}
       <td>{{ ''|placeholder }}</td>
     {% endif %}
@@ -103,7 +103,7 @@
   <tr>
     <th scope="row" class="border-0 ps-3">{% trans "Protection rules" %}</th>
     {% if config.PROTECTION_RULES %}
-      <td class="border-0"><pre>{{ config.PROTECTION_RULES }}</pre></td>
+      <td class="border-0"><pre class="p-0">{{ config.PROTECTION_RULES }}</pre></td>
     {% else %}
       <td class="border-0">{{ ''|placeholder }}</td>
     {% endif %}
@@ -116,7 +116,7 @@
   <tr>
     <th scope="row" class="border-0 ps-3">{% trans "Default preferences" %}</th>
     {% if config.DEFAULT_USER_PREFERENCES %}
-      <td class="border-0"><pre>{{ config.DEFAULT_USER_PREFERENCES|json }}</pre></td>
+      <td class="border-0"><pre class="p-0">{{ config.DEFAULT_USER_PREFERENCES }}</pre></td>
     {% else %}
       <td class="border-0">{{ ''|placeholder }}</td>
     {% endif %}

+ 1 - 0
netbox/templates/dcim/devicetype/attrs/height.html

@@ -0,0 +1 @@
+{{ value|floatformat }}U

+ 34 - 0
netbox/templates/virtualization/panels/virtual_machine_resources.html

@@ -0,0 +1,34 @@
+{% load helpers %}
+{% load i18n %}
+
+<div class="card">
+  <h2 class="card-header">{% trans "Resources" %}</h2>
+  <table class="table table-hover attr-table">
+    <tr>
+      <th scope="row"><i class="mdi mdi-gauge"></i> {% trans "Virtual CPUs" %}</th>
+      <td>{{ object.vcpus|placeholder }}</td>
+    </tr>
+    <tr>
+      <th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
+      <td>
+        {% if object.memory %}
+          <span title={{ object.memory }}>{{ object.memory|humanize_ram_megabytes }}</span>
+        {% else %}
+          {{ ''|placeholder }}
+        {% endif %}
+      </td>
+    </tr>
+    <tr>
+      <th scope="row">
+        <i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}
+      </th>
+      <td>
+        {% if object.disk %}
+          {{ object.disk|humanize_disk_megabytes }}
+        {% else %}
+          {{ ''|placeholder }}
+        {% endif %}
+      </td>
+    </tr>
+  </table>
+</div>

+ 0 - 198
netbox/templates/virtualization/virtualmachine.html

@@ -1,199 +1 @@
 {% extends 'virtualization/virtualmachine/base.html' %}
-{% load buttons %}
-{% load static %}
-{% load helpers %}
-{% load plugins %}
-{% load i18n %}
-
-{% block content %}
-<div class="row my-3">
-	<div class="col col-12 col-md-6">
-        <div class="card">
-            <h2 class="card-header">{% trans "Virtual Machine" %}</h2>
-            <table class="table table-hover attr-table">
-                <tr>
-                    <th scope="row">{% trans "Name" %}</th>
-                    <td>{{ object }}</td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Status" %}</th>
-                    <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Start on boot" %}</th>
-                    <td>{% badge object.get_start_on_boot_display bg_color=object.get_start_on_boot_color %}</td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Role" %}</th>
-                    <td>{{ object.role|linkify|placeholder }}</td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Platform" %}</th>
-                    <td>{{ object.platform|linkify|placeholder }}</td>
-                </tr>
-                <tr>
-                  <th scope="row">{% trans "Description" %}</th>
-                  <td>{{ object.description|placeholder }}</td>
-                </tr>
-                <tr>
-                  <th scope="row">{% trans "Serial Number" %}</th>
-                  <td>{{ object.serial|placeholder }}</td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Tenant" %}</th>
-                    <td>
-                        {% if object.tenant.group %}
-                            {{ object.tenant.group|linkify }} /
-                        {% endif %}
-                        {{ object.tenant|linkify|placeholder }}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Config Template" %}</th>
-                    <td>{{ object.config_template|linkify|placeholder }}</td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Primary IPv4" %}</th>
-                    <td>
-                      {% if object.primary_ip4 %}
-                        <a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}" id="primary_ip4">{{ object.primary_ip4.address.ip }}</a>
-                        {% if object.primary_ip4.nat_inside %}
-                          ({% trans "NAT for" %} <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
-                        {% elif object.primary_ip4.nat_outside.exists %}
-                          ({% trans "NAT" %}: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
-                        {% endif %}
-                        {% copy_content "primary_ip4" %}
-                      {% else %}
-                        {{ ''|placeholder }}
-                      {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Primary IPv6" %}</th>
-                    <td>
-                      {% if object.primary_ip6 %}
-                        <a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}" id="primary_ip6">{{ object.primary_ip6.address.ip }}</a>
-                        {% if object.primary_ip6.nat_inside %}
-                          ({% trans "NAT for" %} <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
-                        {% elif object.primary_ip6.nat_outside.exists %}
-                          ({% trans "NAT" %}: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
-                        {% endif %}
-                        {% copy_content "primary_ip6" %}
-                      {% else %}
-                        {{ ''|placeholder }}
-                      {% endif %}
-                    </td>
-                </tr>
-            </table>
-        </div>
-        {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' %}
-        {% include 'inc/panels/comments.html' %}
-        {% plugin_left_page object %}
-    </div>
-	<div class="col col-12 col-md-6">
-        <div class="card">
-            <h2 class="card-header">{% trans "Cluster" %}</h2>
-            <table class="table table-hover attr-table">
-                <tr>
-                    <th scope="row">{% trans "Site" %}</th>
-                    <td>
-                        {{ object.site|linkify|placeholder }}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Cluster" %}</th>
-                    <td>
-                        {% if object.cluster.group %}
-                            {{ object.cluster.group|linkify }} /
-                        {% endif %}
-                        {{ object.cluster|linkify|placeholder }}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Cluster Type" %}</th>
-                    <td>
-                        {{ object.cluster.type|linkify|placeholder }}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Device" %}</th>
-                    <td>
-                        {{ object.device|linkify|placeholder }}
-                    </td>
-                </tr>
-            </table>
-        </div>
-        <div class="card">
-            <h2 class="card-header">{% trans "Resources" %}</h2>
-            <table class="table table-hover attr-table">
-                <tr>
-                    <th scope="row"><i class="mdi mdi-gauge"></i> {% trans "Virtual CPUs" %}</th>
-                    <td>{{ object.vcpus|placeholder }}</td>
-                </tr>
-                <tr>
-                    <th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
-                    <td>
-                        {% if object.memory %}
-                            <span title={{ object.memory }}>{{ object.memory|humanize_ram_megabytes }}</span>
-                        {% else %}
-                            {{ ''|placeholder }}
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                  <th scope="row">
-                    <i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}
-                  </th>
-                  <td>
-                    {% if object.disk %}
-                      {{ object.disk|humanize_disk_megabytes }}
-                    {% else %}
-                      {{ ''|placeholder }}
-                    {% endif %}
-                  </td>
-                </tr>
-            </table>
-        </div>
-        <div class="card">
-          <h2 class="card-header">
-            {% trans "Application Services" %}
-            {% if perms.ipam.add_service %}
-              <div class="card-actions">
-                <a href="{% url 'ipam:service_add' %}?parent_object_type={{ object|content_type_id }}&parent={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
-                  <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add an application service" %}
-                </a>
-              </div>
-            {% endif %}
-          </h2>
-          {% htmx_table 'ipam:service_list' virtual_machine_id=object.pk %}
-        </div>
-        {% include 'inc/panels/image_attachments.html' %}
-        {% plugin_right_page object %}
-    </div>
-</div>
-
-<div class="row">
-  <div class="col col-md-12">
-    <div class="card">
-      <h2 class="card-header">
-        {% trans "Virtual Disks" %}
-        {% if perms.virtualization.add_virtualdisk %}
-          <div class="card-actions">
-            <a href="{% url 'virtualization:virtualdisk_add' %}?device={{ object.device.pk }}&virtual_machine={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
-              <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Virtual Disk" %}
-            </a>
-          </div>
-        {% endif %}
-      </h2>
-      {% htmx_table 'virtualization:virtualdisk_list' virtual_machine_id=object.pk %}
-    </div>
-  </div>
-</div>
-
-<div class="row">
-    <div class="col col-md-12">
-        {% plugin_full_width_page object %}
-    </div>
-</div>
-{% endblock %}

+ 10 - 0
netbox/templates/virtualization/virtualmachine/attrs/ipaddress.html

@@ -0,0 +1,10 @@
+{% load i18n %}
+<a href="{{ value.get_absolute_url }}"{% if name %} id="attr_{{ name }}"{% endif %}>{{ value.address.ip }}</a>
+{% if value.nat_inside %}
+  ({% trans "NAT for" %} <a href="{{ value.nat_inside.get_absolute_url }}">{{ value.nat_inside.address.ip }}</a>)
+{% elif value.nat_outside.exists %}
+  ({% trans "NAT" %}: {% for nat in value.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
+{% endif %}
+<a class="btn btn-sm btn-primary copy-content" data-clipboard-target="#attr_{{ name }}" title="{% trans "Copy to clipboard" %}">
+  <i class="mdi mdi-content-copy"></i>
+</a>

+ 2 - 2
netbox/templates/virtualization/vminterface.html

@@ -78,8 +78,8 @@
         <tr>
           <th scope="row">{% trans "MAC Address" %}</th>
           <td>
-            {% if object.mac_address %}
-              <span class="font-monospace">{{ object.mac_address }}</span>
+            {% if object.primary_mac_address %}
+              <span class="font-monospace">{{ object.primary_mac_address|linkify }}</span>
               <span class="badge text-bg-primary">{% trans "Primary" %}</span>
             {% else %}
               {{ ''|placeholder }}

+ 11 - 2
netbox/tenancy/filtersets.py

@@ -5,7 +5,7 @@ from django.utils.translation import gettext as _
 from netbox.filtersets import (
     NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
 )
-from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
+from utilities.filters import MultiValueContentTypeFilter, TreeNodeMultipleChoiceFilter
 from utilities.filtersets import register_filterset
 from .models import *
 
@@ -29,11 +29,13 @@ __all__ = (
 class ContactGroupFilterSet(NestedGroupModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ContactGroup.objects.all(),
+        distinct=False,
         label=_('Parent contact group (ID)'),
     )
     parent = django_filters.ModelMultipleChoiceFilter(
         field_name='parent__slug',
         queryset=ContactGroup.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Parent contact group (slug)'),
     )
@@ -110,9 +112,10 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
         method='search',
         label=_('Search'),
     )
-    object_type = ContentTypeFilter()
+    object_type = MultiValueContentTypeFilter()
     contact_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Contact.objects.all(),
+        distinct=False,
         label=_('Contact (ID)'),
     )
     group_id = TreeNodeMultipleChoiceFilter(
@@ -130,11 +133,13 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
     )
     role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ContactRole.objects.all(),
+        distinct=False,
         label=_('Contact role (ID)'),
     )
     role = django_filters.ModelMultipleChoiceFilter(
         field_name='role__slug',
         queryset=ContactRole.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Contact role (slug)'),
     )
@@ -179,11 +184,13 @@ class ContactModelFilterSet(django_filters.FilterSet):
 class TenantGroupFilterSet(NestedGroupModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=TenantGroup.objects.all(),
+        distinct=False,
         label=_('Parent tenant group (ID)'),
     )
     parent = django_filters.ModelMultipleChoiceFilter(
         field_name='parent__slug',
         queryset=TenantGroup.objects.all(),
+        distinct=False,
         to_field_name='slug',
         label=_('Parent tenant group (slug)'),
     )
@@ -256,10 +263,12 @@ class TenancyFilterSet(django_filters.FilterSet):
     )
     tenant_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Tenant.objects.all(),
+        distinct=False,
         label=_('Tenant (ID)'),
     )
     tenant = django_filters.ModelMultipleChoiceFilter(
         queryset=Tenant.objects.all(),
+        distinct=False,
         field_name='tenant__slug',
         to_field_name='slug',
         label=_('Tenant (slug)'),

+ 23 - 0
netbox/tenancy/migrations/0023_add_mptt_tree_indexes.py

@@ -0,0 +1,23 @@
+# Generated by Django 5.2.10 on 2026-02-13 13:36
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0134_owner'),
+        ('tenancy', '0022_add_comments_to_organizationalmodel'),
+        ('users', '0015_owner'),
+    ]
+
+    operations = [
+        migrations.AddIndex(
+            model_name='contactgroup',
+            index=models.Index(fields=['tree_id', 'lft'], name='tenancy_contactgroup_tree_d2ce'),
+        ),
+        migrations.AddIndex(
+            model_name='tenantgroup',
+            index=models.Index(fields=['tree_id', 'lft'], name='tenancy_tenantgroup_tree_ifebc'),
+        ),
+    ]

+ 3 - 0
netbox/tenancy/models/contacts.py

@@ -22,6 +22,9 @@ class ContactGroup(NestedGroupModel):
     """
     class Meta:
         ordering = ['name']
+        # Empty tuple triggers Django migration detection for MPTT indexes
+        # (see #21016, django-mptt/django-mptt#682)
+        indexes = ()
         constraints = (
             models.UniqueConstraint(
                 fields=('parent', 'name'),

+ 3 - 0
netbox/tenancy/models/tenants.py

@@ -29,6 +29,9 @@ class TenantGroup(NestedGroupModel):
 
     class Meta:
         ordering = ['name']
+        # Empty tuple triggers Django migration detection for MPTT indexes
+        # (see #21016, django-mptt/django-mptt#682)
+        indexes = ()
         verbose_name = _('tenant group')
         verbose_name_plural = _('tenant groups')
 

+ 2 - 0
netbox/tenancy/tests/test_filtersets.py

@@ -355,6 +355,8 @@ class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
         ContactAssignment.objects.bulk_create(assignments)
 
     def test_object_type(self):
+        params = {'object_type': ['dcim.site']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         params = {'object_type_id': ObjectType.objects.get_by_natural_key('dcim', 'site')}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 

BIN
netbox/translations/cs/LC_MESSAGES/django.mo


Plik diff jest za duży
+ 294 - 297
netbox/translations/cs/LC_MESSAGES/django.po


BIN
netbox/translations/da/LC_MESSAGES/django.mo


Plik diff jest za duży
+ 292 - 297
netbox/translations/da/LC_MESSAGES/django.po


BIN
netbox/translations/de/LC_MESSAGES/django.mo


Plik diff jest za duży
+ 294 - 299
netbox/translations/de/LC_MESSAGES/django.po


Plik diff jest za duży
+ 247 - 250
netbox/translations/en/LC_MESSAGES/django.po


BIN
netbox/translations/es/LC_MESSAGES/django.mo


Plik diff jest za duży
+ 297 - 300
netbox/translations/es/LC_MESSAGES/django.po


Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików