Преглед изворни кода

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

Brian Tiemann пре 1 дан
родитељ
комит
1dfd1a5db4
100 измењених фајлова са 4060 додато и 2233 уклоњено
  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:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.5.1
+      placeholder: v4.5.3
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

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

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

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

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

+ 4 - 5
base_requirements.txt

@@ -27,9 +27,7 @@ django-graphiql-debug-toolbar
 django-htmx
 django-htmx
 
 
 # Modified Preorder Tree Traversal (recursive nesting of objects)
 # 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
 # Context managers for PostgreSQL advisory locks
 # https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
 # https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
@@ -85,7 +83,7 @@ drf-spectacular-sidecar
 feedparser
 feedparser
 
 
 # WSGI HTTP server
 # WSGI HTTP server
-# https://docs.gunicorn.org/en/latest/news.html
+# https://gunicorn.org/news/
 gunicorn
 gunicorn
 
 
 # Platform-agnostic template rendering engine
 # Platform-agnostic template rendering engine
@@ -159,7 +157,8 @@ strawberry-graphql
 
 
 # Strawberry GraphQL Django extension
 # Strawberry GraphQL Django extension
 # https://github.com/strawberry-graphql/strawberry-django/releases
 # 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)
 # SVG image rendering (used for rack elevations)
 # https://github.com/mozman/svgwrite/blob/master/NEWS.rst
 # https://github.com/mozman/svgwrite/blob/master/NEWS.rst

Разлика између датотеке није приказан због своје велике величине
+ 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
 ```python
 CUSTOM_VALIDATORS = {
 CUSTOM_VALIDATORS = {
-    "dcim.site": [
+    "dcim.Site": [
         {
         {
             "name": {
             "name": {
                 "min_length": 5,
                 "min_length": 5,
@@ -17,12 +17,15 @@ CUSTOM_VALIDATORS = {
         },
         },
         "my_plugin.validators.Validator1"
         "my_plugin.validators.Validator1"
     ],
     ],
-    "dcim.device": [
+    "dcim.Device": [
         "my_plugin.validators.Validator1"
         "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
 ## 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:
 The following model fields support configurable choices:
 
 
 * `circuits.Circuit.status`
 * `circuits.Circuit.status`
@@ -98,7 +104,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
 
 
 ```python
 ```python
 PROTECTION_RULES = {
 PROTECTION_RULES = {
-    "dcim.site": [
+    "dcim.Site": [
         {
         {
             "status": {
             "status": {
                 "eq": "decommissioning"
                 "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
 !!! note
     It is permissible to use Sentinel for only one database and not the other.
     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
 ## 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.
 * 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.
 * 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.
 * 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
 !!! 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
 ## 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 {
 query {
-  device_list(pagination: { offset: 0, limit: 20 }) {
+  device_list(pagination: {offset: 0, limit: 20}) {
     id
     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
 ## 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.
 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
 # 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)
 ## v4.5.1 (2026-01-20)
 
 
 ### Enhancements
 ### Enhancements

+ 40 - 3
netbox/circuits/filtersets.py

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

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

@@ -91,13 +91,13 @@ class ProviderNetworkForm(PrimaryModelForm):
 
 
 class CircuitTypeForm(OrganizationalModelForm):
 class CircuitTypeForm(OrganizationalModelForm):
     fieldsets = (
     fieldsets = (
-        FieldSet('name', 'slug', 'color', 'description', 'owner', 'tags'),
+        FieldSet('name', 'slug', 'color', 'description', 'tags'),
     )
     )
 
 
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
         fields = [
         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',
     'GitBackend',
     'LocalBackend',
     'LocalBackend',
     'S3Backend',
     'S3Backend',
+    'url_has_embedded_credentials',
 )
 )
 
 
 logger = logging.getLogger('netbox.data_backends')
 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()
 @register_data_backend()
 class LocalBackend(DataBackend):
 class LocalBackend(DataBackend):
     name = 'local'
     name = 'local'
@@ -102,7 +115,9 @@ class GitBackend(DataBackend):
             clone_args['pool_manager'] = ProxyPoolManager(self.socks_proxy)
             clone_args['pool_manager'] = ProxyPoolManager(self.socks_proxy)
 
 
         if self.url_scheme in ('http', 'https'):
         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(
                 clone_args.update(
                     {
                     {
                         "username": self.params.get('username'),
                         "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.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, PrimaryModelFilterSet
 from netbox.utils import get_data_backend_choices
 from netbox.utils import get_data_backend_choices
 from users.models import User
 from users.models import User
-from utilities.filters import ContentTypeFilter
+from utilities.filters import MultiValueContentTypeFilter
 from utilities.filtersets import register_filterset
 from utilities.filtersets import register_filterset
 from .choices import *
 from .choices import *
 from .models import *
 from .models import *
@@ -25,14 +25,17 @@ __all__ = (
 class DataSourceFilterSet(PrimaryModelFilterSet):
 class DataSourceFilterSet(PrimaryModelFilterSet):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
         choices=get_data_backend_choices,
         choices=get_data_backend_choices,
+        distinct=False,
         null_value=None
         null_value=None
     )
     )
     status = django_filters.MultipleChoiceFilter(
     status = django_filters.MultipleChoiceFilter(
         choices=DataSourceStatusChoices,
         choices=DataSourceStatusChoices,
+        distinct=False,
         null_value=None
         null_value=None
     )
     )
     sync_interval = django_filters.MultipleChoiceFilter(
     sync_interval = django_filters.MultipleChoiceFilter(
         choices=JobIntervalChoices,
         choices=JobIntervalChoices,
+        distinct=False,
         null_value=None
         null_value=None
     )
     )
 
 
@@ -57,11 +60,13 @@ class DataFileFilterSet(ChangeLoggedModelFilterSet):
     )
     )
     source_id = django_filters.ModelMultipleChoiceFilter(
     source_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data source (ID)'),
         label=_('Data source (ID)'),
     )
     )
     source = django_filters.ModelMultipleChoiceFilter(
     source = django_filters.ModelMultipleChoiceFilter(
         field_name='source__name',
         field_name='source__name',
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
+        distinct=False,
         to_field_name='name',
         to_field_name='name',
         label=_('Data source (name)'),
         label=_('Data source (name)'),
     )
     )
@@ -86,9 +91,10 @@ class JobFilterSet(BaseFilterSet):
     )
     )
     object_type_id = django_filters.ModelMultipleChoiceFilter(
     object_type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ObjectType.objects.with_feature('jobs'),
         queryset=ObjectType.objects.with_feature('jobs'),
+        distinct=False,
         field_name='object_type_id',
         field_name='object_type_id',
     )
     )
-    object_type = ContentTypeFilter()
+    object_type = MultiValueContentTypeFilter()
     created = django_filters.DateTimeFilter()
     created = django_filters.DateTimeFilter()
     created__before = django_filters.DateTimeFilter(
     created__before = django_filters.DateTimeFilter(
         field_name='created',
         field_name='created',
@@ -127,6 +133,7 @@ class JobFilterSet(BaseFilterSet):
     )
     )
     status = django_filters.MultipleChoiceFilter(
     status = django_filters.MultipleChoiceFilter(
         choices=JobStatusChoices,
         choices=JobStatusChoices,
+        distinct=False,
         null_value=None
         null_value=None
     )
     )
     queue_name = django_filters.CharFilter()
     queue_name = django_filters.CharFilter()
@@ -180,18 +187,21 @@ class ObjectChangeFilterSet(BaseFilterSet):
         label=_('Search'),
         label=_('Search'),
     )
     )
     time = django_filters.DateTimeFromToRangeFilter()
     time = django_filters.DateTimeFromToRangeFilter()
-    changed_object_type = ContentTypeFilter()
+    changed_object_type = MultiValueContentTypeFilter()
     changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
     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(
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
         queryset=User.objects.all(),
+        distinct=False,
         label=_('User (ID)'),
         label=_('User (ID)'),
     )
     )
     user = django_filters.ModelMultipleChoiceFilter(
     user = django_filters.ModelMultipleChoiceFilter(
         field_name='user__username',
         field_name='user__username',
         queryset=User.objects.all(),
         queryset=User.objects.all(),
+        distinct=False,
         to_field_name='username',
         to_field_name='username',
         label=_('User name'),
         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:
             with storage.open(self.full_path, 'wb+') as new_file:
                 new_file.write(self.data_file.data)
                 new_file.write(self.data_file.data)
+    sync_data.alters_data = True
 
 
     @cached_property
     @cached_property
     def storage(self):
     def storage(self):

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

@@ -216,6 +216,7 @@ class Job(models.Model):
 
 
         # Send signal
         # Send signal
         job_start.send(self)
         job_start.send(self)
+    start.alters_data = True
 
 
     def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None):
     def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None):
         """
         """
@@ -245,6 +246,7 @@ class Job(models.Model):
 
 
         # Send signal
         # Send signal
         job_end.send(self)
         job_end.send(self)
+    terminate.alters_data = True
 
 
     def log(self, record: logging.LogRecord):
     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.models import Tag
 from extras.utils import run_validators
 from extras.utils import run_validators
 from netbox.config import get_config
 from netbox.config import get_config
+from utilities.data import get_config_value_ci
 from netbox.context import current_request, events_queue
 from netbox.context import current_request, events_queue
 from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public
 from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public
 from utilities.exceptions import AbortRequest
 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
     # to queueing any events for the object being deleted, in case a validation error is
     # raised, causing the deletion to fail.
     # raised, causing the deletion to fail.
     model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
     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:
     try:
         run_validators(instance, validators)
         run_validators(instance, validators)
     except ValidationError as e:
     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.
     # 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
     # 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.
     # 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
     # Enqueue the object for event processing
     queue = events_queue.get()
     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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
     def test_changed_object_type(self):
     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)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
 
 

+ 19 - 2
netbox/core/views.py

@@ -1,6 +1,7 @@
 import json
 import json
 import platform
 import platform
 
 
+from copy import deepcopy
 from django import __version__ as django_version
 from django import __version__ as django_version
 from django.conf import settings
 from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
@@ -310,6 +311,22 @@ class ConfigRevisionListView(generic.ObjectListView):
 class ConfigRevisionView(generic.ObjectView):
 class ConfigRevisionView(generic.ObjectView):
     queryset = ConfigRevision.objects.all()
     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)
 @register_model_view(ConfigRevision, 'add', detail=False)
 class ConfigRevisionEditView(generic.ObjectEditView):
 class ConfigRevisionEditView(generic.ObjectEditView):
@@ -617,8 +634,8 @@ class SystemView(UserPassesTestMixin, View):
             response['Content-Disposition'] = 'attachment; filename="netbox.json"'
             response['Content-Disposition'] = 'attachment; filename="netbox.json"'
             return response
             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):
             if hasattr(config, attr) and getattr(config, attr, None):
                 setattr(config, attr, json.dumps(getattr(config, attr), cls=ConfigJSONEncoder, indent=4))
                 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 django.utils.translation import gettext as _
 from netbox.filtersets import BaseFilterSet
 from netbox.filtersets import BaseFilterSet
-from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
+from utilities.filters import MultiValueContentTypeFilter, TreeNodeMultipleChoiceFilter
 from .models import *
 from .models import *
 
 
 __all__ = (
 __all__ = (
@@ -14,7 +14,7 @@ class ScopedFilterSet(BaseFilterSet):
     """
     """
     Provides additional filtering functionality for location, site, etc.. for Scoped models.
     Provides additional filtering functionality for location, site, etc.. for Scoped models.
     """
     """
-    scope_type = ContentTypeFilter()
+    scope_type = MultiValueContentTypeFilter()
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         field_name='_region',
         field_name='_region',
@@ -43,12 +43,14 @@ class ScopedFilterSet(BaseFilterSet):
     )
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
+        distinct=False,
         field_name='_site',
         field_name='_site',
         label=_('Site (ID)'),
         label=_('Site (ID)'),
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
         field_name='_site__slug',
         field_name='_site__slug',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
+        distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('Site (slug)'),
         label=_('Site (slug)'),
     )
     )

Разлика између датотеке није приказан због своје велике величине
+ 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_ids = [decompile_path_node(node)[1] for node in self.path[0]]
         origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk)
         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
     @property
     def origin_type(self):
     def origin_type(self):
         if self.path:
         if self.path:

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

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

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

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

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

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

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

@@ -44,6 +44,9 @@ class Region(ContactsMixin, NestedGroupModel):
     )
     )
 
 
     class Meta:
     class Meta:
+        # Empty tuple triggers Django migration detection for MPTT indexes
+        # (see #21016, django-mptt/django-mptt#682)
+        indexes = ()
         constraints = (
         constraints = (
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('parent', 'name'),
                 fields=('parent', 'name'),
@@ -100,6 +103,9 @@ class SiteGroup(ContactsMixin, NestedGroupModel):
     )
     )
 
 
     class Meta:
     class Meta:
+        # Empty tuple triggers Django migration detection for MPTT indexes
+        # (see #21016, django-mptt/django-mptt#682)
+        indexes = ()
         constraints = (
         constraints = (
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('parent', 'name'),
                 fields=('parent', 'name'),
@@ -318,6 +324,9 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel):
 
 
     class Meta:
     class Meta:
         ordering = ['site', 'name']
         ordering = ['site', 'name']
+        # Empty tuple triggers Django migration detection for MPTT indexes
+        # (see #21016, django-mptt/django-mptt#682)
+        indexes = ()
         constraints = (
         constraints = (
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('site', 'parent', 'name'),
                 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
         # Remove the deleted CableTermination if it's one of the path's originating nodes
         if instance.termination in cablepath.origins:
         if instance.termination in cablepath.origins:
             cablepath.origins.remove(instance.termination)
             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()
         cablepath.retrace()
 
 
 
 

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

@@ -584,6 +584,15 @@ class BaseInterfaceTable(NetBoxTable):
         orderable=False,
         orderable=False,
         verbose_name=_('IP Addresses')
         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(
     fhrp_groups = tables.TemplateColumn(
         accessor=Accessor('fhrp_group_assignments'),
         accessor=Accessor('fhrp_group_assignments'),
         template_code=INTERFACE_FHRPGROUPS,
         template_code=INTERFACE_FHRPGROUPS,
@@ -615,10 +624,6 @@ class BaseInterfaceTable(NetBoxTable):
         verbose_name=_('Q-in-Q SVLAN'),
         verbose_name=_('Q-in-Q SVLAN'),
         linkify=True
         linkify=True
     )
     )
-    primary_mac_address = tables.Column(
-        verbose_name=_('MAC Address'),
-        linkify=True
-    )
 
 
     def value_ip_addresses(self, value):
     def value_ip_addresses(self, value):
         return ",".join([str(obj.address) for obj in value.all()])
         return ",".join([str(obj.address) for obj in value.all()])
@@ -681,11 +686,12 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
         model = models.Interface
         model = models.Interface
         fields = (
         fields = (
             'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
             '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')
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
 
 
@@ -746,10 +752,11 @@ class DeviceInterfaceTable(InterfaceTable):
         model = models.Interface
         model = models.Interface
         fields = (
         fields = (
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag',
             '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 = (
         default_columns = (
             'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
             'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
@@ -880,24 +887,36 @@ class DeviceBayTable(DeviceComponentTable):
             'args': [Accessor('device_id')],
             '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(
     status = tables.TemplateColumn(
         verbose_name=_('Status'),
         verbose_name=_('Status'),
         template_code=DEVICEBAY_STATUS,
         template_code=DEVICEBAY_STATUS,
         order_by=Accessor('installed_device__status')
         order_by=Accessor('installed_device__status')
     )
     )
     installed_device = tables.Column(
     installed_device = tables.Column(
-        verbose_name=_('Installed device'),
+        verbose_name=_('Installed Device'),
         linkify=True
         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(
     tags = columns.TagColumn(
         url_name='dcim:devicebay_list'
         url_name='dcim:devicebay_list'
     )
     )
@@ -905,8 +924,9 @@ class DeviceBayTable(DeviceComponentTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = models.DeviceBay
         model = models.DeviceBay
         fields = (
         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')
         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',
             'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description', 'is_primary',
             'comments', 'tags', 'created', 'last_updated',
             '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')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
 
 
-        # Create cables 1
         cable1 = Cable(
         cable1 = Cable(
             a_terminations=[interface1],
             a_terminations=[interface1],
             b_terminations=[interface2, interface3]
             b_terminations=[interface2, interface3]
@@ -2838,6 +2837,10 @@ class LegacyCablePathTests(CablePathTestCase):
             is_active=True
             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):
     def test_401_exclude_midspan_devices(self):
         """
         """
         [IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2]
         [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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_component_type(self):
     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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
     def test_status(self):
     def test_status(self):
@@ -6723,10 +6723,8 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
     def test_termination_types(self):
     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):
     def test_termination_ids(self):
         interface_ids = CableTermination.objects.filter(
         interface_ids = CableTermination.objects.filter(
@@ -6734,7 +6732,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
             cable_end='A'
             cable_end='A'
         ).values_list('termination_id', flat=True)
         ).values_list('termination_id', flat=True)
         params = {
         params = {
-            'termination_a_type': 'dcim.interface',
+            'termination_a_type': ['dcim.interface'],
             'termination_a_id': list(interface_ids),
             'termination_a_id': list(interface_ids),
         }
         }
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         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')
     parent_device = attrs.TemplatedAttr('parent_bay', template_name='dcim/device/attrs/parent_device.html')
     gps_coordinates = attrs.GPSCoordinatesAttr()
     gps_coordinates = attrs.GPSCoordinatesAttr()
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
-    device_type = attrs.RelatedObjectAttr('device_type', linkify=True, grouped_by='manufacturer')
     description = attrs.TextAttr('description')
     description = attrs.TextAttr('description')
     airflow = attrs.ChoiceAttr('airflow')
     airflow = attrs.ChoiceAttr('airflow')
     serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
     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)
     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):
 class DeviceDimensionsPanel(panels.ObjectAttributesPanel):
     title = _('Dimensions')
     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')
     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')
     part_number = attrs.TextAttr('part_number')
     default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True)
     default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True)
     description = attrs.TextAttr('description')
     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')
     exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization')
     full_depth = attrs.BooleanAttr('is_full_depth')
     full_depth = attrs.BooleanAttr('is_full_depth')
     weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')
     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 django.views.generic import View
 
 
 from circuits.models import Circuit, CircuitTermination
 from circuits.models import Circuit, CircuitTermination
-from dcim.ui import panels
 from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
 from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
 from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
@@ -44,6 +43,7 @@ from .choices import DeviceFaceChoices, InterfaceModeChoices
 from .models import *
 from .models import *
 from .models.device_components import PortMapping
 from .models.device_components import PortMapping
 from .object_actions import BulkAddComponents, BulkDisconnect
 from .object_actions import BulkAddComponents, BulkDisconnect
+from .ui import panels
 
 
 CABLE_TERMINATION_TYPES = {
 CABLE_TERMINATION_TYPES = {
     'dcim.consoleport': ConsolePort,
     'dcim.consoleport': ConsolePort,
@@ -2470,6 +2470,7 @@ class DeviceView(generic.ObjectView):
                 ],
                 ],
             ),
             ),
             ImageAttachmentsPanel(),
             ImageAttachmentsPanel(),
+            panels.DeviceDeviceTypePanel(),
             panels.DeviceDimensionsPanel(),
             panels.DeviceDimensionsPanel(),
             TemplatePanel('dcim/panels/device_rack_elevations.html'),
             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.fields import Field
 from rest_framework.serializers import ValidationError
 from rest_framework.serializers import ValidationError
 
 
-from core.models import ObjectType
 from extras.choices import CustomFieldTypeChoices
 from extras.choices import CustomFieldTypeChoices
 from extras.constants import CUSTOMFIELD_EMPTY_VALUES
 from extras.constants import CUSTOMFIELD_EMPTY_VALUES
 from extras.models import CustomField
 from extras.models import CustomField
@@ -24,13 +23,9 @@ class CustomFieldDefaultValues:
     def __call__(self, serializer_field):
     def __call__(self, serializer_field):
         self.model = serializer_field.parent.Meta.model
         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 = {}
         value = {}
-        for field in fields:
+        for field in CustomField.objects.get_for_model(self.model):
             if field.default is not None:
             if field.default is not None:
                 value[field.name] = field.default
                 value[field.name] = field.default
             else:
             else:
@@ -47,8 +42,7 @@ class CustomFieldsDataField(Field):
         Cache CustomFields assigned to this model to avoid redundant database queries
         Cache CustomFields assigned to this model to avoid redundant database queries
         """
         """
         if not hasattr(self, '_custom_fields'):
         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
         return self._custom_fields
 
 
     def to_representation(self, obj):
     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):
 def get_models_from_content_types(content_types):
     """
     """
     Return a list of models corresponding to the given content types, identified by natural key.
     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 = []
     models = []
     for content_type_id in content_types:
     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:
         try:
             content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
             content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
             if content_type.model_class():
             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):
 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),
         '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):
 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):
 def process_event_rules(event_rules, object_type, event):
     """
     """
     Process a list of EventRules against an 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:
     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)
             queue_name = get_config().QUEUE_MAPPINGS.get('webhook', RQ_QUEUE_DEFAULT)
             rq_queue = get_queue(queue_name)
             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
             # Compile the task parameters
             params = {
             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:
             if 'request' in event:
                 # Exclude FILES - webhooks don't need uploaded files,
                 # 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
             # Enqueue a Job to record the script's execution
             from extras.jobs import ScriptJob
             from extras.jobs import ScriptJob
+
             params = {
             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:
             if 'snapshots' in event:
                 params['snapshots'] = event['snapshots']
                 params['snapshots'] = event['snapshots']
@@ -171,7 +197,7 @@ def process_event_rules(event_rules, object_type, event):
                 object_type=object_type,
                 object_type=object_type,
                 object_id=event_data['id'],
                 object_id=event_data['id'],
                 object_repr=event_data.get('display'),
                 object_repr=event_data.get('display'),
-                event_type=event['event_type']
+                event_type=event['event_type'],
             )
             )
 
 
         else:
         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.filterset_mixins import OwnerFilterMixin
 from users.models import Group, User
 from users.models import Group, User
 from utilities.filters import (
 from utilities.filters import (
-    ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
+    MultiValueCharFilter, MultiValueContentTypeFilter, MultiValueNumberFilter
 )
 )
 from utilities.filtersets import register_filterset
 from utilities.filtersets import register_filterset
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
@@ -49,6 +49,7 @@ class ScriptFilterSet(BaseFilterSet):
     )
     )
     module_id = django_filters.ModelMultipleChoiceFilter(
     module_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ScriptModule.objects.all(),
         queryset=ScriptModule.objects.all(),
+        distinct=False,
         label=_('Script module (ID)'),
         label=_('Script module (ID)'),
     )
     )
 
 
@@ -71,7 +72,8 @@ class WebhookFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
         label=_('Search'),
         label=_('Search'),
     )
     )
     http_method = django_filters.MultipleChoiceFilter(
     http_method = django_filters.MultipleChoiceFilter(
-        choices=WebhookHttpMethodChoices
+        choices=WebhookHttpMethodChoices,
+        distinct=False,
     )
     )
     payload_url = MultiValueCharFilter(
     payload_url = MultiValueCharFilter(
         lookup_expr='icontains'
         lookup_expr='icontains'
@@ -104,16 +106,17 @@ class EventRuleFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
         queryset=ObjectType.objects.all(),
         queryset=ObjectType.objects.all(),
         field_name='object_types'
         field_name='object_types'
     )
     )
-    object_type = ContentTypeFilter(
+    object_type = MultiValueContentTypeFilter(
         field_name='object_types'
         field_name='object_types'
     )
     )
     event_type = MultiValueCharFilter(
     event_type = MultiValueCharFilter(
         method='filter_event_type'
         method='filter_event_type'
     )
     )
     action_type = django_filters.MultipleChoiceFilter(
     action_type = django_filters.MultipleChoiceFilter(
-        choices=EventRuleActionChoices
+        choices=EventRuleActionChoices,
+        distinct=False,
     )
     )
-    action_object_type = ContentTypeFilter()
+    action_object_type = MultiValueContentTypeFilter()
     action_object_id = MultiValueNumberFilter()
     action_object_id = MultiValueNumberFilter()
 
 
     class Meta:
     class Meta:
@@ -142,26 +145,30 @@ class CustomFieldFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
         label=_('Search'),
         label=_('Search'),
     )
     )
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
-        choices=CustomFieldTypeChoices
+        choices=CustomFieldTypeChoices,
+        distinct=False,
     )
     )
     object_type_id = django_filters.ModelMultipleChoiceFilter(
     object_type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ObjectType.objects.all(),
         queryset=ObjectType.objects.all(),
         field_name='object_types'
         field_name='object_types'
     )
     )
-    object_type = ContentTypeFilter(
+    object_type = MultiValueContentTypeFilter(
         field_name='object_types'
         field_name='object_types'
     )
     )
     related_object_type_id = django_filters.ModelMultipleChoiceFilter(
     related_object_type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ObjectType.objects.all(),
         queryset=ObjectType.objects.all(),
+        distinct=False,
         field_name='related_object_type'
         field_name='related_object_type'
     )
     )
-    related_object_type = ContentTypeFilter()
+    related_object_type = MultiValueContentTypeFilter()
     choice_set_id = django_filters.ModelMultipleChoiceFilter(
     choice_set_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=CustomFieldChoiceSet.objects.all()
+        queryset=CustomFieldChoiceSet.objects.all(),
+        distinct=False,
     )
     )
     choice_set = django_filters.ModelMultipleChoiceFilter(
     choice_set = django_filters.ModelMultipleChoiceFilter(
         field_name='choice_set__name',
         field_name='choice_set__name',
         queryset=CustomFieldChoiceSet.objects.all(),
         queryset=CustomFieldChoiceSet.objects.all(),
+        distinct=False,
         to_field_name='name'
         to_field_name='name'
     )
     )
 
 
@@ -224,7 +231,7 @@ class CustomLinkFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
         queryset=ObjectType.objects.all(),
         queryset=ObjectType.objects.all(),
         field_name='object_types'
         field_name='object_types'
     )
     )
-    object_type = ContentTypeFilter(
+    object_type = MultiValueContentTypeFilter(
         field_name='object_types'
         field_name='object_types'
     )
     )
 
 
@@ -255,15 +262,17 @@ class ExportTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
         queryset=ObjectType.objects.all(),
         queryset=ObjectType.objects.all(),
         field_name='object_types'
         field_name='object_types'
     )
     )
-    object_type = ContentTypeFilter(
+    object_type = MultiValueContentTypeFilter(
         field_name='object_types'
         field_name='object_types'
     )
     )
     data_source_id = django_filters.ModelMultipleChoiceFilter(
     data_source_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data source (ID)'),
         label=_('Data source (ID)'),
     )
     )
     data_file_id = django_filters.ModelMultipleChoiceFilter(
     data_file_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data file (ID)'),
         label=_('Data file (ID)'),
     )
     )
 
 
@@ -294,16 +303,18 @@ class SavedFilterFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
         queryset=ObjectType.objects.all(),
         queryset=ObjectType.objects.all(),
         field_name='object_types'
         field_name='object_types'
     )
     )
-    object_type = ContentTypeFilter(
+    object_type = MultiValueContentTypeFilter(
         field_name='object_types'
         field_name='object_types'
     )
     )
     user_id = django_filters.ModelMultipleChoiceFilter(
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
         queryset=User.objects.all(),
+        distinct=False,
         label=_('User (ID)'),
         label=_('User (ID)'),
     )
     )
     user = django_filters.ModelMultipleChoiceFilter(
     user = django_filters.ModelMultipleChoiceFilter(
         field_name='user__username',
         field_name='user__username',
         queryset=User.objects.all(),
         queryset=User.objects.all(),
+        distinct=False,
         to_field_name='username',
         to_field_name='username',
         label=_('User (name)'),
         label=_('User (name)'),
     )
     )
@@ -345,18 +356,21 @@ class TableConfigFilterSet(ChangeLoggedModelFilterSet):
     )
     )
     object_type_id = django_filters.ModelMultipleChoiceFilter(
     object_type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ObjectType.objects.all(),
         queryset=ObjectType.objects.all(),
+        distinct=False,
         field_name='object_type'
         field_name='object_type'
     )
     )
-    object_type = ContentTypeFilter(
+    object_type = MultiValueContentTypeFilter(
         field_name='object_type'
         field_name='object_type'
     )
     )
     user_id = django_filters.ModelMultipleChoiceFilter(
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
         queryset=User.objects.all(),
+        distinct=False,
         label=_('User (ID)'),
         label=_('User (ID)'),
     )
     )
     user = django_filters.ModelMultipleChoiceFilter(
     user = django_filters.ModelMultipleChoiceFilter(
         field_name='user__username',
         field_name='user__username',
         queryset=User.objects.all(),
         queryset=User.objects.all(),
+        distinct=False,
         to_field_name='username',
         to_field_name='username',
         label=_('User (name)'),
         label=_('User (name)'),
     )
     )
@@ -395,14 +409,16 @@ class TableConfigFilterSet(ChangeLoggedModelFilterSet):
 class BookmarkFilterSet(BaseFilterSet):
 class BookmarkFilterSet(BaseFilterSet):
     created = django_filters.DateTimeFilter()
     created = django_filters.DateTimeFilter()
     object_type_id = MultiValueNumberFilter()
     object_type_id = MultiValueNumberFilter()
-    object_type = ContentTypeFilter()
+    object_type = MultiValueContentTypeFilter()
     user_id = django_filters.ModelMultipleChoiceFilter(
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
         queryset=User.objects.all(),
+        distinct=False,
         label=_('User (ID)'),
         label=_('User (ID)'),
     )
     )
     user = django_filters.ModelMultipleChoiceFilter(
     user = django_filters.ModelMultipleChoiceFilter(
         field_name='user__username',
         field_name='user__username',
         queryset=User.objects.all(),
         queryset=User.objects.all(),
+        distinct=False,
         to_field_name='username',
         to_field_name='username',
         label=_('User (name)'),
         label=_('User (name)'),
     )
     )
@@ -462,7 +478,7 @@ class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
     )
     )
-    object_type = ContentTypeFilter()
+    object_type = MultiValueContentTypeFilter()
 
 
     class Meta:
     class Meta:
         model = ImageAttachment
         model = ImageAttachment
@@ -481,22 +497,26 @@ class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
 @register_filterset
 @register_filterset
 class JournalEntryFilterSet(NetBoxModelFilterSet):
 class JournalEntryFilterSet(NetBoxModelFilterSet):
     created = django_filters.DateTimeFromToRangeFilter()
     created = django_filters.DateTimeFromToRangeFilter()
-    assigned_object_type = ContentTypeFilter()
+    assigned_object_type = MultiValueContentTypeFilter()
     assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
     assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=ContentType.objects.all()
+        queryset=ContentType.objects.all(),
+        distinct=False,
     )
     )
     created_by_id = django_filters.ModelMultipleChoiceFilter(
     created_by_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
         queryset=User.objects.all(),
+        distinct=False,
         label=_('User (ID)'),
         label=_('User (ID)'),
     )
     )
     created_by = django_filters.ModelMultipleChoiceFilter(
     created_by = django_filters.ModelMultipleChoiceFilter(
         field_name='created_by__username',
         field_name='created_by__username',
         queryset=User.objects.all(),
         queryset=User.objects.all(),
+        distinct=False,
         to_field_name='username',
         to_field_name='username',
         label=_('User (name)'),
         label=_('User (name)'),
     )
     )
     kind = django_filters.MultipleChoiceFilter(
     kind = django_filters.MultipleChoiceFilter(
-        choices=JournalEntryKindChoices
+        choices=JournalEntryKindChoices,
+        distinct=False,
     )
     )
 
 
     class Meta:
     class Meta:
@@ -576,19 +596,22 @@ class TaggedItemFilterSet(BaseFilterSet):
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
     )
     )
-    object_type = ContentTypeFilter(
+    object_type = MultiValueContentTypeFilter(
         field_name='content_type'
         field_name='content_type'
     )
     )
     object_type_id = django_filters.ModelMultipleChoiceFilter(
     object_type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),
+        distinct=False,
         field_name='content_type_id'
         field_name='content_type_id'
     )
     )
     tag_id = django_filters.ModelMultipleChoiceFilter(
     tag_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Tag.objects.all()
+        queryset=Tag.objects.all(),
+        distinct=False,
     )
     )
     tag = django_filters.ModelMultipleChoiceFilter(
     tag = django_filters.ModelMultipleChoiceFilter(
         field_name='tag__slug',
         field_name='tag__slug',
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
+        distinct=False,
         to_field_name='slug',
         to_field_name='slug',
     )
     )
 
 
@@ -614,10 +637,12 @@ class ConfigContextProfileFilterSet(PrimaryModelFilterSet):
     )
     )
     data_source_id = django_filters.ModelMultipleChoiceFilter(
     data_source_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data source (ID)'),
         label=_('Data source (ID)'),
     )
     )
     data_file_id = django_filters.ModelMultipleChoiceFilter(
     data_file_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data file (ID)'),
         label=_('Data file (ID)'),
     )
     )
 
 
@@ -645,11 +670,13 @@ class ConfigContextFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
     )
     )
     profile_id = django_filters.ModelMultipleChoiceFilter(
     profile_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ConfigContextProfile.objects.all(),
         queryset=ConfigContextProfile.objects.all(),
+        distinct=False,
         label=_('Profile (ID)'),
         label=_('Profile (ID)'),
     )
     )
     profile = django_filters.ModelMultipleChoiceFilter(
     profile = django_filters.ModelMultipleChoiceFilter(
         field_name='profile__name',
         field_name='profile__name',
         queryset=ConfigContextProfile.objects.all(),
         queryset=ConfigContextProfile.objects.all(),
+        distinct=False,
         to_field_name='name',
         to_field_name='name',
         label=_('Profile (name)'),
         label=_('Profile (name)'),
     )
     )
@@ -786,10 +813,12 @@ class ConfigContextFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
     )
     )
     data_source_id = django_filters.ModelMultipleChoiceFilter(
     data_source_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data source (ID)'),
         label=_('Data source (ID)'),
     )
     )
     data_file_id = django_filters.ModelMultipleChoiceFilter(
     data_file_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data file (ID)'),
         label=_('Data file (ID)'),
     )
     )
 
 
@@ -815,10 +844,12 @@ class ConfigTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
     )
     )
     data_source_id = django_filters.ModelMultipleChoiceFilter(
     data_source_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data source (ID)'),
         label=_('Data source (ID)'),
     )
     )
     data_file_id = django_filters.ModelMultipleChoiceFilter(
     data_file_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DataSource.objects.all(),
         queryset=DataSource.objects.all(),
+        distinct=False,
         label=_('Data file (ID)'),
         label=_('Data file (ID)'),
     )
     )
     tag = TagFilter()
     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 core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.data import CHOICE_SETS
 from extras.data import CHOICE_SETS
+from netbox.context import query_cache
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
 from netbox.models.mixins import OwnerMixin
 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.
         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)
         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):
     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,
                 name=name,
                 is_executable=True,
                 is_executable=True,
             )
             )
+    sync_classes.alters_data = True
 
 
     def sync_data(self):
     def sync_data(self):
         super().sync_data()
         super().sync_data()
+    sync_data.alters_data = True
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         self.file_root = ManagedFileRootPathChoices.SCRIPTS
         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.config import get_config
 from netbox.models.features import has_feature
 from netbox.models.features import has_feature
 from netbox.signals import post_clean
 from netbox.signals import post_clean
+from utilities.data import get_config_value_ci
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
 from .models import CustomField, TaggedItem
 from .models import CustomField, TaggedItem
 from .utils import run_validators
 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().
     Run any custom validation rules for the model prior to calling save().
     """
     """
     model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
     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)
     run_validators(instance, validators)
 
 

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

@@ -39,9 +39,20 @@ __all__ = (
 )
 )
 
 
 IMAGEATTACHMENT_IMAGE = """
 IMAGEATTACHMENT_IMAGE = """
+{% load thumbnail %}
 {% if record.image %}
 {% 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 %}
 {% endif %}
 <a href="{{ record.get_absolute_url }}">{{ record.filename|truncate_middle:16 }}</a>
 <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.
         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)
         site_ct = ContentType.objects.get_for_model(Site)
         webhook = Webhook.objects.create(name='Webhook 100', payload_url='http://example.com/?1', http_method='POST')
         webhook = Webhook.objects.create(name='Webhook 100', payload_url='http://example.com/?1', http_method='POST')
         form = EventRuleForm({
         form = EventRuleForm({

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

@@ -1,6 +1,6 @@
 import json
 import json
 import uuid
 import uuid
-from unittest.mock import patch
+from unittest.mock import Mock, patch
 
 
 import django_rq
 import django_rq
 from django.http import HttpResponse
 from django.http import HttpResponse
@@ -15,7 +15,8 @@ from dcim.choices import SiteStatusChoices
 from dcim.models import Site
 from dcim.models import Site
 from extras.choices import EventRuleActionChoices
 from extras.choices import EventRuleActionChoices
 from extras.events import enqueue_event, flush_events, serialize_for_event
 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 extras.webhooks import generate_signature, send_webhook
 from netbox.context_managers import event_tracking
 from netbox.context_managers import event_tracking
 from utilities.testing import APITestCase
 from utilities.testing import APITestCase
@@ -395,6 +396,36 @@ class EventRuleTest(APITestCase):
         with patch.object(Session, 'send', dummy_send):
         with patch.object(Session, 'send', dummy_send):
             send_webhook(**job.kwargs)
             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):
     def test_duplicate_triggers(self):
         """
         """
         Test for erroneous duplicate event triggers resulting from saving an object multiple times
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_object_type(self):
     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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         params = {'object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]}
         params = {'object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
     def test_related_object_type(self):
     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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         params = {'related_object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]}
         params = {'related_object_type_id': [ObjectType.objects.get_by_natural_key('dcim', 'site').pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_object_type(self):
     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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         params = {'object_type_id': [ObjectType.objects.get_for_model(Region).pk]}
         params = {'object_type_id': [ObjectType.objects.get_for_model(Region).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_object_type(self):
     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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
         params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_object_type(self):
     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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
         params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -600,7 +600,7 @@ class BookmarkTestCase(TestCase, BaseFilterSetTests):
         Bookmark.objects.bulk_create(bookmarks)
         Bookmark.objects.bulk_create(bookmarks)
 
 
     def test_object_type(self):
     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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         params = {'object_type_id': [ContentType.objects.get_for_model(Site).pk]}
         params = {'object_type_id': [ContentType.objects.get_for_model(Site).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_object_type(self):
     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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
         params = {'object_type_id': [ObjectType.objects.get_for_model(Site).pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -697,8 +697,8 @@ class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     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 = (
         sites = (
             Site(name='Site 1', slug='site-1'),
             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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_object_type(self):
     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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_object_type_id_and_object_id(self):
     def test_object_type_id_and_object_id(self):
         params = {
         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],
             'object_id': [Site.objects.first().pk],
         }
         }
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
     def test_assigned_object_type(self):
     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)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
     def test_assigned_object(self):
     def test_assigned_object(self):
         params = {
         params = {
-            'assigned_object_type': 'dcim.site',
+            'assigned_object_type': ['dcim.site'],
             'assigned_object_id': [Site.objects.first().pk],
             'assigned_object_id': [Site.objects.first().pk],
         }
         }
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1426,15 +1426,15 @@ class TaggedItemFilterSetTestCase(TestCase):
 
 
     def test_object_type(self):
     def test_object_type(self):
         object_type = ObjectType.objects.get_for_model(Site)
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         params = {'object_type_id': [object_type.pk]}
         params = {'object_type_id': [object_type.pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         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)
         site_ids = Site.objects.values_list('pk', flat=True)
         params = {
         params = {
-            'object_type': 'dcim.site',
+            'object_type': ['dcim.site'],
             'object_id': site_ids[:2],
             'object_id': site_ids[:2],
         }
         }
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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):
 class ImageAttachmentTests(TestCase):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     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''
         cls.image_content = b''
 
 
     def _stub_image_attachment(self, object_id, image_filename, name=None):
     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):
     def setUpTestData(cls):
         # We only need a ContentType with model="rack" for the prefix;
         # We only need a ContentType with model="rack" for the prefix;
         # this doesn't require creating a Rack object.
         # 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):
     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 tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
 from utilities.filters import (
 from utilities.filters import (
-    ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
+    MultiValueCharFilter, MultiValueContentTypeFilter, MultiValueNumberFilter, NumericArrayFilter,
+    TreeNodeMultipleChoiceFilter,
 )
 )
 from utilities.filtersets import register_filterset
 from utilities.filtersets import register_filterset
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
@@ -166,11 +167,13 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
     )
     )
     rir_id = django_filters.ModelMultipleChoiceFilter(
     rir_id = django_filters.ModelMultipleChoiceFilter(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
+        distinct=False,
         label=_('RIR (ID)'),
         label=_('RIR (ID)'),
     )
     )
     rir = django_filters.ModelMultipleChoiceFilter(
     rir = django_filters.ModelMultipleChoiceFilter(
         field_name='rir__slug',
         field_name='rir__slug',
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
+        distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('RIR (slug)'),
         label=_('RIR (slug)'),
     )
     )
@@ -206,11 +209,13 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
 class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
 class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
     rir_id = django_filters.ModelMultipleChoiceFilter(
     rir_id = django_filters.ModelMultipleChoiceFilter(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
+        distinct=False,
         label=_('RIR (ID)'),
         label=_('RIR (ID)'),
     )
     )
     rir = django_filters.ModelMultipleChoiceFilter(
     rir = django_filters.ModelMultipleChoiceFilter(
         field_name='rir__slug',
         field_name='rir__slug',
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
+        distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('RIR (slug)'),
         label=_('RIR (slug)'),
     )
     )
@@ -232,11 +237,13 @@ class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
 class ASNFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
 class ASNFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     rir_id = django_filters.ModelMultipleChoiceFilter(
     rir_id = django_filters.ModelMultipleChoiceFilter(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
+        distinct=False,
         label=_('RIR (ID)'),
         label=_('RIR (ID)'),
     )
     )
     rir = django_filters.ModelMultipleChoiceFilter(
     rir = django_filters.ModelMultipleChoiceFilter(
         field_name='rir__slug',
         field_name='rir__slug',
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
+        distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('RIR (slug)'),
         label=_('RIR (slug)'),
     )
     )
@@ -342,11 +349,13 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
     )
     )
     vrf_id = django_filters.ModelMultipleChoiceFilter(
     vrf_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
+        distinct=False,
         label=_('VRF'),
         label=_('VRF'),
     )
     )
     vrf = django_filters.ModelMultipleChoiceFilter(
     vrf = django_filters.ModelMultipleChoiceFilter(
         field_name='vrf__rd',
         field_name='vrf__rd',
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
+        distinct=False,
         to_field_name='rd',
         to_field_name='rd',
         label=_('VRF (RD)'),
         label=_('VRF (RD)'),
     )
     )
@@ -364,17 +373,20 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
     vlan_group_id = django_filters.ModelMultipleChoiceFilter(
     vlan_group_id = django_filters.ModelMultipleChoiceFilter(
         field_name='vlan__group',
         field_name='vlan__group',
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
+        distinct=False,
         to_field_name='id',
         to_field_name='id',
         label=_('VLAN Group (ID)'),
         label=_('VLAN Group (ID)'),
     )
     )
     vlan_group = django_filters.ModelMultipleChoiceFilter(
     vlan_group = django_filters.ModelMultipleChoiceFilter(
         field_name='vlan__group__slug',
         field_name='vlan__group__slug',
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
+        distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('VLAN Group (slug)'),
         label=_('VLAN Group (slug)'),
     )
     )
     vlan_id = django_filters.ModelMultipleChoiceFilter(
     vlan_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
+        distinct=False,
         label=_('VLAN (ID)'),
         label=_('VLAN (ID)'),
     )
     )
     vlan_vid = django_filters.NumberFilter(
     vlan_vid = django_filters.NumberFilter(
@@ -383,16 +395,19 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
     )
     )
     role_id = django_filters.ModelMultipleChoiceFilter(
     role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
+        distinct=False,
         label=_('Role (ID)'),
         label=_('Role (ID)'),
     )
     )
     role = django_filters.ModelMultipleChoiceFilter(
     role = django_filters.ModelMultipleChoiceFilter(
         field_name='role__slug',
         field_name='role__slug',
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
+        distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('Role (slug)'),
         label=_('Role (slug)'),
     )
     )
     status = django_filters.MultipleChoiceFilter(
     status = django_filters.MultipleChoiceFilter(
         choices=PrefixStatusChoices,
         choices=PrefixStatusChoices,
+        distinct=False,
         null_value=None
         null_value=None
     )
     )
 
 
@@ -486,26 +501,31 @@ class IPRangeFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt
     )
     )
     vrf_id = django_filters.ModelMultipleChoiceFilter(
     vrf_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
+        distinct=False,
         label=_('VRF'),
         label=_('VRF'),
     )
     )
     vrf = django_filters.ModelMultipleChoiceFilter(
     vrf = django_filters.ModelMultipleChoiceFilter(
         field_name='vrf__rd',
         field_name='vrf__rd',
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
+        distinct=False,
         to_field_name='rd',
         to_field_name='rd',
         label=_('VRF (RD)'),
         label=_('VRF (RD)'),
     )
     )
     role_id = django_filters.ModelMultipleChoiceFilter(
     role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
+        distinct=False,
         label=_('Role (ID)'),
         label=_('Role (ID)'),
     )
     )
     role = django_filters.ModelMultipleChoiceFilter(
     role = django_filters.ModelMultipleChoiceFilter(
         field_name='role__slug',
         field_name='role__slug',
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
+        distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('Role (slug)'),
         label=_('Role (slug)'),
     )
     )
     status = django_filters.MultipleChoiceFilter(
     status = django_filters.MultipleChoiceFilter(
         choices=IPRangeStatusChoices,
         choices=IPRangeStatusChoices,
+        distinct=False,
         null_value=None
         null_value=None
     )
     )
     parent = MultiValueCharFilter(
     parent = MultiValueCharFilter(
@@ -588,11 +608,13 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
     )
     )
     vrf_id = django_filters.ModelMultipleChoiceFilter(
     vrf_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
+        distinct=False,
         label=_('VRF'),
         label=_('VRF'),
     )
     )
     vrf = django_filters.ModelMultipleChoiceFilter(
     vrf = django_filters.ModelMultipleChoiceFilter(
         field_name='vrf__rd',
         field_name='vrf__rd',
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
+        distinct=False,
         to_field_name='rd',
         to_field_name='rd',
         label=_('VRF (RD)'),
         label=_('VRF (RD)'),
     )
     )
@@ -607,7 +629,7 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
         to_field_name='rd',
         to_field_name='rd',
         label=_('VRF (RD)'),
         label=_('VRF (RD)'),
     )
     )
-    assigned_object_type = ContentTypeFilter()
+    assigned_object_type = MultiValueContentTypeFilter()
     device = MultiValueCharFilter(
     device = MultiValueCharFilter(
         method='filter_device',
         method='filter_device',
         field_name='name',
         field_name='name',
@@ -665,10 +687,12 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
     )
     )
     status = django_filters.MultipleChoiceFilter(
     status = django_filters.MultipleChoiceFilter(
         choices=IPAddressStatusChoices,
         choices=IPAddressStatusChoices,
+        distinct=False,
         null_value=None
         null_value=None
     )
     )
     role = django_filters.MultipleChoiceFilter(
     role = django_filters.MultipleChoiceFilter(
-        choices=IPAddressRoleChoices
+        choices=IPAddressRoleChoices,
+        distinct=False,
     )
     )
     service_id = django_filters.ModelMultipleChoiceFilter(
     service_id = django_filters.ModelMultipleChoiceFilter(
         field_name='services',
         field_name='services',
@@ -678,6 +702,7 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
     nat_inside_id = django_filters.ModelMultipleChoiceFilter(
     nat_inside_id = django_filters.ModelMultipleChoiceFilter(
         field_name='nat_inside',
         field_name='nat_inside',
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
+        distinct=False,
         label=_('NAT inside IP address (ID)'),
         label=_('NAT inside IP address (ID)'),
     )
     )
 
 
@@ -799,10 +824,12 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
 @register_filterset
 @register_filterset
 class FHRPGroupFilterSet(PrimaryModelFilterSet):
 class FHRPGroupFilterSet(PrimaryModelFilterSet):
     protocol = django_filters.MultipleChoiceFilter(
     protocol = django_filters.MultipleChoiceFilter(
-        choices=FHRPGroupProtocolChoices
+        choices=FHRPGroupProtocolChoices,
+        distinct=False,
     )
     )
     auth_type = django_filters.MultipleChoiceFilter(
     auth_type = django_filters.MultipleChoiceFilter(
-        choices=FHRPGroupAuthTypeChoices
+        choices=FHRPGroupAuthTypeChoices,
+        distinct=False,
     )
     )
     related_ip = django_filters.ModelMultipleChoiceFilter(
     related_ip = django_filters.ModelMultipleChoiceFilter(
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
@@ -846,9 +873,10 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet):
 
 
 @register_filterset
 @register_filterset
 class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
 class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
-    interface_type = ContentTypeFilter()
+    interface_type = MultiValueContentTypeFilter()
     group_id = django_filters.ModelMultipleChoiceFilter(
     group_id = django_filters.ModelMultipleChoiceFilter(
         queryset=FHRPGroup.objects.all(),
         queryset=FHRPGroup.objects.all(),
+        distinct=False,
         label=_('Group (ID)'),
         label=_('Group (ID)'),
     )
     )
     device = MultiValueCharFilter(
     device = MultiValueCharFilter(
@@ -901,7 +929,7 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
 
 
 @register_filterset
 @register_filterset
 class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
 class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
-    scope_type = ContentTypeFilter()
+    scope_type = MultiValueContentTypeFilter()
     region = django_filters.NumberFilter(
     region = django_filters.NumberFilter(
         method='filter_scope'
         method='filter_scope'
     )
     )
@@ -979,36 +1007,43 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     )
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
+        distinct=False,
         label=_('Site (ID)'),
         label=_('Site (ID)'),
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
         field_name='site__slug',
         field_name='site__slug',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
+        distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('Site (slug)'),
         label=_('Site (slug)'),
     )
     )
     group_id = django_filters.ModelMultipleChoiceFilter(
     group_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
+        distinct=False,
         label=_('Group (ID)'),
         label=_('Group (ID)'),
     )
     )
     group = django_filters.ModelMultipleChoiceFilter(
     group = django_filters.ModelMultipleChoiceFilter(
         field_name='group__slug',
         field_name='group__slug',
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
+        distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('Group'),
         label=_('Group'),
     )
     )
     role_id = django_filters.ModelMultipleChoiceFilter(
     role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
+        distinct=False,
         label=_('Role (ID)'),
         label=_('Role (ID)'),
     )
     )
     role = django_filters.ModelMultipleChoiceFilter(
     role = django_filters.ModelMultipleChoiceFilter(
         field_name='role__slug',
         field_name='role__slug',
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
+        distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('Role (slug)'),
         label=_('Role (slug)'),
     )
     )
     status = django_filters.MultipleChoiceFilter(
     status = django_filters.MultipleChoiceFilter(
         choices=VLANStatusChoices,
         choices=VLANStatusChoices,
+        distinct=False,
         null_value=None
         null_value=None
     )
     )
     available_at_site = django_filters.ModelChoiceFilter(
     available_at_site = django_filters.ModelChoiceFilter(
@@ -1024,10 +1059,12 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         method='get_for_virtualmachine'
         method='get_for_virtualmachine'
     )
     )
     qinq_role = django_filters.MultipleChoiceFilter(
     qinq_role = django_filters.MultipleChoiceFilter(
-        choices=VLANQinQRoleChoices
+        choices=VLANQinQRoleChoices,
+        distinct=False,
     )
     )
     qinq_svlan_id = django_filters.ModelMultipleChoiceFilter(
     qinq_svlan_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
+        distinct=False,
         label=_('Q-in-Q SVLAN (ID)'),
         label=_('Q-in-Q SVLAN (ID)'),
     )
     )
     qinq_svlan_vid = MultiValueNumberFilter(
     qinq_svlan_vid = MultiValueNumberFilter(
@@ -1122,11 +1159,13 @@ class VLANTranslationPolicyFilterSet(PrimaryModelFilterSet):
 class VLANTranslationRuleFilterSet(NetBoxModelFilterSet):
 class VLANTranslationRuleFilterSet(NetBoxModelFilterSet):
     policy_id = django_filters.ModelMultipleChoiceFilter(
     policy_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VLANTranslationPolicy.objects.all(),
         queryset=VLANTranslationPolicy.objects.all(),
+        distinct=False,
         label=_('VLAN Translation Policy (ID)'),
         label=_('VLAN Translation Policy (ID)'),
     )
     )
     policy = django_filters.ModelMultipleChoiceFilter(
     policy = django_filters.ModelMultipleChoiceFilter(
         field_name='policy__name',
         field_name='policy__name',
         queryset=VLANTranslationPolicy.objects.all(),
         queryset=VLANTranslationPolicy.objects.all(),
+        distinct=False,
         to_field_name='name',
         to_field_name='name',
         label=_('VLAN Translation Policy (name)'),
         label=_('VLAN Translation Policy (name)'),
     )
     )
@@ -1173,7 +1212,7 @@ class ServiceTemplateFilterSet(PrimaryModelFilterSet):
 
 
 @register_filterset
 @register_filterset
 class ServiceFilterSet(ContactModelFilterSet, PrimaryModelFilterSet):
 class ServiceFilterSet(ContactModelFilterSet, PrimaryModelFilterSet):
-    parent_object_type = ContentTypeFilter()
+    parent_object_type = MultiValueContentTypeFilter()
     device = MultiValueCharFilter(
     device = MultiValueCharFilter(
         method='filter_device',
         method='filter_device',
         field_name='name',
         field_name='name',
@@ -1265,22 +1304,26 @@ class PrimaryIPFilterSet(django_filters.FilterSet):
     primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
     primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
         field_name='primary_ip4',
         field_name='primary_ip4',
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
+        distinct=False,
         label=_('Primary IPv4 (ID)'),
         label=_('Primary IPv4 (ID)'),
     )
     )
     primary_ip4 = django_filters.ModelMultipleChoiceFilter(
     primary_ip4 = django_filters.ModelMultipleChoiceFilter(
         field_name='primary_ip4__address',
         field_name='primary_ip4__address',
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
+        distinct=False,
         to_field_name='address',
         to_field_name='address',
         label=_('Primary IPv4 (address)'),
         label=_('Primary IPv4 (address)'),
     )
     )
     primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
     primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
         field_name='primary_ip6',
         field_name='primary_ip6',
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
+        distinct=False,
         label=_('Primary IPv6 (ID)'),
         label=_('Primary IPv6 (ID)'),
     )
     )
     primary_ip6 = django_filters.ModelMultipleChoiceFilter(
     primary_ip6 = django_filters.ModelMultipleChoiceFilter(
         field_name='primary_ip6__address',
         field_name='primary_ip6__address',
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
+        distinct=False,
         to_field_name='address',
         to_field_name='address',
         label=_('Primary IPv6 (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')
     VLANGroup = apps.get_model('ipam', 'VLANGroup')
     db_alias = schema_editor.connection.alias
     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.vid_ranges = [NumericRange(group.min_vid, group.max_vid, bounds='[]')]
         group._total_vlan_ids = group.max_vid - group.min_vid + 1
         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):
 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")
         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:
     class Meta:
         indexes = (
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
     def test_interface_type(self):
     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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
     def test_interface(self):
     def test_interface(self):
         interfaces = Interface.objects.all()[:2]
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_priority(self):
     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.core.exceptions import ObjectDoesNotExist
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
@@ -109,7 +110,7 @@ class ContentTypeField(RelatedField):
     def to_internal_value(self, data):
     def to_internal_value(self, data):
         try:
         try:
             app_label, model = data.split('.')
             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:
         except ObjectDoesNotExist:
             self.fail('does_not_exist', content_type=data)
             self.fail('does_not_exist', content_type=data)
         except (AttributeError, TypeError, ValueError):
         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():
             for k, v in attrs.items():
                 setattr(instance, k, v)
                 setattr(instance, k, v)
         instance._m2m_values = m2m_values
         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
         return data

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

@@ -170,6 +170,28 @@ class NetBoxModelViewSet(
 
 
     # Creates
     # 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):
     def perform_create(self, serializer):
         model = self.queryset.model
         model = self.queryset.model
         logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
         logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
@@ -186,9 +208,20 @@ class NetBoxModelViewSet(
     # Updates
     # Updates
 
 
     def update(self, request, *args, **kwargs):
     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):
     def perform_update(self, serializer):
         model = self.queryset.model
         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
             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):
     def perform_bulk_update(self, objects, update_data, partial):
+        updated_pks = []
         with transaction.atomic(using=router.db_for_write(self.queryset.model)):
         with transaction.atomic(using=router.db_for_write(self.queryset.model)):
-            data_list = []
             for obj in objects:
             for obj in objects:
                 data = update_data.get(obj.id)
                 data = update_data.get(obj.id)
                 if hasattr(obj, 'snapshot'):
                 if hasattr(obj, 'snapshot'):
@@ -122,9 +126,9 @@ class BulkUpdateModelMixin:
                 serializer = self.get_serializer(obj, data=data, partial=partial)
                 serializer = self.get_serializer(obj, data=data, partial=partial)
                 serializer.is_valid(raise_exception=True)
                 serializer.is_valid(raise_exception=True)
                 self.perform_update(serializer)
                 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):
     def bulk_partial_update(self, request, *args, **kwargs):
         kwargs['partial'] = True
         kwargs['partial'] = True

+ 6 - 11
netbox/netbox/filtersets.py

@@ -305,18 +305,13 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*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 = {}
         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
                 custom_field_filters[filter_name] = filter_instance
 
 
                 # Add relevant additional lookups
                 # 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):
     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):
     def _get_form_field(self, customfield):
         return customfield.to_form_field(for_csv_import=True)
         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 import forms
-from django.db.models import Q
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from extras.choices import *
 from extras.choices import *
@@ -35,10 +34,13 @@ class NetBoxModelFilterSetForm(FilterModifierMixin, CustomFieldsMixin, SavedFilt
     selector_fields = ('filter_id', 'q')
     selector_fields = ('filter_id', 'q')
 
 
     def _get_custom_fields(self, content_type):
     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):
     def _get_form_field(self, customfield):
         return customfield.to_form_field(
         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)
         return ObjectType.objects.get_for_model(self.model)
 
 
     def _get_custom_fields(self, content_type):
     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):
     def _get_form_field(self, customfield):
         return customfield.to_form_field()
         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
     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.
     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(
     parent = TreeForeignKey(
         to='self',
         to='self',

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

@@ -2,7 +2,7 @@ import json
 from collections import defaultdict
 from collections import defaultdict
 from functools import cached_property
 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.contrib.contenttypes.models import ContentType
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
@@ -121,9 +121,11 @@ class ChangeLoggingMixin(DeleteMixin, models.Model):
         if hasattr(self, '_prechange_snapshot'):
         if hasattr(self, '_prechange_snapshot'):
             objectchange.prechange_data = self._prechange_snapshot
             objectchange.prechange_data = self._prechange_snapshot
         if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE):
         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
         return objectchange
+    to_objectchange.alters_data = True
 
 
 
 
 class CloningMixin(models.Model):
 class CloningMixin(models.Model):
@@ -159,6 +161,13 @@ class CloningMixin(models.Model):
             elif field_value not in (None, ''):
             elif field_value not in (None, ''):
                 attrs[field_name] = field_value
                 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)
         # Include tags (if applicable)
         if is_taggable(self):
         if is_taggable(self):
             attrs['tags'] = [tag.pk for tag in self.tags.all()]
             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))
                 raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
 
 
     def save(self, *args, **kwargs):
     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
                 self.custom_field_data[cf.name] = cf.default
 
 
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)

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

@@ -187,7 +187,6 @@ class CachedValueSearchBackend(SearchBackend):
         return ret
         return ret
 
 
     def cache(self, instances, indexer=None, remove_existing=True):
     def cache(self, instances, indexer=None, remove_existing=True):
-        object_type = None
         custom_fields = None
         custom_fields = None
 
 
         # Convert a single instance to an iterable
         # Convert a single instance to an iterable
@@ -208,15 +207,18 @@ class CachedValueSearchBackend(SearchBackend):
                     except KeyError:
                     except KeyError:
                         break
                         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
             # Wipe out any previously cached values for the object
             if remove_existing:
             if remove_existing:
                 self.remove(instance)
                 self.remove(instance)
 
 
             # Generate cache data
             # Generate cache data
+            object_type = ObjectType.objects.get_for_model(indexer.model)
             for field in indexer.to_cache(instance, custom_fields=custom_fields):
             for field in indexer.to_cache(instance, custom_fields=custom_fields):
                 buffer.append(
                 buffer.append(
                     CachedValue(
                     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.core.validators import URLValidator
 from django.utils.module_loading import import_string
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
-from rest_framework.utils import field_mapping
 
 
 from core.exceptions import IncompatiblePluginError
 from core.exceptions import IncompatiblePluginError
 from netbox.config import PARAMS as CONFIG_PARAMS
 from netbox.config import PARAMS as CONFIG_PARAMS
@@ -25,15 +24,6 @@ from utilities.string import trailing_slash
 from .monkey import get_unique_validators
 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
 # Environment setup
 #
 #
@@ -399,6 +389,11 @@ if CACHING_REDIS_CA_CERT_PATH:
     CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
     CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
     CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS']['ssl_ca_certs'] = CACHING_REDIS_CA_CERT_PATH
     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
 # Sessions
@@ -764,7 +759,7 @@ SPECTACULAR_SETTINGS = {
     'COMPONENT_SPLIT_REQUEST': True,
     'COMPONENT_SPLIT_REQUEST': True,
     'REDOC_DIST': 'SIDECAR',
     'REDOC_DIST': 'SIDECAR',
     'SERVERS': [{
     'SERVERS': [{
-        'url': BASE_PATH,
+        'url': '',
         'description': 'NetBox',
         'description': 'NetBox',
     }],
     }],
     'SWAGGER_UI_DIST': 'SIDECAR',
     'SWAGGER_UI_DIST': 'SIDECAR',
@@ -808,6 +803,11 @@ if TASKS_REDIS_CA_CERT_PATH:
     RQ_PARAMS.setdefault('REDIS_CLIENT_KWARGS', {})
     RQ_PARAMS.setdefault('REDIS_CLIENT_KWARGS', {})
     RQ_PARAMS['REDIS_CLIENT_KWARGS']['ssl_ca_certs'] = TASKS_REDIS_CA_CERT_PATH
     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
 # Define named RQ queues
 RQ_QUEUES = {
 RQ_QUEUES = {
     RQ_QUEUE_HIGH: RQ_PARAMS,
     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")
             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.
 # UNSUPPORTED FUNCTIONALITY: Import any local overrides.
 try:
 try:
     from .local_settings import *
     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()
                 (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([
         extra_columns.extend([
             (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
             (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)
         custom_links = CustomLink.objects.filter(object_types=object_type, enabled=True)
         extra_columns.extend([
         extra_columns.extend([
             (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links
             (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 django.urls import reverse
 from rest_framework import status
 from rest_framework import status
 
 
-from core.models import ObjectType
 from dcim.choices import LocationStatusChoices
 from dcim.choices import LocationStatusChoices
 from dcim.models import Site, Location
 from dcim.models import Site, Location
-from users.models import ObjectPermission
 from utilities.testing import disable_warnings, APITestCase, TestCase
 from utilities.testing import disable_warnings, APITestCase, TestCase
 
 
 
 
@@ -45,17 +43,28 @@ class GraphQLTestCase(TestCase):
 
 
 class GraphQLAPITestCase(APITestCase):
 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 = (
         sites = (
             Site(name='Site 1', slug='site-1'),
             Site(name='Site 1', slug='site-1'),
             Site(name='Site 2', slug='site-2'),
             Site(name='Site 2', slug='site-2'),
             Site(name='Site 3', slug='site-3'),
             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)
         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(
         Location.objects.create(
             site=sites[0],
             site=sites[0],
             name='Location 1',
             name='Location 1',
@@ -75,18 +84,6 @@ class GraphQLAPITestCase(APITestCase):
             status=LocationStatusChoices.STATUS_ACTIVE
             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
         # A valid request should return the filtered list
         query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {id site {id}}}'
         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)
         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)
         self.assertEqual(len(data['data']['location_list']), 0)
 
 
         # Removing the permissions from location should result in an empty locations list
         # 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}}}'
         query = '{site(id: ' + str(sites[0].pk) + ') {id locations {id}}}'
         response = self.client.post(url, data={'query': query}, format="json", **self.header)
         response = self.client.post(url, data={'query': query}, format="json", **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         data = json.loads(response.content)
         data = json.loads(response.content)
         self.assertNotIn('errors', data)
         self.assertNotIn('errors', data)
         self.assertEqual(len(data['data']['site']['locations']), 0)
         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 django.test import TestCase
 
 
 from core.models import AutoSyncRecord, DataSource
 from core.models import AutoSyncRecord, DataSource
+from dcim.models import Site
 from extras.models import CustomLink
 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.models.features import get_model_features, has_feature, model_is_public
-from netbox.tests.dummy_plugin.models import DummyModel
 from taggit.models import Tag
 from taggit.models import Tag
 
 
 
 
 class ModelFeaturesTestCase(TestCase):
 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):
     def test_model_is_public(self):
         """
         """
         Test that the is_public() utility function returns True for public models only.
         Test that the is_public() utility function returns True for public models only.
         """
         """
+        from netbox.tests.dummy_plugin.models import DummyModel
+
         # Public model
         # Public model
         self.assertFalse(hasattr(DataSource, '_netbox_private'))
         self.assertFalse(hasattr(DataSource, '_netbox_private'))
         self.assertTrue(model_is_public(DataSource))
         self.assertTrue(model_is_public(DataSource))
@@ -51,3 +61,53 @@ class ModelFeaturesTestCase(TestCase):
         features = get_model_features(CustomLink)
         features = get_model_features(CustomLink)
         self.assertIn('cloning', features)
         self.assertIn('cloning', features)
         self.assertNotIn('bookmarks', 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):
     def get_value(self, obj):
         value = resolve_attr_path(obj, self.accessor)
         value = resolve_attr_path(obj, self.accessor)
         # Apply format string (if any)
         # 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 self.format_string.format(value)
         return 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 import messages
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
 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.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
 from django.db import IntegrityError, router, transaction
 from django.db import IntegrityError, router, transaction
 from django.db.models import ManyToManyField, ProtectedError, RestrictedError
 from django.db.models import ManyToManyField, ProtectedError, RestrictedError
@@ -484,12 +483,11 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
             else:
             else:
                 instance = self.queryset.model()
                 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}'
                     field_name = f'cf_{cf.name}'
                     if field_name not in record:
                     if field_name not in record:
                         record[field_name] = cf.default
                         record[field_name] = cf.default

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/dist/netbox.css


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/dist/netbox.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/dist/netbox.js.map


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

@@ -31,29 +31,29 @@
     "gridstack": "12.4.2",
     "gridstack": "12.4.2",
     "htmx.org": "2.0.8",
     "htmx.org": "2.0.8",
     "query-string": "9.3.1",
     "query-string": "9.3.1",
-    "sass": "1.97.2",
+    "sass": "1.97.3",
     "tom-select": "2.4.3",
     "tom-select": "2.4.3",
     "typeface-inter": "3.18.1",
     "typeface-inter": "3.18.1",
     "typeface-roboto-mono": "1.1.13"
     "typeface-roboto-mono": "1.1.13"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@eslint/compat": "^2.0.1",
+    "@eslint/compat": "^2.0.2",
     "@eslint/eslintrc": "^3.3.3",
     "@eslint/eslintrc": "^3.3.3",
     "@eslint/js": "^9.39.2",
     "@eslint/js": "^9.39.2",
     "@types/bootstrap": "5.2.10",
     "@types/bootstrap": "5.2.10",
     "@types/cookie": "^1.0.0",
     "@types/cookie": "^1.0.0",
     "@types/node": "^24.10.1",
     "@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",
     "esbuild-sass-plugin": "^3.6.0",
     "eslint": "^9.39.2",
     "eslint": "^9.39.2",
     "eslint-config-prettier": "^10.1.8",
     "eslint-config-prettier": "^10.1.8",
     "eslint-import-resolver-typescript": "^4.4.4",
     "eslint-import-resolver-typescript": "^4.4.4",
     "eslint-plugin-import": "^2.32.0",
     "eslint-plugin-import": "^2.32.0",
     "eslint-plugin-prettier": "^5.5.5",
     "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"
     "typescript": "^5.9.3"
   },
   },
   "resolutions": {
   "resolutions": {

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

@@ -150,20 +150,22 @@ function initSidebarAccordions(): void {
  */
  */
 function initImagePreview(): void {
 function initImagePreview(): void {
   for (const element of getElements<HTMLAnchorElement>('a.image-preview')) {
   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.
     // Create a container for the image.
     const content = createElement('div', null, null, [image]);
     const content = createElement('div', null, null, [image]);
 
 
     // Initialize the Bootstrap Popper instance.
     // Initialize the Bootstrap Popper instance.
     new Popover(element, {
     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',
       customClass: 'image-preview-popover',
       trigger: 'hover',
       trigger: 'hover',
       html: true,
       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] {
 body[data-bs-theme=dark] {
   // Assuming icon is black/white line art, invert it and tone down brightness
   // Assuming icon is black/white line art, invert it and tone down brightness
   img.plugin-icon {
   img.plugin-icon {

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

@@ -5,6 +5,16 @@
   font-variant-ligatures: none;
   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
 // Restore default foreground & background colors for <pre> blocks
 pre {
 pre {
   background-color: transparent;
   background-color: transparent;

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

@@ -24,135 +24,135 @@
   dependencies:
   dependencies:
     tslib "^2.4.0"
     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":
 "@eslint-community/eslint-utils@^4.8.0":
   version "4.9.0"
   version "4.9.0"
@@ -173,12 +173,12 @@
   resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
   resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
   integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
   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:
   dependencies:
-    "@eslint/core" "^1.0.1"
+    "@eslint/core" "^1.1.0"
 
 
 "@eslint/config-array@^0.21.1":
 "@eslint/config-array@^0.21.1":
   version "0.21.1"
   version "0.21.1"
@@ -203,10 +203,10 @@
   dependencies:
   dependencies:
     "@types/json-schema" "^7.0.15"
     "@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:
   dependencies:
     "@types/json-schema" "^7.0.15"
     "@types/json-schema" "^7.0.15"
 
 
@@ -935,101 +935,101 @@
   dependencies:
   dependencies:
     "@types/estree" "*"
     "@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:
   dependencies:
     "@eslint-community/regexpp" "^4.12.2"
     "@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"
     ignore "^7.0.5"
     natural-compare "^1.4.0"
     natural-compare "^1.4.0"
     ts-api-utils "^2.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:
   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"
     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:
   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"
     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:
   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:
   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"
     debug "^4.4.3"
     ts-api-utils "^2.4.0"
     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:
   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"
     debug "^4.4.3"
     minimatch "^9.0.5"
     minimatch "^9.0.5"
     semver "^7.7.3"
     semver "^7.7.3"
     tinyglobby "^0.2.15"
     tinyglobby "^0.2.15"
     ts-api-utils "^2.4.0"
     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:
   dependencies:
     "@eslint-community/eslint-utils" "^4.9.1"
     "@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:
   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":
 "@unrs/resolver-binding-android-arm-eabi@1.11.1":
   version "1.11.1"
   version "1.11.1"
@@ -1772,37 +1772,37 @@ esbuild-sass-plugin@^3.6.0:
     resolve "^1.22.11"
     resolve "^1.22.11"
     sass "^1.97.2"
     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:
   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:
 escape-string-regexp@^4.0.0:
   version "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"
   resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz"
   integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
   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:
 eslint@^9.39.2:
   version "9.39.2"
   version "9.39.2"
   resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.2.tgz#cb60e6d16ab234c0f8369a3fe7cc87967faf4b6c"
   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"
   resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"
   integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
   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:
 globalthis@^1.0.3, globalthis@^1.0.4:
   version "1.0.4"
   version "1.0.4"
@@ -2985,10 +2990,10 @@ prettier-linter-helpers@^1.0.1:
   dependencies:
   dependencies:
     fast-diff "^1.1.2"
     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:
 punycode.js@^2.3.1:
   version "2.3.1"
   version "2.3.1"
@@ -3172,7 +3177,18 @@ safe-regex-test@^1.1.0:
     es-errors "^1.3.0"
     es-errors "^1.3.0"
     is-regex "^1.2.1"
     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"
   version "1.97.2"
   resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.2.tgz#e515a319092fd2c3b015228e3094b40198bff0da"
   resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.2.tgz#e515a319092fd2c3b015228e3094b40198bff0da"
   integrity sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==
   integrity sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==
@@ -3441,7 +3457,7 @@ toggle-selection@^1.0.6:
 
 
 tom-select@2.4.3:
 tom-select@2.4.3:
   version "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==
   integrity sha512-MFFrMxP1bpnAMPbdvPCZk0KwYxLqhYZso39torcdoefeV/NThNyDu8dV96/INJ5XQVTL3O55+GqQ78Pkj5oCfw==
   dependencies:
   dependencies:
     "@orchidjs/sifter" "^1.1.0"
     "@orchidjs/sifter" "^1.1.0"

+ 2 - 2
netbox/release.yaml

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

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

@@ -53,7 +53,7 @@ Blocks:
           {% nav %}
           {% nav %}
 
 
           {# Release info #}
           {# 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 }}
             {{ settings.RELEASE.name }}
             {% if not settings.RELEASE.features.commercial and not settings.ISOLATED_DEPLOYMENT %}
             {% if not settings.RELEASE.features.commercial and not settings.ISOLATED_DEPLOYMENT %}
               <div>
               <div>

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

@@ -33,7 +33,7 @@
     <div class="col col-md-12">
     <div class="col col-md-12">
       <div class="card">
       <div class="card">
         <h2 class="card-header">{% trans "Configuration Data" %}</h2>
         <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>
 
 
       <div class="card">
       <div class="card">

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

@@ -95,7 +95,7 @@
   <tr>
   <tr>
     <th scope="row" class="ps-3">{% trans "Custom validators" %}</th>
     <th scope="row" class="ps-3">{% trans "Custom validators" %}</th>
     {% if config.CUSTOM_VALIDATORS %}
     {% if config.CUSTOM_VALIDATORS %}
-      <td><pre>{{ config.CUSTOM_VALIDATORS }}</pre></td>
+      <td><pre class="p-0">{{ config.CUSTOM_VALIDATORS }}</pre></td>
     {% else %}
     {% else %}
       <td>{{ ''|placeholder }}</td>
       <td>{{ ''|placeholder }}</td>
     {% endif %}
     {% endif %}
@@ -103,7 +103,7 @@
   <tr>
   <tr>
     <th scope="row" class="border-0 ps-3">{% trans "Protection rules" %}</th>
     <th scope="row" class="border-0 ps-3">{% trans "Protection rules" %}</th>
     {% if config.PROTECTION_RULES %}
     {% 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 %}
     {% else %}
       <td class="border-0">{{ ''|placeholder }}</td>
       <td class="border-0">{{ ''|placeholder }}</td>
     {% endif %}
     {% endif %}
@@ -116,7 +116,7 @@
   <tr>
   <tr>
     <th scope="row" class="border-0 ps-3">{% trans "Default preferences" %}</th>
     <th scope="row" class="border-0 ps-3">{% trans "Default preferences" %}</th>
     {% if config.DEFAULT_USER_PREFERENCES %}
     {% 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 %}
     {% else %}
       <td class="border-0">{{ ''|placeholder }}</td>
       <td class="border-0">{{ ''|placeholder }}</td>
     {% endif %}
     {% 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' %}
 {% 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>
         <tr>
           <th scope="row">{% trans "MAC Address" %}</th>
           <th scope="row">{% trans "MAC Address" %}</th>
           <td>
           <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>
               <span class="badge text-bg-primary">{% trans "Primary" %}</span>
             {% else %}
             {% else %}
               {{ ''|placeholder }}
               {{ ''|placeholder }}

+ 11 - 2
netbox/tenancy/filtersets.py

@@ -5,7 +5,7 @@ from django.utils.translation import gettext as _
 from netbox.filtersets import (
 from netbox.filtersets import (
     NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
     NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
 )
 )
-from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
+from utilities.filters import MultiValueContentTypeFilter, TreeNodeMultipleChoiceFilter
 from utilities.filtersets import register_filterset
 from utilities.filtersets import register_filterset
 from .models import *
 from .models import *
 
 
@@ -29,11 +29,13 @@ __all__ = (
 class ContactGroupFilterSet(NestedGroupModelFilterSet):
 class ContactGroupFilterSet(NestedGroupModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
+        distinct=False,
         label=_('Parent contact group (ID)'),
         label=_('Parent contact group (ID)'),
     )
     )
     parent = django_filters.ModelMultipleChoiceFilter(
     parent = django_filters.ModelMultipleChoiceFilter(
         field_name='parent__slug',
         field_name='parent__slug',
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
+        distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('Parent contact group (slug)'),
         label=_('Parent contact group (slug)'),
     )
     )
@@ -110,9 +112,10 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
     )
     )
-    object_type = ContentTypeFilter()
+    object_type = MultiValueContentTypeFilter()
     contact_id = django_filters.ModelMultipleChoiceFilter(
     contact_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Contact.objects.all(),
         queryset=Contact.objects.all(),
+        distinct=False,
         label=_('Contact (ID)'),
         label=_('Contact (ID)'),
     )
     )
     group_id = TreeNodeMultipleChoiceFilter(
     group_id = TreeNodeMultipleChoiceFilter(
@@ -130,11 +133,13 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
     )
     )
     role_id = django_filters.ModelMultipleChoiceFilter(
     role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ContactRole.objects.all(),
         queryset=ContactRole.objects.all(),
+        distinct=False,
         label=_('Contact role (ID)'),
         label=_('Contact role (ID)'),
     )
     )
     role = django_filters.ModelMultipleChoiceFilter(
     role = django_filters.ModelMultipleChoiceFilter(
         field_name='role__slug',
         field_name='role__slug',
         queryset=ContactRole.objects.all(),
         queryset=ContactRole.objects.all(),
+        distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('Contact role (slug)'),
         label=_('Contact role (slug)'),
     )
     )
@@ -179,11 +184,13 @@ class ContactModelFilterSet(django_filters.FilterSet):
 class TenantGroupFilterSet(NestedGroupModelFilterSet):
 class TenantGroupFilterSet(NestedGroupModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
+        distinct=False,
         label=_('Parent tenant group (ID)'),
         label=_('Parent tenant group (ID)'),
     )
     )
     parent = django_filters.ModelMultipleChoiceFilter(
     parent = django_filters.ModelMultipleChoiceFilter(
         field_name='parent__slug',
         field_name='parent__slug',
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
+        distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('Parent tenant group (slug)'),
         label=_('Parent tenant group (slug)'),
     )
     )
@@ -256,10 +263,12 @@ class TenancyFilterSet(django_filters.FilterSet):
     )
     )
     tenant_id = django_filters.ModelMultipleChoiceFilter(
     tenant_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
+        distinct=False,
         label=_('Tenant (ID)'),
         label=_('Tenant (ID)'),
     )
     )
     tenant = django_filters.ModelMultipleChoiceFilter(
     tenant = django_filters.ModelMultipleChoiceFilter(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
+        distinct=False,
         field_name='tenant__slug',
         field_name='tenant__slug',
         to_field_name='slug',
         to_field_name='slug',
         label=_('Tenant (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:
     class Meta:
         ordering = ['name']
         ordering = ['name']
+        # Empty tuple triggers Django migration detection for MPTT indexes
+        # (see #21016, django-mptt/django-mptt#682)
+        indexes = ()
         constraints = (
         constraints = (
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('parent', 'name'),
                 fields=('parent', 'name'),

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

@@ -29,6 +29,9 @@ class TenantGroup(NestedGroupModel):
 
 
     class Meta:
     class Meta:
         ordering = ['name']
         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 = _('tenant group')
         verbose_name_plural = _('tenant groups')
         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)
         ContactAssignment.objects.bulk_create(assignments)
 
 
     def test_object_type(self):
     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')}
         params = {'object_type_id': ObjectType.objects.get_by_natural_key('dcim', 'site')}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 

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


Разлика између датотеке није приказан због своје велике величине
+ 294 - 297
netbox/translations/cs/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 292 - 297
netbox/translations/da/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 294 - 299
netbox/translations/de/LC_MESSAGES/django.po


Разлика између датотеке није приказан због своје велике величине
+ 247 - 250
netbox/translations/en/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 297 - 300
netbox/translations/es/LC_MESSAGES/django.po


Неке датотеке нису приказане због велике количине промена