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

Merge branch 'main' into feature

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Jeremy Stretch 5 дней назад
Родитель
Сommit
f12fe46486
62 измененных файлов с 4116 добавлено и 3115 удалено
  1. 92 168
      contrib/openapi.json
  2. 27 0
      docs/release-notes/version-4.6.md
  3. 13 11
      netbox/dcim/forms/bulk_import.py
  4. 32 0
      netbox/dcim/tests/test_views.py
  5. 34 0
      netbox/extras/api/routers.py
  6. 2 3
      netbox/extras/api/urls.py
  7. 60 30
      netbox/extras/api/views.py
  8. 26 2
      netbox/extras/models/scripts.py
  9. 223 23
      netbox/extras/tests/test_api.py
  10. 52 0
      netbox/extras/tests/test_api_routers.py
  11. 306 0
      netbox/extras/tests/test_scripts_deletion.py
  12. 8 1
      netbox/ipam/filtersets.py
  13. 8 0
      netbox/netbox/api/viewsets/__init__.py
  14. 7 0
      netbox/netbox/constants.py
  15. 14 11
      netbox/netbox/context_managers.py
  16. 26 0
      netbox/netbox/forms/bulk_import.py
  17. 66 0
      netbox/netbox/models/deletion.py
  18. 43 2
      netbox/netbox/models/features.py
  19. 0 4
      netbox/netbox/settings.py
  20. 87 0
      netbox/netbox/tests/test_context_managers.py
  21. 103 6
      netbox/netbox/tests/test_forms.py
  22. 16 5
      netbox/netbox/views/generic/object_views.py
  23. 0 0
      netbox/project-static/dist/netbox.js
  24. 0 0
      netbox/project-static/dist/netbox.js.map
  25. 7 7
      netbox/project-static/package.json
  26. 243 243
      netbox/project-static/yarn.lock
  27. 1 2
      netbox/release.yaml
  28. 30 23
      netbox/templates/htmx/delete_form.html
  29. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  30. 156 156
      netbox/translations/cs/LC_MESSAGES/django.po
  31. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  32. 156 156
      netbox/translations/da/LC_MESSAGES/django.po
  33. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  34. 156 156
      netbox/translations/de/LC_MESSAGES/django.po
  35. 61 45
      netbox/translations/en/LC_MESSAGES/django.po
  36. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  37. 156 156
      netbox/translations/es/LC_MESSAGES/django.po
  38. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  39. 156 156
      netbox/translations/fr/LC_MESSAGES/django.po
  40. BIN
      netbox/translations/it/LC_MESSAGES/django.mo
  41. 156 156
      netbox/translations/it/LC_MESSAGES/django.po
  42. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  43. 161 161
      netbox/translations/ja/LC_MESSAGES/django.po
  44. BIN
      netbox/translations/ko/LC_MESSAGES/django.mo
  45. 161 161
      netbox/translations/ko/LC_MESSAGES/django.po
  46. BIN
      netbox/translations/lv/LC_MESSAGES/django.mo
  47. 158 158
      netbox/translations/lv/LC_MESSAGES/django.po
  48. BIN
      netbox/translations/nl/LC_MESSAGES/django.mo
  49. 156 156
      netbox/translations/nl/LC_MESSAGES/django.po
  50. BIN
      netbox/translations/pl/LC_MESSAGES/django.mo
  51. 156 156
      netbox/translations/pl/LC_MESSAGES/django.po
  52. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  53. 156 156
      netbox/translations/pt/LC_MESSAGES/django.po
  54. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  55. 154 154
      netbox/translations/ru/LC_MESSAGES/django.po
  56. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  57. 156 156
      netbox/translations/tr/LC_MESSAGES/django.po
  58. BIN
      netbox/translations/uk/LC_MESSAGES/django.mo
  59. 158 158
      netbox/translations/uk/LC_MESSAGES/django.po
  60. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  61. 169 169
      netbox/translations/zh/LC_MESSAGES/django.po
  62. 8 8
      requirements.txt

+ 92 - 168
contrib/openapi.json

@@ -2,7 +2,7 @@
     "openapi": "3.0.3",
     "info": {
         "title": "NetBox REST API",
-        "version": "4.6.7",
+        "version": "4.6.8",
         "license": {
             "name": "Apache v2 License"
         }
@@ -150938,33 +150938,6 @@
                         "description": ""
                     }
                 }
-            },
-            "post": {
-                "operationId": "extras_scripts_create",
-                "description": "Post a list of script objects.",
-                "tags": [
-                    "extras"
-                ],
-                "security": [
-                    {
-                        "cookieAuth": []
-                    },
-                    {
-                        "tokenAuth": []
-                    }
-                ],
-                "responses": {
-                    "201": {
-                        "content": {
-                            "application/json": {
-                                "schema": {
-                                    "$ref": "#/components/schemas/Script"
-                                }
-                            }
-                        },
-                        "description": ""
-                    }
-                }
             }
         },
         "/api/extras/scripts/{id}/": {
@@ -151030,9 +151003,9 @@
                     }
                 }
             },
-            "put": {
-                "operationId": "extras_scripts_update",
-                "description": "Put a script object.",
+            "post": {
+                "operationId": "extras_scripts_run",
+                "description": "Run a Script identified by its numeric PK or module & name and return the pending Job as the result",
                 "parameters": [
                     {
                         "in": "path",
@@ -151075,94 +151048,11 @@
                         "content": {
                             "application/json": {
                                 "schema": {
-                                    "$ref": "#/components/schemas/Script"
+                                    "$ref": "#/components/schemas/ScriptDetail"
                                 }
                             }
                         },
-                        "description": ""
-                    }
-                }
-            },
-            "patch": {
-                "operationId": "extras_scripts_partial_update",
-                "description": "Patch a script object.",
-                "parameters": [
-                    {
-                        "in": "path",
-                        "name": "id",
-                        "schema": {
-                            "type": "string",
-                            "pattern": "^[^/]+$"
-                        },
-                        "required": true
-                    }
-                ],
-                "tags": [
-                    "extras"
-                ],
-                "requestBody": {
-                    "content": {
-                        "application/json": {
-                            "schema": {
-                                "$ref": "#/components/schemas/PatchedScriptInputRequest"
-                            }
-                        },
-                        "multipart/form-data": {
-                            "schema": {
-                                "$ref": "#/components/schemas/PatchedScriptInputRequest"
-                            }
-                        }
-                    }
-                },
-                "security": [
-                    {
-                        "cookieAuth": []
-                    },
-                    {
-                        "tokenAuth": []
-                    }
-                ],
-                "responses": {
-                    "200": {
-                        "content": {
-                            "application/json": {
-                                "schema": {
-                                    "$ref": "#/components/schemas/Script"
-                                }
-                            }
-                        },
-                        "description": ""
-                    }
-                }
-            },
-            "delete": {
-                "operationId": "extras_scripts_destroy",
-                "description": "Delete a script object.",
-                "parameters": [
-                    {
-                        "in": "path",
-                        "name": "id",
-                        "schema": {
-                            "type": "string",
-                            "pattern": "^[^/]+$"
-                        },
-                        "required": true
-                    }
-                ],
-                "tags": [
-                    "extras"
-                ],
-                "security": [
-                    {
-                        "cookieAuth": []
-                    },
-                    {
-                        "tokenAuth": []
-                    }
-                ],
-                "responses": {
-                    "204": {
-                        "description": "No response body"
+                        "description": "The script has been enqueued for execution."
                     }
                 }
             }
@@ -244416,16 +244306,6 @@
                     "user"
                 ]
             },
-            "BriefJobRequest": {
-                "type": "object",
-                "properties": {
-                    "completed": {
-                        "type": "string",
-                        "format": "date-time",
-                        "nullable": true
-                    }
-                }
-            },
             "BriefL2VPN": {
                 "type": "object",
                 "description": "Base serializer class for models inheriting from PrimaryModel.",
@@ -249575,11 +249455,13 @@
                         "enum": [
                             "IATA",
                             "ISO_3166",
-                            "UN_LOCODE"
+                            "UN_LOCODE",
+                            null
                         ],
                         "type": "string",
                         "description": "* `IATA` - IATA (Airport codes)\n* `ISO_3166` - ISO 3166 (Country codes)\n* `UN_LOCODE` - UN/LOCODE (Location codes)",
-                        "x-spec-enum-id": "cf0efb5195f85007"
+                        "x-spec-enum-id": "cf0efb5195f85007",
+                        "nullable": true
                     },
                     "extra_choices": {
                         "type": "array",
@@ -260331,7 +260213,7 @@
                         "type": "string",
                         "minLength": 1,
                         "title": "URL",
-                        "description": "This URL will be called using the HTTP method defined when the webhook is called. Jinja2 template processing is supported with the same context as the request body.",
+                        "description": "This URL will be called using the HTTP method defined when the webhook is called. Must be http:// or https://. Jinja2 template processing is supported (with the same context as the request body) for part or all of the URL.",
                         "maxLength": 500
                     },
                     "http_method": {
@@ -266122,7 +266004,8 @@
                                 "enum": [
                                     "IATA",
                                     "ISO_3166",
-                                    "UN_LOCODE"
+                                    "UN_LOCODE",
+                                    null
                                 ],
                                 "type": "string",
                                 "description": "* `IATA` - IATA (Airport codes)\n* `ISO_3166` - ISO 3166 (Country codes)\n* `UN_LOCODE` - UN/LOCODE (Location codes)",
@@ -266136,7 +266019,8 @@
                                     "UN/LOCODE (Location codes)"
                                 ]
                             }
-                        }
+                        },
+                        "nullable": true
                     },
                     "extra_choices": {
                         "type": "array",
@@ -266228,11 +266112,13 @@
                         "enum": [
                             "IATA",
                             "ISO_3166",
-                            "UN_LOCODE"
+                            "UN_LOCODE",
+                            null
                         ],
                         "type": "string",
                         "description": "* `IATA` - IATA (Airport codes)\n* `ISO_3166` - ISO 3166 (Country codes)\n* `UN_LOCODE` - UN/LOCODE (Location codes)",
-                        "x-spec-enum-id": "cf0efb5195f85007"
+                        "x-spec-enum-id": "cf0efb5195f85007",
+                        "nullable": true
                     },
                     "extra_choices": {
                         "type": "array",
@@ -287323,11 +287209,13 @@
                         "enum": [
                             "IATA",
                             "ISO_3166",
-                            "UN_LOCODE"
+                            "UN_LOCODE",
+                            null
                         ],
                         "type": "string",
                         "description": "* `IATA` - IATA (Airport codes)\n* `ISO_3166` - ISO 3166 (Country codes)\n* `UN_LOCODE` - UN/LOCODE (Location codes)",
-                        "x-spec-enum-id": "cf0efb5195f85007"
+                        "x-spec-enum-id": "cf0efb5195f85007",
+                        "nullable": true
                     },
                     "extra_choices": {
                         "type": "array",
@@ -297860,7 +297748,7 @@
                         "type": "string",
                         "minLength": 1,
                         "title": "URL",
-                        "description": "This URL will be called using the HTTP method defined when the webhook is called. Jinja2 template processing is supported with the same context as the request body.",
+                        "description": "This URL will be called using the HTTP method defined when the webhook is called. Must be http:// or https://. Jinja2 template processing is supported (with the same context as the request body) for part or all of the URL.",
                         "maxLength": 500
                     },
                     "http_method": {
@@ -300843,35 +300731,6 @@
                     }
                 }
             },
-            "PatchedScriptInputRequest": {
-                "type": "object",
-                "properties": {
-                    "data": {},
-                    "commit": {
-                        "type": "boolean"
-                    },
-                    "schedule_at": {
-                        "type": "string",
-                        "format": "date-time",
-                        "nullable": true
-                    },
-                    "interval": {
-                        "type": "integer",
-                        "nullable": true
-                    },
-                    "notifications": {
-                        "enum": [
-                            "always",
-                            "on_failure",
-                            "never"
-                        ],
-                        "type": "string",
-                        "description": "* `always` - Always\n* `on_failure` - On failure\n* `never` - Never",
-                        "x-spec-enum-id": "57071f4400340a5c",
-                        "default": "always"
-                    }
-                }
-            },
             "PatchedScriptModuleRequest": {
                 "type": "object",
                 "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)",
@@ -301681,7 +301540,7 @@
                         "type": "string",
                         "minLength": 1,
                         "title": "URL",
-                        "description": "This URL will be called using the HTTP method defined when the webhook is called. Jinja2 template processing is supported with the same context as the request body.",
+                        "description": "This URL will be called using the HTTP method defined when the webhook is called. Must be http:// or https://. Jinja2 template processing is supported (with the same context as the request body) for part or all of the URL.",
                         "maxLength": 500
                     },
                     "http_method": {
@@ -318061,6 +317920,71 @@
                     "vars"
                 ]
             },
+            "ScriptDetail": {
+                "type": "object",
+                "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display_url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "module": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "name": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "description": {
+                        "type": "string",
+                        "nullable": true,
+                        "readOnly": true
+                    },
+                    "vars": {
+                        "nullable": true,
+                        "readOnly": true
+                    },
+                    "result": {
+                        "allOf": [
+                            {
+                                "$ref": "#/components/schemas/Job"
+                            }
+                        ],
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "is_executable": {
+                        "type": "boolean",
+                        "readOnly": true
+                    }
+                },
+                "required": [
+                    "description",
+                    "display",
+                    "display_url",
+                    "id",
+                    "is_executable",
+                    "module",
+                    "name",
+                    "result",
+                    "url",
+                    "vars"
+                ]
+            },
             "ScriptInputRequest": {
                 "type": "object",
                 "properties": {
@@ -324076,7 +324000,7 @@
                     "payload_url": {
                         "type": "string",
                         "title": "URL",
-                        "description": "This URL will be called using the HTTP method defined when the webhook is called. Jinja2 template processing is supported with the same context as the request body.",
+                        "description": "This URL will be called using the HTTP method defined when the webhook is called. Must be http:// or https://. Jinja2 template processing is supported (with the same context as the request body) for part or all of the URL.",
                         "maxLength": 500
                     },
                     "http_method": {
@@ -324185,7 +324109,7 @@
                         "type": "string",
                         "minLength": 1,
                         "title": "URL",
-                        "description": "This URL will be called using the HTTP method defined when the webhook is called. Jinja2 template processing is supported with the same context as the request body.",
+                        "description": "This URL will be called using the HTTP method defined when the webhook is called. Must be http:// or https://. Jinja2 template processing is supported (with the same context as the request body) for part or all of the URL.",
                         "maxLength": 500
                     },
                     "http_method": {

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

@@ -1,5 +1,32 @@
 # NetBox v4.6
 
+## v4.6.8 (2026-08-11)
+
+### Performance Improvements
+
+* [#22787](https://github.com/netbox-community/netbox/issues/22787) - Avoid N+1 queries when resolving generic relations (e.g. assigned objects) via the GraphQL API
+* [#22835](https://github.com/netbox-community/netbox/issues/22835) - Improve performance when provisioning new custom fields
+* [#22837](https://github.com/netbox-community/netbox/issues/22837) - Omit implicit pagination when prefetching to-one relations via the GraphQL API
+* [#22877](https://github.com/netbox-community/netbox/issues/22877) - Improve caching logic when retrieving custom fields via `get_for_model()`
+
+### Bug Fixes
+
+* [#22694](https://github.com/netbox-community/netbox/issues/22694) - Clear a device's stale rack assignment when changing its site
+* [#22745](https://github.com/netbox-community/netbox/issues/22745) - Enforce object permissions on custom script write operations via the REST API
+* [#22805](https://github.com/netbox-community/netbox/issues/22805) - Avoid re-executing the LDAP configuration file on every permission check
+* [#22821](https://github.com/netbox-community/netbox/issues/22821) - Prevent the deletion of a tenant group from creating duplicate tenant names or slugs
+* [#22825](https://github.com/netbox-community/netbox/issues/22825) - Fix cable path tracing for paths which originate from a circuit termination and traverse only pass-through ports
+* [#22828](https://github.com/netbox-community/netbox/issues/22828) - Validate that a webhook's payload URL is a valid URL or Jinja2 template when saving
+* [#22844](https://github.com/netbox-community/netbox/issues/22844) - Allow a null value for `base_choices` when creating a custom field choice set via the REST API
+* [#22848](https://github.com/netbox-community/netbox/issues/22848) - Ensure deterministic ordering of duplicate IP addresses to avoid repeating an object across paginated REST API results
+* [#22852](https://github.com/netbox-community/netbox/issues/22852) - Honor a custom script's `notifications_default` setting when the script is run from an event rule
+* [#22865](https://github.com/netbox-community/netbox/issues/22865) - Reference the appropriate component template types on the GraphQL type for inventory item templates
+* [#22879](https://github.com/netbox-community/netbox/issues/22879) - Improve the contrast of unselected radio buttons and checkboxes in dark mode
+* [#22882](https://github.com/netbox-community/netbox/issues/22882) - Fix support for the `DISTINCT` filter on nested GraphQL list fields
+* [#22894](https://github.com/netbox-community/netbox/issues/22894) - Sanitize the error message rendered when an exception occurs in `CustomLinkColumn`
+
+---
+
 ## v4.6.7 (2026-07-30)
 
 ### Performance Improvements

+ 13 - 11
netbox/dcim/forms/bulk_import.py

@@ -1,7 +1,7 @@
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.forms.array import SimpleArrayField
-from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
+from django.core.exceptions import NON_FIELD_ERRORS, MultipleObjectsReturned, ObjectDoesNotExist
 from django.utils.functional import lazy
 from django.utils.html import format_html
 from django.utils.safestring import SafeString, mark_safe
@@ -1894,17 +1894,19 @@ class CableImportForm(PrimaryModelImportForm):
                     **side_b_parent_params
                 )
 
-    def add_error(self, field, error):
+    def _update_errors(self, errors):
         # Remap any termination errors raised by Cable.clean() onto the relevant import column.
-        # Without this, Django raises ValueError for an error reported against a field which does
-        # not exist on the form. Errors are dispatched per key so that both sides can fall back to
-        # a non-field error without colliding.
-        if field is None and hasattr(error, 'error_dict'):
-            for name, errors in error.error_dict.items():
-                super().add_error(self._map_termination_field(name), errors)
-            return
-
-        super().add_error(self._map_termination_field(field), error)
+        # Without this, the base form reports them as prefixed non-field errors, as neither
+        # a_terminations nor b_terminations exists as a field on this form. Errors are dispatched
+        # per key so that both sides can fall back to a non-field error without colliding.
+        if hasattr(errors, 'error_dict'):
+            remapped = {}
+            for name, error_list in errors.error_dict.items():
+                mapped_name = self._map_termination_field(name) or NON_FIELD_ERRORS
+                remapped.setdefault(mapped_name, []).extend(error_list)
+            errors = forms.ValidationError(remapped)
+
+        super()._update_errors(errors)
 
     def _map_termination_field(self, field):
         """

+ 32 - 0
netbox/dcim/tests/test_views.py

@@ -3757,6 +3757,38 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         instance.refresh_from_db()
         self.assertIsNone(instance.primary_mac_address)
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_bulk_import_omitted_field_validation_error(self):
+        """Surface omitted-field validation errors during bulk updates."""
+        device = Device.objects.first()
+        wireless_interface = Interface.objects.create(
+            device=device,
+            name='Wireless-22683',
+            type=InterfaceTypeChoices.TYPE_80211AC,
+            rf_channel_width=Decimal('20.0'),
+        )
+        self.add_permissions('dcim.add_interface', 'dcim.change_interface')
+        csv_data = '\n'.join([
+            'id,type',
+            f'{wireless_interface.pk},{InterfaceTypeChoices.TYPE_1GE_GBIC}',
+        ])
+        response = self.client.post(
+            self._get_url('bulk_import'),
+            data={
+                'data': csv_data,
+                'format': ImportFormatChoices.CSV,
+                'csv_delimiter': CSVDelimiterChoices.AUTO,
+            },
+        )
+        self.assertHttpStatus(response, 200)
+        self.assertContains(
+            response,
+            'rf_channel_width: Channel width may be set only on wireless interfaces.',
+        )
+        wireless_interface.refresh_from_db()
+        self.assertEqual(wireless_interface.type, InterfaceTypeChoices.TYPE_80211AC)
+        self.assertEqual(wireless_interface.rf_channel_width, Decimal('20.0'))
+
 
 class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = FrontPort

+ 34 - 0
netbox/extras/api/routers.py

@@ -0,0 +1,34 @@
+from rest_framework.routers import Route
+
+from netbox.api.routers import NetBoxRouter
+
+from .views import ScriptViewSet
+
+__all__ = (
+    'ScriptRouter',
+)
+
+
+class ScriptRouter(NetBoxRouter):
+    """
+    Extend NetBoxRouter to map POST on the script detail route to ScriptViewSet.run(). DRF's detail route
+    maps only the standard CRUD methods; absent this, run() must be declared as a raw post() method, which
+    binds to every route of the ViewSet and is invisible to per-action permissions & schema generation.
+    """
+    def get_routes(self, viewset):
+        if not issubclass(viewset, ScriptViewSet):
+            return super().get_routes(viewset)
+
+        # Extend the detail route template. Applied before super() expands the templates so that any
+        # @action routes are untouched; _replace() avoids mutating the templates shared by all routers.
+        routes = self.routes
+        self.routes = [
+            route._replace(mapping={**route.mapping, 'post': 'run'})
+            if isinstance(route, Route) and route.detail else route
+            for route in routes
+        ]
+
+        try:
+            return super().get_routes(viewset)
+        finally:
+            self.routes = routes

+ 2 - 3
netbox/extras/api/urls.py

@@ -1,10 +1,9 @@
 from django.urls import include, path
 
-from netbox.api.routers import NetBoxRouter
-
 from . import views
+from .routers import ScriptRouter
 
-router = NetBoxRouter()
+router = ScriptRouter()
 router.APIRootView = views.ExtrasRootView
 
 router.register('event-rules', views.EventRuleViewSet)

+ 60 - 30
netbox/extras/api/views.py

@@ -1,16 +1,15 @@
 from django.http import Http404
 from django.shortcuts import get_object_or_404
 from django.utils.translation import gettext_lazy as _
-from drf_spectacular.utils import OpenApiResponse, OpenApiTypes, extend_schema, extend_schema_view
+from drf_spectacular.utils import OpenApiResponse, OpenApiTypes, extend_schema
 from rest_framework import status
 from rest_framework.decorators import action
-from rest_framework.exceptions import PermissionDenied
+from rest_framework.exceptions import PermissionDenied, ValidationError
 from rest_framework.generics import RetrieveUpdateDestroyAPIView
 from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin
 from rest_framework.renderers import JSONRenderer
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
-from rest_framework.viewsets import ModelViewSet
 
 from core.choices import ManagedFileRootPathChoices
 from extras import filtersets
@@ -308,30 +307,44 @@ class ScriptModuleViewSet(ObjectValidationMixin, CreateModelMixin, UpdateModelMi
         return obj
 
 
-@extend_schema_view(
-    update=extend_schema(request=serializers.ScriptInputSerializer),
-    partial_update=extend_schema(request=serializers.ScriptInputSerializer),
-)
-class ScriptViewSet(ModelViewSet):
+class ScriptViewSet(ListModelMixin, RetrieveModelMixin, BaseViewSet):
+    # Individual scripts are created, modified, and deleted through their module (see ScriptModuleViewSet),
+    # so the standard write actions are intentionally omitted here. Only listing/retrieving a script (GET)
+    # and running one (POST to the detail route) are supported.
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
     queryset = Script.objects.all()
     serializer_class = serializers.ScriptSerializer
     filterset_class = filtersets.ScriptFilterSet
 
-    _ignore_model_permissions = True
     lookup_value_regex = '[^/]+'  # Allow dots
 
-    def initial(self, request, *args, **kwargs):
-        super().initial(request, *args, **kwargs)
+    def get_serializer(self, *args, **kwargs):
+        # A POST to the detail route runs the script, taking ScriptInputSerializer as its request body.
+        # (This is keyed on the request method rather than on self.action, which is unset when generating
+        # OPTIONS metadata.) ScriptInputSerializer is instantiated directly rather than via BaseViewSet,
+        # which would pass it the fields/omit kwargs supported only by BaseModelSerializer.
+        if getattr(self.request, 'method', None) == 'POST':
+            kwargs.setdefault('context', self.get_serializer_context())
+            return serializers.ScriptInputSerializer(*args, **kwargs)
+        return super().get_serializer(*args, **kwargs)
 
-        # Restrict the view's QuerySet to allow only the permitted objects
-        if request.user.is_authenticated:
-            action = 'run' if request.method == 'POST' else 'view'
-            self.queryset = self.queryset.restrict(request.user, action)
+    def get_serializer_context(self):
+        context = super().get_serializer_context()
+
+        # ScriptInputSerializer resolves its field defaults and validates scheduling against the script
+        # being run (set by run() below).
+        context['script'] = getattr(self, 'script', None)
+
+        return context
 
     def _get_script(self, pk):
-        # If pk is numeric, retrieve script by ID
-        if pk.isnumeric():
+        # Retrieve the script by ID if the PK is all decimal digits. (isdecimal() rather than isnumeric(),
+        # as the latter also matches characters which cannot be cast to an integer.)
+        if pk.isdecimal():
+            try:
+                pk = int(pk)
+            except ValueError:
+                raise Http404
             return get_object_or_404(self.queryset, pk=pk)
 
         # Default to retrieval by module & name
@@ -342,32 +355,49 @@ class ScriptViewSet(ModelViewSet):
 
         return get_object_or_404(self.queryset, module__file_path=f'{module_name}.py', name=script_name)
 
-    def retrieve(self, request, pk):
+    def retrieve(self, request, pk, **kwargs):
         script = self._get_script(pk)
         serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
 
         return Response(serializer.data)
 
-    def post(self, request, pk):
+    @extend_schema(
+        operation_id='extras_scripts_run',
+        request=serializers.ScriptInputSerializer,
+        responses={
+            200: OpenApiResponse(
+                response=serializers.ScriptDetailSerializer,
+                description=_("The script has been enqueued for execution."),
+            ),
+        },
+    )
+    def run(self, request, pk, **kwargs):
         """
         Run a Script identified by its numeric PK or module & name and return the pending Job as the result
         """
+        # Bound to POST on the detail route by ScriptRouter
 
-        # Running a script is a state-changing operation. If token authentication is in use, enforce the token's
-        # write ability before performing any object lookup. Session-authenticated requests are unaffected
-        # (request.auth is not a Token).
+        # Reject read-only tokens before resolving the script, so that an insufficient token is always
+        # reported as such. (Not via TokenWritePermission, which permits token auth only.)
         if isinstance(request.auth, Token) and not request.auth.write_enabled:
-            raise PermissionDenied("This token does not permit write operations.")
+            raise PermissionDenied(_("This token does not permit write operations (running a script)."))
 
-        script = self._get_script(pk)
+        # An unauthenticated user can never run a script; report that explicitly, as restrict() below would
+        # match no scripts and yield a misleading 404.
+        if not request.user.is_authenticated:
+            raise PermissionDenied(_("This user does not have permission to run this script."))
+
+        # Running a script is a 'run' operation (not the 'add' that BaseViewSet maps to POST), so restrict
+        # the QuerySet on 'run' before resolving the script. A script the user cannot run yields a 404.
+        self.queryset = self.queryset.model.objects.restrict(request.user, 'run')
+        self.script = script = self._get_script(pk)
 
-        if not request.user.has_perm('extras.run_script', obj=script):
-            raise PermissionDenied("This user does not have permission to run this script.")
+        # A script whose Python class cannot be resolved (e.g. its module has been modified or the script has
+        # been deleted, retaining the record for its jobs) cannot be run
+        if not script.is_executable or script.python_class is None:
+            raise ValidationError(_("This script is not currently executable."))
 
-        input_serializer = serializers.ScriptInputSerializer(
-            data=request.data,
-            context={'script': script}
-        )
+        input_serializer = self.get_serializer(data=request.data)
 
         # Check that at least one RQ worker is running
         if not any_workers_for_queue('default'):

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

@@ -3,7 +3,8 @@ import logging
 from functools import cached_property
 
 from django.contrib.contenttypes.fields import GenericRelation
-from django.db import models
+from django.contrib.contenttypes.models import ContentType
+from django.db import models, router, transaction
 from django.db.models import Q
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
@@ -11,7 +12,7 @@ from django.utils.translation import gettext_lazy as _
 from core.choices import ManagedFileRootPathChoices
 from core.models import ManagedFile
 from extras.utils import is_script
-from netbox.models.features import EventRulesMixin, JobsMixin
+from netbox.models.features import EventRulesMixin, JobsMixin, batch_delete_jobs
 from utilities.querysets import RestrictedQuerySet
 
 from .mixins import PythonModuleMixin
@@ -119,6 +120,29 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
     def __str__(self):
         return self.python_name
 
+    def delete(self, using=None, *args, **kwargs):
+        # Job is imported here rather than at module level to avoid a circular import
+        # (core.models.jobs -> core.signals -> extras.events -> extras.models -> this module).
+        from core.models import Job
+
+        # Deleting a ScriptModule cascades (via the Script.module FK) to its child Scripts, and
+        # Django's collector would materialize every one of those Scripts' Jobs to delete them.
+        # A module's scripts can accumulate thousands of jobs, exhausting memory. Batch-delete
+        # the child Scripts' jobs up front, in a single queryset (no per-script loop), before
+        # delegating to the cascade. The transaction rolls the job deletions back if the parent
+        # delete fails; note it does not cover ManagedFile.delete removing the file from disk,
+        # which happens before the DB delete and is not transactional. See #22812.
+        using = using or router.db_for_write(self.__class__, instance=self)
+        with transaction.atomic(using=using):
+            script_type = ContentType.objects.get_for_model(Script, for_concrete_model=False)
+            child_jobs = Job.objects.using(using).filter(
+                object_type=script_type,
+                object_id__in=self.scripts.values_list('pk', flat=True),
+            )
+            batch_delete_jobs(child_jobs)
+            return super().delete(using, *args, **kwargs)
+    delete.alters_data = True
+
     @property
     def ordered_scripts(self):
         script_objects = {s.name: s for s in self.scripts.all()}

+ 223 - 23
netbox/extras/tests/test_api.py

@@ -8,13 +8,14 @@ from unittest.mock import MagicMock, patch
 from django.contrib.contenttypes.models import ContentType
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.db import IntegrityError
+from django.test import override_settings
 from django.urls import reverse
 from django.utils.timezone import make_aware, now
 from rest_framework import status
 
 from core.choices import ManagedFileRootPathChoices
 from core.events import *
-from core.models import DataFile, DataSource, ObjectType
+from core.models import DataFile, DataSource, Job, ObjectType
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Rack, RackRole, Site
 from extras.api.serializers import EventRuleSerializer
 from extras.choices import *
@@ -26,7 +27,7 @@ from netbox.registry import registry
 from users.constants import TOKEN_PREFIX
 from users.models import Group, ObjectPermission, Token, User
 from utilities.tables import get_table_for_model
-from utilities.testing import APITestCase, APIViewTestCases
+from utilities.testing import APITestCase, APIViewTestCases, disable_warnings
 
 
 class AppTestCase(APITestCase):
@@ -1532,6 +1533,34 @@ class ScriptTestCase(APITestCase):
         self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
         self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
 
+    def test_list_scripts(self):
+        """
+        The list route is served by BaseViewSet, which resolves the QuerySet's prefetches & annotations (and
+        any fields/omit request parameters) from the serializer.
+        """
+        url = reverse('extras-api:script-list')
+
+        response = self.client.get(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['count'], 1)
+        self.assertEqual(response.data['results'][0]['name'], self.TestScriptClass.Meta.name)
+
+        response = self.client.get(f'{url}?fields=id,name', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(sorted(response.data['results'][0]), ['id', 'name'])
+
+    def test_get_script_by_module_and_name(self):
+        """
+        A script may also be identified by its module & name, e.g. /api/extras/scripts/example.MyReport/.
+        """
+        script = Script.objects.first()
+        url = reverse('extras-api:script-detail', kwargs={'pk': f'script.{script.name}'})
+
+        response = self.client.get(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['id'], script.pk)
+
     def test_schedule_script_past_time_rejected(self):
         """
         Scheduling with past schedule_at should fail.
@@ -1602,45 +1631,216 @@ class ScriptTestCase(APITestCase):
             # Restore the original setting for other tests
             self.TestScriptClass.Meta.scheduling_enabled = original
 
-    def test_run_token_write_enabled(self):
+    def test_run_script_without_permission(self):
         """
-        Running a script is an unsafe (state-changing) action and must be rejected when the calling token has
-        write_enabled=False, even if the user holds the run_script permission.
+        A user permitted to view a script but not to run it must not be able to enqueue it. (The script is
+        excluded from the restricted QuerySet, so the request yields a 404.)
         """
+        payload = {'data': {'var1': 'hello', 'var2': 1, 'var3': False}, 'commit': True}
+
+        # setUp() grants only extras.view_script
+        with disable_warnings('django.request'):
+            response = self.client.post(self.url, payload, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
+        self.assertFalse(Job.objects.exists())
+
+        # Granting the run permission permits the same request
         self.add_permissions('extras.run_script')
-        payload = {
-            'data': {'var1': 'hello', 'var2': 1, 'var3': False},
-            'commit': True,
-        }
+        response = self.client.post(self.url, payload, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertTrue(Job.objects.exists())
 
-        # A token with write_enabled=False should be rejected
-        token = Token.objects.create(version=2, user=self.user, write_enabled=False)
-        token_header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'
-        response = self.client.post(self.url, payload, format='json', HTTP_AUTHORIZATION=token_header)
+    @override_settings(LOGIN_REQUIRED=False, EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_run_script_anonymous(self):
+        """
+        An unauthenticated user must be told that running a script is not permitted, rather than that the
+        script does not exist.
+        """
+        payload = {'data': {'var1': 'hello', 'var2': 1, 'var3': False}, 'commit': True}
+
+        with disable_warnings('django.request'):
+            response = self.client.post(self.url, payload, format='json')
         self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+        self.assertFalse(Job.objects.exists())
 
-        # Enabling write ability on the token should allow the script to run
-        token.write_enabled = True
-        token.save()
-        response = self.client.post(self.url, payload, format='json', HTTP_AUTHORIZATION=token_header)
+    def test_run_script_read_only_token(self):
+        """
+        Running a script is a write operation and must be rejected for a read-only token.
+        """
+        self.add_permissions('extras.run_script')
+        payload = {'data': {'var1': 'hello', 'var2': 1, 'var3': False}, 'commit': True}
+
+        # A write-disabled token should be rejected
+        ro_token = Token.objects.create(version=2, user=self.user, write_enabled=False)
+        ro_header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{ro_token.key}.{ro_token.token}'}
+        response = self.client.post(self.url, payload, format='json', **ro_header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+
+        # The default (write-enabled) token should succeed
+        response = self.client.post(self.url, payload, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
-    def test_run_session_auth(self):
+    def test_run_script_read_only_token_without_permission(self):
+        """
+        A read-only token is rejected before the script is resolved, so an insufficient token is reported as
+        such regardless of the user's permission to run the script.
+        """
+        payload = {'data': {'var1': 'hello', 'var2': 1, 'var3': False}, 'commit': True}
+
+        # setUp() grants only extras.view_script
+        ro_token = Token.objects.create(version=2, user=self.user, write_enabled=False)
+        ro_header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{ro_token.key}.{ro_token.token}'}
+        response = self.client.post(self.url, payload, format='json', **ro_header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+
+    def test_run_script_session_auth(self):
         """
         The token write-ability check applies only to token authentication. Session-authenticated requests
-        (where request.auth is not a Token) must still be allowed to run scripts.
+        (where request.auth is not a Token) must still be permitted to run scripts.
         """
         self.add_permissions('extras.run_script')
-        payload = {
-            'data': {'var1': 'hello', 'var2': 1, 'var3': False},
-            'commit': True,
-        }
+        payload = {'data': {'var1': 'hello', 'var2': 1, 'var3': False}, 'commit': True}
 
         # Authenticate via session rather than a token; request.auth is None
         self.client.force_authenticate(user=self.user)
         response = self.client.post(self.url, payload, format='json')
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
+    def test_run_script_not_executable(self):
+        """
+        A script whose Python class cannot be resolved must be rejected, not raise an exception.
+        """
+        self.add_permissions('extras.run_script')
+        payload = {'data': {'var1': 'hello', 'var2': 1, 'var3': False}, 'commit': True}
+
+        # Simulate a script whose class can no longer be found in its module
+        class_patch = patch.object(Script, 'python_class', None)
+        class_patch.start()
+        self.addCleanup(class_patch.stop)
+
+        response = self.client.post(self.url, payload, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertFalse(Job.objects.exists())
+
+    def test_run_script_by_module_and_name(self):
+        """
+        A script identified by its module & name (rather than by its PK) must also be runnable.
+        """
+        self.add_permissions('extras.run_script')
+        payload = {'data': {'var1': 'hello', 'var2': 1, 'var3': False}, 'commit': True}
+        script = Script.objects.first()
+        url = reverse('extras-api:script-detail', kwargs={'pk': f'script.{script.name}'})
+
+        response = self.client.post(url, payload, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['id'], script.pk)
+        self.assertTrue(Job.objects.exists())
+
+    def test_run_script_format_suffix(self):
+        """
+        The format-suffix variants of the detail route (e.g. /1.json) must dispatch to run().
+        """
+        self.add_permissions('extras.run_script')
+        payload = {'data': {'var1': 'hello', 'var2': 1, 'var3': False}, 'commit': True}
+        script = Script.objects.first()
+        lookups = (script.pk, f'script.{script.name}')
+
+        for lookup in lookups:
+            with self.subTest(lookup=lookup):
+                url = reverse('extras-api:script-detail', kwargs={'pk': lookup, 'format': 'json'})
+
+                response = self.client.post(url, payload, format='json', **self.header)
+
+                self.assertHttpStatus(response, status.HTTP_200_OK)
+                self.assertEqual(response.data['id'], script.pk)
+
+        self.assertEqual(Job.objects.count(), len(lookups))
+
+    def test_modify_script_methods_disabled(self):
+        """
+        Individual scripts are created, modified, and deleted through their module, so PUT/PATCH/DELETE on
+        the script endpoint are not supported (even for a user holding the corresponding permissions).
+        """
+        self.add_permissions('extras.change_script', 'extras.delete_script')
+        script = Script.objects.first()
+
+        for method in ('put', 'patch', 'delete'):
+            with self.subTest(method=method):
+                with disable_warnings('django.request'):
+                    response = getattr(self.client, method)(self.url, {}, format='json', **self.header)
+                self.assertHttpStatus(response, status.HTTP_405_METHOD_NOT_ALLOWED)
+
+        # The script must remain untouched
+        self.assertTrue(Script.objects.filter(pk=script.pk).exists())
+
+    def test_create_script_disabled(self):
+        """
+        Scripts cannot be created via the API: POST is mapped only on the detail route (to run a script),
+        and must be neither permitted nor advertised on the list route.
+        """
+        self.add_permissions('extras.add_script')
+        list_url = reverse('extras-api:script-list')
+
+        with disable_warnings('django.request'):
+            response = self.client.post(list_url, {}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_405_METHOD_NOT_ALLOWED)
+
+        # OPTIONS must not advertise a create action for the list route
+        response = self.client.options(list_url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertNotIn('POST', response.data.get('actions', {}))
+
+    def test_options_detail_route(self):
+        """
+        POST on the detail route runs a script, so its OPTIONS metadata must describe the run input
+        rather than the Script model's own fields.
+        """
+        self.add_permissions('extras.run_script')
+
+        response = self.client.options(self.url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        post_fields = response.data['actions']['POST']
+        self.assertIn('data', post_fields)
+        self.assertIn('commit', post_fields)
+        self.assertNotIn('module', post_fields)
+        self.assertNotIn('name', post_fields)
+
+    def test_options_detail_route_dynamic_fields(self):
+        """
+        The run input serializer does not support the fields/omit query parameters, but their presence must
+        not break the generation of OPTIONS metadata.
+        """
+        self.add_permissions('extras.run_script')
+
+        for query in ('fields=id', 'omit=id'):
+            with self.subTest(query=query):
+                response = self.client.options(f'{self.url}?{query}', **self.header)
+
+                self.assertHttpStatus(response, status.HTTP_200_OK)
+                self.assertIn('data', response.data['actions']['POST'])
+
+    def test_unsupported_method(self):
+        """
+        A request using an HTTP method which maps to no action must be rejected with a 405.
+        """
+        with disable_warnings('django.request'):
+            response = self.client.trace(self.url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_405_METHOD_NOT_ALLOWED)
+
+    def test_get_script_invalid_pk(self):
+        """
+        A PK which cannot be cast to an integer must yield a 404, not a server error. This covers numeric (but
+        non-decimal) characters, as well as a decimal value too long for Python to convert.
+        """
+        for pk in ('½', '1' * 5000):
+            with self.subTest(pk=pk[:10]):
+                url = reverse('extras-api:script-detail', kwargs={'pk': pk})
+
+                with disable_warnings('django.request'):
+                    response = self.client.get(url, **self.header)
+                self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
+
 
 class CreatedUpdatedFilterTestCase(APITestCase):
 

+ 52 - 0
netbox/extras/tests/test_api_routers.py

@@ -0,0 +1,52 @@
+from django.test import TestCase
+
+from extras.api.routers import ScriptRouter
+from extras.api.views import CustomFieldChoiceSetViewSet, ScriptViewSet, WebhookViewSet
+
+
+class ScriptRouterTestCase(TestCase):
+    """
+    Verify the routes generated by ScriptRouter.
+    """
+    @staticmethod
+    def get_actions(viewset):
+        """
+        Return a mapping of route name to the HTTP methods bound on it for the given ViewSet.
+        """
+        router = ScriptRouter()
+        router.register('dummy', viewset, basename='dummy')
+
+        return {
+            url.name: url.callback.actions
+            for url in router.urls if hasattr(url.callback, 'actions')
+        }
+
+    def test_script_routes(self):
+        actions = self.get_actions(ScriptViewSet)
+
+        # POST on the detail route runs the script; the list route accepts only GET
+        self.assertEqual(actions['dummy-detail'], {'get': 'retrieve', 'post': 'run'})
+        self.assertEqual(actions['dummy-list'], {'get': 'list'})
+
+    def test_script_viewset_subclass(self):
+        # A subclass of ScriptViewSet (e.g. as registered by a plugin) gets the same route mapping
+        class MyScriptViewSet(ScriptViewSet):
+            pass
+
+        actions = self.get_actions(MyScriptViewSet)
+
+        self.assertEqual(actions['dummy-detail'], {'get': 'retrieve', 'post': 'run'})
+        self.assertEqual(actions['dummy-list'], {'get': 'list'})
+
+    def test_other_viewsets_unaffected(self):
+        # Standard ViewSets keep the stock detail route mapping
+        self.assertNotIn('post', self.get_actions(WebhookViewSet)['dummy-detail'])
+
+        # Routes generated for @action methods are untouched
+        self.assertEqual(self.get_actions(CustomFieldChoiceSetViewSet)['dummy-choices'], {'get': 'choices'})
+
+    def test_route_templates_not_mutated(self):
+        router = ScriptRouter()
+        router.get_routes(ScriptViewSet)
+
+        self.assertNotIn('post', router.routes[2].mapping)

+ 306 - 0
netbox/extras/tests/test_scripts_deletion.py

@@ -0,0 +1,306 @@
+import uuid
+from unittest import mock
+
+from django.contrib.contenttypes.models import ContentType
+from django.db import router
+from django.db.models import QuerySet
+from django.test import TestCase, override_settings
+from django.urls import reverse
+
+from core.choices import JobStatusChoices, ManagedFileRootPathChoices
+from core.models import DataSource, Job
+from extras.models import Script, ScriptModule
+from extras.validators import CustomValidator
+from netbox.models.deletion import ConfirmCollector, CountOnly
+from utilities.exceptions import AbortRequest
+from utilities.testing import TestCase as ViewTestCase
+
+
+class ScriptDeletionTestCase(TestCase):
+    """
+    Regression tests for #22812: deleting a JobsMixin object (Script, ScriptModule, DataSource)
+    with many associated Jobs must not load every Job into memory at once.
+    """
+    @classmethod
+    def setUpTestData(cls):
+        cls.script_ct = ContentType.objects.get_for_model(Script, for_concrete_model=False)
+
+    def _create_module(self):
+        return ScriptModule.objects.create(
+            file_root=ManagedFileRootPathChoices.SCRIPTS,
+            file_path=f'test_{uuid.uuid4().hex[:8]}.py',
+        )
+
+    def _create_script(self, module=None):
+        module = module or self._create_module()
+        script = Script.objects.create(module=module, name=f'S{uuid.uuid4().hex[:8]}')
+        return module, script
+
+    def _add_jobs(self, obj, count, object_type=None):
+        object_type = object_type or ContentType.objects.get_for_model(type(obj), for_concrete_model=False)
+        Job.objects.bulk_create([
+            Job(
+                object_type=object_type,
+                object_id=obj.pk,
+                name='testjob',
+                status=JobStatusChoices.STATUS_COMPLETED,
+                job_id=uuid.uuid4(),
+                data={'output': 'x' * 50},
+            )
+            for _ in range(count)
+        ])
+
+    def test_delete_script_deletes_all_jobs(self):
+        _, script = self._create_script()
+        self._add_jobs(script, 2500)
+        self.assertEqual(script.jobs.count(), 2500)
+
+        script.delete()
+
+        self.assertFalse(Script.objects.filter(pk=script.pk).exists())
+        self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 0)
+
+    def test_delete_script_batches_jobs(self):
+        _, script = self._create_script()
+        self._add_jobs(script, 5)
+
+        job_delete_calls = []
+        original_delete = QuerySet.delete
+
+        def counting_delete(qs, *args, **kwargs):
+            if qs.model is Job:
+                job_delete_calls.append(len(qs))
+            return original_delete(qs, *args, **kwargs)
+
+        with mock.patch('netbox.models.features.JOB_DELETE_BATCH_SIZE', 2):
+            with mock.patch.object(QuerySet, 'delete', counting_delete):
+                script.delete()
+
+        # 5 jobs at a batch size of 2 => three batched deletes (2, 2, 1)
+        self.assertEqual(job_delete_calls, [2, 2, 1])
+        self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 0)
+
+    def test_delete_scriptmodule_cascades_to_scripts_and_jobs(self):
+        module, script = self._create_script()
+        self._add_jobs(script, 100)
+
+        module.delete()
+
+        self.assertFalse(ScriptModule.objects.filter(pk=module.pk).exists())
+        self.assertFalse(Script.objects.filter(pk=script.pk).exists())
+        self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 0)
+
+    def test_delete_scriptmodule_batches_child_script_jobs(self):
+        # The reporter's actual path: a script is only removable via the UI by deleting its
+        # ScriptModule. The module's delete must batch the child Script's jobs.
+        module, script = self._create_script()
+        self._add_jobs(script, 5)
+
+        job_delete_calls = []
+        original_delete = QuerySet.delete
+
+        def counting_delete(qs, *args, **kwargs):
+            if qs.model is Job:
+                job_delete_calls.append(len(qs))
+            return original_delete(qs, *args, **kwargs)
+
+        with mock.patch('netbox.models.features.JOB_DELETE_BATCH_SIZE', 2):
+            with mock.patch.object(QuerySet, 'delete', counting_delete):
+                module.delete()
+
+        # 5 child-script jobs at a batch size of 2 => three batched deletes (2, 2, 1). The module
+        # has no jobs of its own, so JobsMixin.delete adds no further Job deletes.
+        self.assertEqual(job_delete_calls, [2, 2, 1])
+        self.assertFalse(Script.objects.filter(pk=script.pk).exists())
+        self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 0)
+
+    def test_delete_datasource_deletes_jobs(self):
+        datasource = DataSource.objects.create(name='DS', type='local', source_url='/tmp/test')
+        self._add_jobs(datasource, 100)
+        ds_ct = ContentType.objects.get_for_model(DataSource, for_concrete_model=False)
+        self.assertEqual(Job.objects.filter(object_type=ds_ct, object_id=datasource.pk).count(), 100)
+
+        datasource.delete()
+
+        self.assertFalse(DataSource.objects.filter(pk=datasource.pk).exists())
+        self.assertEqual(Job.objects.filter(object_type=ds_ct, object_id=datasource.pk).count(), 0)
+
+    def test_soft_delete_preserves_jobs(self):
+        _, script = self._create_script()
+        self._add_jobs(script, 10)
+
+        script.delete(soft_delete=True)
+
+        script.refresh_from_db()
+        self.assertFalse(script.is_executable)
+        self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 10)
+
+    @override_settings(PROTECTION_RULES={'extras.script': [CustomValidator({'name': {'eq': ''}})]})
+    def test_delete_rolls_back_jobs_on_parent_failure(self):
+        # A protection rule that no real script can satisfy (name must be empty) makes the
+        # cascade's pre_delete handler raise AbortRequest *after* JobsMixin.delete has already
+        # batch-deleted the jobs. JobsMixin.delete wraps the batch loop and super().delete() in a
+        # transaction, so the job deletions must roll back, leaving no orphaned partial state.
+        # This exercises the real deletion-abort path rather than mocking Django internals.
+        _, script = self._create_script()
+        self._add_jobs(script, 10)
+
+        with self.assertRaises(AbortRequest):
+            script.delete()
+
+        self.assertTrue(Script.objects.filter(pk=script.pk).exists())
+        self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 10)
+
+    @override_settings(PROTECTION_RULES={'extras.script': [CustomValidator({'name': {'eq': ''}})]})
+    def test_delete_scriptmodule_rolls_back_child_jobs_on_failure(self):
+        # Same abort path via the module: the protection rule fires when the cascade pre_deletes
+        # the child Script, after ScriptModule.delete has already batch-deleted that script's jobs.
+        # The transaction must roll those job deletions back, leaving no orphaned partial state.
+        module, script = self._create_script()
+        self._add_jobs(script, 10)
+
+        with self.assertRaises(AbortRequest):
+            module.delete()
+
+        self.assertTrue(ScriptModule.objects.filter(pk=module.pk).exists())
+        self.assertTrue(Script.objects.filter(pk=script.pk).exists())
+        self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 10)
+
+
+class ConfirmCollectorTestCase(TestCase):
+    """
+    #22812: the delete-confirmation page must not materialize every dependent Job.
+    """
+    def _create_script_with_jobs(self, count):
+        module = ScriptModule.objects.create(
+            file_root=ManagedFileRootPathChoices.SCRIPTS,
+            file_path=f'test_{uuid.uuid4().hex[:8]}.py',
+        )
+        script = Script.objects.create(module=module, name=f'S{uuid.uuid4().hex[:8]}')
+        ct = ContentType.objects.get_for_model(Script, for_concrete_model=False)
+        Job.objects.bulk_create([
+            Job(object_type=ct, object_id=script.pk, name='j', status=JobStatusChoices.STATUS_COMPLETED,
+                job_id=uuid.uuid4(), data={'output': 'x' * 50})
+            for _ in range(count)
+        ])
+        return script
+
+    def test_confirm_collector_counts_jobs_without_instantiating(self):
+        script = self._create_script_with_jobs(500)
+
+        init_calls = []
+        original_init = Job.__init__
+
+        def counting_init(self, *args, **kwargs):
+            init_calls.append(1)
+            original_init(self, *args, **kwargs)
+
+        with mock.patch.object(Job, '__init__', counting_init):
+            collector = ConfirmCollector(using=router.db_for_write(Script))
+            collector.collect([script])
+
+        # No Job rows were instantiated; the relation was counted instead.
+        self.assertEqual(len(init_calls), 0)
+        self.assertNotIn(Job, collector.data)
+        self.assertEqual(collector.generic_relation_counts.get(Job), 500)
+        # The non-job cascade (the Script itself) is still collected.
+        self.assertIn(Script, collector.data)
+
+    def test_count_only_wrapper(self):
+        # CountOnly reports its count via len() but iterates empty, so it slots into the
+        # dependent-objects mapping as a non-expandable, non-materializing row.
+        wrapper = CountOnly(3000)
+        self.assertEqual(len(wrapper), 3000)
+        self.assertEqual(list(wrapper), [])
+        self.assertTrue(wrapper.count_only)
+
+    def test_confirm_collector_omits_jobs_when_none(self):
+        # A jobless object must not record a zero count, or the confirmation page would show a
+        # spurious "0 jobs" row (#22812 regression).
+        datasource = DataSource.objects.create(name='DS', type='local', source_url='/tmp/test')
+
+        collector = ConfirmCollector(using=router.db_for_write(DataSource))
+        collector.collect([datasource])
+
+        self.assertNotIn(Job, collector.generic_relation_counts)
+
+
+class ObjectDeleteViewCountsTestCase(ViewTestCase):
+    """
+    #22812: the delete-confirmation view must report a JobsMixin object's jobs as a count
+    (via CountOnly) without materializing them, and _get_dependent_objects must keep returning
+    a single dict.
+    """
+    def test_get_dependent_objects_returns_count_only_for_jobs(self):
+        from netbox.views.generic.object_views import ObjectDeleteView
+
+        module = ScriptModule.objects.create(
+            file_root=ManagedFileRootPathChoices.SCRIPTS,
+            file_path=f'test_{uuid.uuid4().hex[:8]}.py',
+        )
+        script = Script.objects.create(module=module, name=f'S{uuid.uuid4().hex[:8]}')
+        ct = ContentType.objects.get_for_model(Script, for_concrete_model=False)
+        Job.objects.bulk_create([
+            Job(
+                object_type=ct, object_id=script.pk, name='j',
+                status=JobStatusChoices.STATUS_COMPLETED, job_id=uuid.uuid4(),
+            )
+            for _ in range(50)
+        ])
+
+        view = ObjectDeleteView()
+        view.queryset = ScriptModule.objects.all()
+        dependent_objects = view._get_dependent_objects(module)
+
+        # Single dict returned (not a tuple); jobs represented as a CountOnly.
+        self.assertIsInstance(dependent_objects, dict)
+        self.assertIn(Job, dependent_objects)
+        self.assertIsInstance(dependent_objects[Job], CountOnly)
+        self.assertEqual(len(dependent_objects[Job]), 50)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_confirm_page_renders_job_count(self):
+        module = ScriptModule.objects.create(
+            file_root=ManagedFileRootPathChoices.SCRIPTS,
+            file_path=f'test_{uuid.uuid4().hex[:8]}.py',
+        )
+        script = Script.objects.create(module=module, name=f'S{uuid.uuid4().hex[:8]}')
+        ct = ContentType.objects.get_for_model(Script, for_concrete_model=False)
+        Job.objects.bulk_create([
+            Job(
+                object_type=ct, object_id=script.pk, name='j',
+                status=JobStatusChoices.STATUS_COMPLETED, job_id=uuid.uuid4(),
+            )
+            for _ in range(50)
+        ])
+
+        # ScriptModule is a proxy over core.ManagedFile, so the delete view requires the
+        # concrete model's permission (core.delete_managedfile), not extras.delete_scriptmodule.
+        self.add_permissions('core.delete_managedfile')
+        url = reverse('extras:scriptmodule_delete', kwargs={'pk': module.pk})
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        # Assert on the rendered context, not brittle HTML substrings: Job is present in
+        # dependent_objects as a CountOnly reporting the true count, so the confirmation page
+        # renders it as a summarized (non-expandable) row without materializing 50 Job rows.
+        dependent_objects = response.context['dependent_objects']
+        self.assertIn(Job, dependent_objects)
+        self.assertIsInstance(dependent_objects[Job], CountOnly)
+        self.assertEqual(len(dependent_objects[Job]), 50)
+        self.assertTrue(dependent_objects[Job].count_only)
+
+    def test_get_dependent_objects_omits_jobs_when_none(self):
+        from netbox.views.generic.object_views import ObjectDeleteView
+
+        # A module with no jobs must not produce a CountOnly(0) entry (#22812 regression).
+        module = ScriptModule.objects.create(
+            file_root=ManagedFileRootPathChoices.SCRIPTS,
+            file_path=f'test_{uuid.uuid4().hex[:8]}.py',
+        )
+
+        view = ObjectDeleteView()
+        view.queryset = ScriptModule.objects.all()
+        dependent_objects = view._get_dependent_objects(module)
+
+        self.assertNotIn(Job, dependent_objects)

+ 8 - 1
netbox/ipam/filtersets.py

@@ -1246,7 +1246,7 @@ class ServicePortMappingFilterMixin(django_filters.FilterSet):
     )
     protocol = django_filters.MultipleChoiceFilter(
         choices=ServiceProtocolChoices,
-        method='filter_noop',
+        method='filter_protocol_noop',
     )
     # Negation lookup retained from when `protocol` was a model field: method-based filters don't get
     # the char-based lookups (protocol__n, __ic, ...) auto-generated, and silently dropping protocol__n
@@ -1314,6 +1314,13 @@ class ServicePortMappingFilterMixin(django_filters.FilterSet):
         # See filter_queryset(), which applies `protocol` and the port lookups as one correlated predicate.
         return queryset
 
+    def filter_protocol_noop(self, queryset, name, value: list[str]):
+        # A no-op like filter_noop(), from which this differs only in its type hint. `protocol` is not a
+        # model field, so the schema generator cannot infer the parameter's type from the model, and a
+        # plain MultipleChoiceFilter carries none of the @extend_schema_field annotations which the
+        # MultiValue* filters (used by the port lookups) do. The hint supplies it.
+        return self.filter_noop(queryset, name, value)
+
     def filter_port_mappings(self, queryset, name, value: list[str]):
         # Array overlap (&&) is served by the GIN index on port_mappings and gives the multi-value OR
         # semantics used throughout NetBox: ?port_mappings=tcp/80&port_mappings=udp/53 matches either.

+ 8 - 0
netbox/netbox/api/viewsets/__init__.py

@@ -7,6 +7,7 @@ from django.db import router, transaction
 from django.db.models import ProtectedError, RestrictedError
 from rest_framework import mixins as drf_mixins
 from rest_framework import status
+from rest_framework.exceptions import MethodNotAllowed
 from rest_framework.response import Response
 from rest_framework.viewsets import GenericViewSet
 
@@ -87,6 +88,13 @@ class BaseViewSet(GenericViewSet):
     def initial(self, request, *args, **kwargs):
         super().initial(request, *args, **kwargs)
 
+        # Reject any method for which no action has been declared, rather than proceeding against an
+        # unrestricted QuerySet. (A method mapped to None, e.g. OPTIONS, is permitted: it needs no
+        # restriction.) This is the same 405 DRF would return when resolving the handler for an unmapped
+        # method, but it also covers a handler bound to such a method (e.g. @action(methods=['trace'])).
+        if request.method not in HTTP_ACTIONS:
+            raise MethodNotAllowed(request.method)
+
         # Restrict the view's QuerySet to allow only the permitted objects
         if request.user.is_authenticated:
             if action := HTTP_ACTIONS[request.method]:

+ 7 - 0
netbox/netbox/constants.py

@@ -39,3 +39,10 @@ CENSOR_TOKEN_CHANGED = '***CHANGED***'
 
 # Placeholder text for empty tables
 EMPTY_TABLE_TEXT = 'No results found'
+
+# Batch size for deleting a JobsMixin object's associated jobs during cascade deletion. Job
+# cannot be fast-deleted (a global pre_delete receiver forces per-instance signals), so deleting
+# in chunks bounds the work per delete cycle rather than building one huge collection and running
+# one long DELETE. 1000 matches EXPORT_CHUNK_SIZE and, in benchmarking a 200k-job deletion, was
+# the fastest of 100/1000/5000 while keeping peak memory flat. See #22812.
+JOB_DELETE_BATCH_SIZE = 1000

+ 14 - 11
netbox/netbox/context_managers.py

@@ -15,17 +15,20 @@ def event_tracking(request):
 
     :param request: WSGIRequest object with a unique `id` set
     """
-    current_request.set(request)
-    events_queue.set({})
-    query_cache.set(defaultdict(dict))
+    request_token = current_request.set(request)
+    queue_token = events_queue.set({})
+    cache_token = query_cache.set(defaultdict(dict))
 
-    yield
+    try:
+        yield
 
-    # Flush queued webhooks to RQ
-    if events := list(events_queue.get().values()):
-        flush_events(events)
+        # Flush queued webhooks to RQ. This is done only if the wrapped block completed successfully; events
+        # queued by a failed request or job must not be dispatched.
+        if events := list(events_queue.get().values()):
+            flush_events(events)
 
-    # Clear context vars
-    current_request.set(None)
-    events_queue.set({})
-    query_cache.set(None)
+    finally:
+        # Restore the previous context vars, whether or not the wrapped block raised an exception
+        current_request.reset(request_token)
+        events_queue.reset(queue_token)
+        query_cache.reset(cache_token)

+ 26 - 0
netbox/netbox/forms/bulk_import.py

@@ -1,4 +1,5 @@
 from django import forms
+from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 
@@ -70,6 +71,31 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
 
         return cleaned
 
+    def _update_errors(self, errors):
+        """Convert errors for fields absent from the form to prefixed non-field errors."""
+        if hasattr(errors, 'error_dict'):
+            remapped = []
+            passthrough = {}
+            for field, error_list in errors.error_dict.items():
+                if field == NON_FIELD_ERRORS or field in self.fields:
+                    passthrough[field] = error_list
+                else:
+                    for error in error_list:
+                        message = next(iter(error))
+                        if error.params:
+                            message = message.replace('%', '%%')
+                        remapped.append(ValidationError(
+                            '{field}: {message}'.format(field=field, message=message),
+                            code=error.code,
+                            params=error.params,
+                        ))
+            if passthrough:
+                super()._update_errors(ValidationError(passthrough))
+            for error in remapped:
+                self.add_error(None, error)
+        else:
+            super()._update_errors(errors)
+
 
 class OwnerCSVMixin(forms.Form):
     owner = CSVModelChoiceField(

+ 66 - 0
netbox/netbox/models/deletion.py

@@ -8,6 +8,72 @@ from django.utils.translation import gettext as _
 logger = logging.getLogger("netbox.models.deletion")
 
 
+class CountOnly:
+    """
+    A stand-in for a list of dependent instances that reports a count without holding any
+    instances. Used on the delete-confirmation page for high-cardinality relations (e.g. a
+    JobsMixin object's jobs) which we deliberately do not materialize (see #22812). It is a
+    lenient, empty iterable: `len()` returns the true row count, but iterating yields nothing,
+    so it slots into the same `{model: <iterable>}` mapping as real instance lists and renders
+    as a non-expandable row.
+    """
+    # Template flag: distinguishes a count-only entry (no instances to list) from a real list,
+    # so the confirmation page can render it without an expand/collapse affordance.
+    count_only = True
+
+    def __init__(self, count):
+        self.count = count
+
+    def __len__(self):
+        return self.count
+
+    def __iter__(self):
+        return iter(())
+
+
+class ConfirmCollector(Collector):
+    """
+    A display-only Collector used to enumerate the objects that would be deleted along with a
+    given object, for rendering the delete confirmation page. It behaves like Django's stock
+    Collector (preserving the full FK cascade graph and its ProtectedError/RestrictedError
+    behavior) except that it does not descend into the `jobs` GenericRelation. A JobsMixin
+    object can accumulate thousands of Jobs, each carrying large data/log_entries payloads;
+    materializing them all just to render a confirmation page can exhaust memory (see #22812).
+    Instead, the related Jobs are counted and recorded in `generic_relation_counts`.
+
+    This is intentionally specific to Job, the only high-cardinality GenericRelation in the
+    data model; it is not a general count-out over every GenericRelation. If another relation
+    ever needs the same treatment, extend the check in collect() (and the matching write-path
+    batching in JobsMixin/ScriptModule.delete) rather than assuming this already handles it.
+    """
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.generic_relation_counts = {}
+
+    def collect(self, objs, source=None, *args, **kwargs):
+        """
+        Override collect() to count the `jobs` GenericRelation rather than descend into it.
+
+        Django's Collector offers no per-relation skip hook, so we intercept the one call it
+        makes when cascading into a GenericRelation: collect(sub_objs, source=model, ...), where
+        `sub_objs` is a queryset of the related model. When that model is Job, we count the rows
+        instead of collecting (and thus instantiating) them, and forward every other call to the
+        stock implementation untouched. A directly-deleted Job (top-level call, source=None)
+        still collects normally.
+        """
+        from core.models import Job
+
+        if source is not None and getattr(objs, 'model', None) is Job:
+            # Django calls this branch for the jobs relation even when there are none; only record
+            # a count when there are actually jobs, so jobless objects don't get a spurious
+            # "0 jobs" row on the delete-confirmation page.
+            count = objs.count()
+            if count:
+                self.generic_relation_counts[Job] = self.generic_relation_counts.get(Job, 0) + count
+            return None
+        return super().collect(objs, source=source, *args, **kwargs)
+
+
 class CustomCollector(Collector):
     """
     Override Django's stock Collector to handle GenericRelations and ensure proper ordering of cascading deletions.

+ 43 - 2
netbox/netbox/models/features.py

@@ -5,7 +5,7 @@ from functools import cached_property
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
 from django.core.validators import ValidationError
-from django.db import models
+from django.db import models, router, transaction
 from django.db.models import Q
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
@@ -17,7 +17,7 @@ from extras.constants import CUSTOMFIELD_EMPTY_VALUES
 from extras.managers import NetBoxTaggableManager, NetBoxTaggableManagerField
 from extras.utils import is_taggable
 from netbox.config import get_config
-from netbox.constants import CORE_APPS
+from netbox.constants import CORE_APPS, JOB_DELETE_BATCH_SIZE
 from netbox.models.deletion import DeleteMixin
 from netbox.plugins import PluginConfig
 from netbox.registry import registry
@@ -43,6 +43,7 @@ __all__ = (
     'NotificationsMixin',
     'SyncedDataMixin',
     'TagsMixin',
+    'batch_delete_jobs',
     'get_model_features',
     'has_feature',
     'model_is_public',
@@ -456,9 +457,39 @@ class NotificationsMixin(models.Model):
         abstract = True
 
 
+def batch_delete_jobs(job_queryset):
+    """
+    Delete the Jobs in `job_queryset` in JOB_DELETE_BATCH_SIZE chunks. Job cannot be fast-deleted
+    (a global pre_delete receiver forces per-instance signals), so a single delete would build one
+    huge collection of Job instances and run one very long DELETE; batching bounds the per-cycle
+    work. Callers are responsible for wrapping this in a transaction. As with the prior cascade
+    behavior, this bulk delete does not invoke Job.delete() and therefore does not cancel the
+    backing RQ job. See #22812.
+    """
+    from core.models import Job
+
+    # Route writes to the same database the queryset reads from. In JobsMixin.delete the queryset
+    # is bound to the instance's DB while Job.objects would otherwise use the router default; if
+    # those diverge the deleted rows never leave the read side and the loop below never terminates.
+    jobs = Job.objects.using(job_queryset.db)
+
+    job_pks = job_queryset.order_by('pk').values_list('pk', flat=True)
+    # Re-slice the queryset each iteration: it re-queries after each batch delete, so the
+    # remaining set shrinks and the loop terminates (do not hoist this into a cursor).
+    while pks := list(job_pks[:JOB_DELETE_BATCH_SIZE]):
+        # only('pk'): the batch still can't fast-delete, so each Job in the batch is instantiated;
+        # loading just the PK avoids pulling the large data/log_entries payloads into memory.
+        jobs.filter(pk__in=pks).only('pk').delete()
+
+
 class JobsMixin(models.Model):
     """
     Enables support for job results.
+
+    Note: for the job-batching in delete() to run, JobsMixin must precede DeleteMixin in a
+    model's MRO. DeleteMixin.delete() drives its own collector and does not call super(), so a
+    model declared as e.g. `class Foo(NetBoxModel, JobsMixin)` would reach DeleteMixin first and
+    bypass the batching. Core models that combine both (e.g. DataSource) list JobsMixin first.
     """
     jobs = GenericRelation(
         to='core.Job',
@@ -470,6 +501,16 @@ class JobsMixin(models.Model):
     class Meta:
         abstract = True
 
+    def delete(self, using=None, *args, **kwargs):
+        # Delete associated jobs in batches so the cascade never has to load thousands of Job
+        # rows into memory at once. Wrapped in a transaction so that a failure in the parent
+        # delete rolls the job deletions back as well. See #22812.
+        using = using or router.db_for_write(self.__class__, instance=self)
+        with transaction.atomic(using=using):
+            batch_delete_jobs(self.jobs.using(using))
+            return super().delete(using, *args, **kwargs)
+    delete.alters_data = True
+
     def get_latest_jobs(self):
         """
         Return a list of the most recent jobs for this instance.

+ 0 - 4
netbox/netbox/settings.py

@@ -653,10 +653,6 @@ SERIALIZATION_MODULES = {
 
 DEBUG_TOOLBAR_CONFIG = {
     'SHOW_TOOLBAR_CALLBACK': 'utilities.debug.show_toolbar',
-    # The GraphiQL integration provided by strawberry-django locates the toolbar via
-    # document.getElementById('djDebug'), which fails when the toolbar is rendered inside a
-    # shadow root (the default as of django-debug-toolbar v7.0).
-    'USE_SHADOW_DOM': False,
 }
 
 

+ 87 - 0
netbox/netbox/tests/test_context_managers.py

@@ -0,0 +1,87 @@
+import uuid
+from unittest.mock import patch
+
+from django.test import RequestFactory, SimpleTestCase
+
+from netbox.context import current_request, events_queue, query_cache
+from netbox.context_managers import event_tracking
+
+
+def _build_request():
+    request = RequestFactory().get('/')
+    request.id = uuid.uuid4()
+    request.user = None
+    return request
+
+
+class EventTrackingTestCase(SimpleTestCase):
+    """
+    Verify that event_tracking() populates and restores its context variables.
+    """
+    def assertContextVarsRestored(self):
+        self.assertIsNone(current_request.get())
+        self.assertEqual(events_queue.get(), {})
+        self.assertIsNone(query_cache.get())
+
+    def test_context_vars_set_within_block(self):
+        request = _build_request()
+
+        with event_tracking(request):
+            self.assertIs(current_request.get(), request)
+            self.assertEqual(events_queue.get(), {})
+            self.assertIsNotNone(query_cache.get())
+
+        self.assertContextVarsRestored()
+
+    def test_context_vars_restored_after_exception(self):
+        request = _build_request()
+
+        with self.assertRaises(RuntimeError):
+            with event_tracking(request):
+                raise RuntimeError('simulated view/script failure')
+
+        self.assertContextVarsRestored()
+
+    def test_events_flushed_on_success(self):
+        request = _build_request()
+
+        with patch('netbox.context_managers.flush_events') as flush_events:
+            with event_tracking(request):
+                events_queue.get()['foo'] = 'bar'
+
+        flush_events.assert_called_once_with(['bar'])
+
+    def test_events_not_flushed_after_exception(self):
+        request = _build_request()
+
+        with patch('netbox.context_managers.flush_events') as flush_events:
+            with self.assertRaises(RuntimeError):
+                with event_tracking(request):
+                    events_queue.get()['foo'] = 'bar'
+                    raise RuntimeError('simulated view/script failure')
+
+        flush_events.assert_not_called()
+
+    def test_nested_context_restores_outer_values(self):
+        outer_request = _build_request()
+        inner_request = _build_request()
+
+        with patch('netbox.context_managers.flush_events'):
+            with event_tracking(outer_request):
+                outer_cache = query_cache.get()
+                outer_queue = events_queue.get()
+                outer_queue['outer'] = 'event'
+
+                with event_tracking(inner_request):
+                    self.assertIs(current_request.get(), inner_request)
+                    self.assertIsNot(events_queue.get(), outer_queue)
+                    self.assertEqual(events_queue.get(), {})
+
+                # The outer request's context must be restored intact, including any events it had
+                # already queued
+                self.assertIs(current_request.get(), outer_request)
+                self.assertIs(query_cache.get(), outer_cache)
+                self.assertIs(events_queue.get(), outer_queue)
+                self.assertEqual(events_queue.get(), {'outer': 'event'})
+
+        self.assertContextVarsRestored()

+ 103 - 6
netbox/netbox/tests/test_forms.py

@@ -1,4 +1,9 @@
+from unittest.mock import patch
+
 from django import forms
+from django.core.exceptions import NON_FIELD_ERRORS
+from django.core.exceptions import ValidationError as DjangoValidationError
+from django.core.validators import MaxLengthValidator
 from django.forms.models import ALL_FIELDS
 from django.test import TestCase
 
@@ -22,12 +27,8 @@ from vpn.forms import TunnelCreateForm, TunnelTerminationForm
 from wireless.forms import WirelessLANForm
 
 
-class NetBoxModelImportFormCleanTestCase(TestCase):
-    """
-    Test the clean() method of NetBoxModelImportForm to ensure it properly converts
-    empty strings to None for nullable fields during CSV import.
-    Uses InterfaceImportForm as the concrete implementation to test.
-    """
+class NetBoxModelImportFormTestCase(TestCase):
+    """Test NetBoxModelImportForm."""
 
     @classmethod
     def setUpTestData(cls):
@@ -319,6 +320,102 @@ class NetBoxModelImportFormCleanTestCase(TestCase):
         self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
         self.assertIsNone(form.cleaned_data['wwn'])
 
+    def test_missing_field_validation_error_becomes_non_field_error(self):
+        """Convert validation errors for absent fields to non-field errors."""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Test Interface',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+            }
+        )
+        with patch.object(
+            form.instance,
+            'full_clean',
+            side_effect=DjangoValidationError({'absent_field': ['Field error.']}),
+        ):
+            result = form.is_valid()
+
+        self.assertFalse(result)
+        self.assertIn('absent_field: Field error.', form.non_field_errors())
+
+    def test_non_field_error_not_overwritten_by_remapped_missing_field_error(self):
+        """Preserve remapped and existing non-field errors."""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Test Interface Mixed',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        # absent_field appears first to expose the former overwrite bug
+        form._update_errors(DjangoValidationError({
+            'absent_field': ['Field error.'],
+            NON_FIELD_ERRORS: ['A general error.'],
+        }))
+        non_field_errors = form.non_field_errors()
+        self.assertIn('A general error.', non_field_errors)
+        self.assertIn('absent_field: Field error.', non_field_errors)
+
+    def test_remapped_error_preserves_code_and_params(self):
+        """Preserve the original ValidationError code and params when remapping."""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Test Interface Params',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        form._update_errors(DjangoValidationError({
+            'absent_field': [
+                DjangoValidationError(
+                    '%(value)s is not a valid value.',
+                    code='invalid_value',
+                    params={'value': '100%'},
+                ),
+            ],
+        }))
+        non_field_errors = form.non_field_errors()
+        self.assertIn(
+            'absent_field: 100% is not a valid value.',
+            non_field_errors,
+        )
+        error_data = form.errors[NON_FIELD_ERRORS].as_data()
+        matching = [error for error in error_data if error.code == 'invalid_value']
+        self.assertEqual(len(matching), 1)
+        self.assertEqual(matching[0].params, {'value': '100%'})
+
+    def test_remapped_error_preserves_pluralized_message(self):
+        """Preserve pluralization order when remapping an ngettext_lazy message."""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Test Interface Plural',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        form._update_errors(DjangoValidationError({
+            'absent_field': [
+                DjangoValidationError(
+                    MaxLengthValidator.message,
+                    code=MaxLengthValidator.code,
+                    params={'limit_value': 20, 'show_value': 25},
+                ),
+            ],
+        }))
+        non_field_errors = form.non_field_errors()
+        self.assertIn(
+            'absent_field: Ensure this value has at most 20 characters (it has 25).',
+            non_field_errors,
+        )
+        error_data = form.errors[NON_FIELD_ERRORS].as_data()
+        matching = [error for error in error_data if error.code == MaxLengthValidator.code]
+        self.assertEqual(len(matching), 1)
+        self.assertEqual(matching[0].params, {'limit_value': 20, 'show_value': 25})
+
 
 class HTMXPartialSwapRenderingTestCase(TestCase):
     """Ensure each form field's HTMX swap wiring is present on the widget bound to the field."""

+ 16 - 5
netbox/netbox/views/generic/object_views.py

@@ -4,7 +4,6 @@ from collections import defaultdict
 from django.contrib import messages
 from django.db import router, transaction
 from django.db.models import ProtectedError, RestrictedError
-from django.db.models.deletion import Collector
 from django.http import HttpResponse
 from django.shortcuts import redirect, render
 from django.urls import reverse
@@ -13,6 +12,7 @@ from django.utils.safestring import mark_safe
 from django.utils.translation import gettext as _
 
 from core.signals import clear_events
+from netbox.models.deletion import ConfirmCollector, CountOnly
 from netbox.object_actions import BulkDelete, BulkEdit, CloneObject, DeleteObject, EditObject
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortRequest, PermissionsViolation
@@ -385,14 +385,19 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
 
     def _get_dependent_objects(self, obj):
         """
-        Returns a dictionary mapping of dependent objects (organized by model) which will be deleted as a result of
-        deleting the requested object.
+        Returns a dictionary mapping each dependent model to the objects (of that model) which will
+        be deleted as a result of deleting the requested object.
+
+        Values are normally a list of instances. For high-cardinality relations that we do not
+        materialize to avoid excessive memory use (currently a JobsMixin object's jobs, see
+        #22812), the value is a `CountOnly` — a lenient empty iterable whose `len()` is the true
+        row count, so it renders as a non-expandable row alongside the itemized relations.
 
         Args:
             obj: The object to return dependent objects for
         """
         using = router.db_for_write(obj._meta.model)
-        collector = Collector(using=using)
+        collector = ConfirmCollector(using=using)
         collector.collect([obj])
 
         # Compile a mapping of models to instances
@@ -406,7 +411,13 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
                 continue
             dependent_objects[model].append(instances)
 
-        return dict(dependent_objects)
+        # Add count-only entries for relations the collector enumerated by count rather than by
+        # instance (e.g. jobs), so they render as non-expandable rows in the same mapping.
+        dependent_objects = dict(dependent_objects)
+        for model, count in collector.generic_relation_counts.items():
+            dependent_objects[model] = CountOnly(count)
+
+        return dependent_objects
 
     def _handle_protected_objects(self, obj, protected_objects, request, exc):
         """

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


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

@@ -32,7 +32,7 @@
     "htmx.org": "2.0.10",
     "markdown-it": "^14.3.0",
     "picomatch": "4.0.5",
-    "query-string": "9.4.1",
+    "query-string": "9.5.0",
     "sass": "1.102.0",
     "tom-select": "2.6.2",
     "typeface-inter": "3.18.1",
@@ -45,17 +45,17 @@
     "@types/bootstrap": "5.2.11",
     "@types/cookie": "^1.0.0",
     "@types/node": "^24.10.1",
-    "@typescript-eslint/eslint-plugin": "^8.65.0",
-    "@typescript-eslint/parser": "^8.65.0",
-    "esbuild": "^0.28.1",
+    "@typescript-eslint/eslint-plugin": "^8.67.0",
+    "@typescript-eslint/parser": "^8.67.0",
+    "esbuild": "^0.28.2",
     "esbuild-sass-plugin": "^3.7.0",
-    "eslint": "^10.8.0",
+    "eslint": "^10.8.1",
     "eslint-config-prettier": "^10.1.8",
     "eslint-import-resolver-typescript": "^4.4.5",
     "eslint-plugin-import": "^2.32.0",
     "eslint-plugin-prettier": "^5.5.6",
-    "globals": "^17.8.0",
-    "prettier": "^3.9.5",
+    "globals": "^17.9.0",
+    "prettier": "^3.9.6",
     "typescript": "^5.9.3"
   },
   "resolutions": {

+ 243 - 243
netbox/project-static/yarn.lock

@@ -24,135 +24,135 @@
   dependencies:
     tslib "^2.4.0"
 
-"@esbuild/aix-ppc64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be"
-  integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==
-
-"@esbuild/android-arm64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a"
-  integrity sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==
-
-"@esbuild/android-arm@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f"
-  integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==
-
-"@esbuild/android-x64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e"
-  integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==
-
-"@esbuild/darwin-arm64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54"
-  integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==
-
-"@esbuild/darwin-x64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772"
-  integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==
-
-"@esbuild/freebsd-arm64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6"
-  integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==
-
-"@esbuild/freebsd-x64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3"
-  integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==
-
-"@esbuild/linux-arm64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717"
-  integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==
-
-"@esbuild/linux-arm@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c"
-  integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==
-
-"@esbuild/linux-ia32@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3"
-  integrity sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==
-
-"@esbuild/linux-loong64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b"
-  integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==
-
-"@esbuild/linux-mips64el@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8"
-  integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==
-
-"@esbuild/linux-ppc64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d"
-  integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==
-
-"@esbuild/linux-riscv64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08"
-  integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==
-
-"@esbuild/linux-s390x@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc"
-  integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==
-
-"@esbuild/linux-x64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd"
-  integrity sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==
-
-"@esbuild/netbsd-arm64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36"
-  integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==
-
-"@esbuild/netbsd-x64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347"
-  integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==
-
-"@esbuild/openbsd-arm64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2"
-  integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==
-
-"@esbuild/openbsd-x64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c"
-  integrity sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==
-
-"@esbuild/openharmony-arm64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097"
-  integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==
-
-"@esbuild/sunos-x64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a"
-  integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==
-
-"@esbuild/win32-arm64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4"
-  integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==
-
-"@esbuild/win32-ia32@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6"
-  integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==
-
-"@esbuild/win32-x64@0.28.1":
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12"
-  integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==
+"@esbuild/aix-ppc64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz#bf6e10303bcf2e7c686975fa52f937ec2728d8bc"
+  integrity sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==
+
+"@esbuild/android-arm64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz#0c6246bc8d2c4d172aac2db3fb1190d72bd65504"
+  integrity sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==
+
+"@esbuild/android-arm@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz#2d84ece6a4e2684d92be26ee13d42757d831c381"
+  integrity sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==
+
+"@esbuild/android-x64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz#fc38d4d6358d8dc1cf53f09f7589fe436eb64801"
+  integrity sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==
+
+"@esbuild/darwin-arm64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz#f83afeeac1d7dac01c7a2fd012b3e451a0591fcc"
+  integrity sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==
+
+"@esbuild/darwin-x64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz#510147c055a795588dbbe14fd6b1b8ad0a2f30de"
+  integrity sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==
+
+"@esbuild/freebsd-arm64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz#093b9200ecf0b115ba4e5e248a7485c9c5f8bd5e"
+  integrity sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==
+
+"@esbuild/freebsd-x64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz#0be22b6df925d213e841ea87123af5df80b0faf7"
+  integrity sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==
+
+"@esbuild/linux-arm64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz#1bdbc651cda9ba9995c53ed9c71ceaa65094762d"
+  integrity sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==
+
+"@esbuild/linux-arm@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz#beb12ad72b84f72d28488cc1b8ee9f7eb141d753"
+  integrity sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==
+
+"@esbuild/linux-ia32@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz#b81f9d55529b45c206a46a138214b1aa6879696b"
+  integrity sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==
+
+"@esbuild/linux-loong64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz#598667241a04c99b76ed6ef940ac50038c419f98"
+  integrity sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==
+
+"@esbuild/linux-mips64el@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz#1c51eb9cea903f53d97b5af3b1841db70f5596ca"
+  integrity sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==
+
+"@esbuild/linux-ppc64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz#63dd61f17ceb31a81227f413feac8a71bc2c51f2"
+  integrity sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==
+
+"@esbuild/linux-riscv64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz#3763b08fde5cf25ab1facb8e7752edfe45fbfc27"
+  integrity sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==
+
+"@esbuild/linux-s390x@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz#1a137ff293a82906eb3176385bd7e8e0e5cfb7cb"
+  integrity sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==
+
+"@esbuild/linux-x64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz#268b36211c146ca54f8fe12c578a8d6ef8979485"
+  integrity sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==
+
+"@esbuild/netbsd-arm64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz#22571ad951d62bb6accc82d8d1fad5c8c1ac0ba1"
+  integrity sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==
+
+"@esbuild/netbsd-x64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz#42fcc57297eb0a0ca3f5fc475291f4c1a3f7c0de"
+  integrity sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==
+
+"@esbuild/openbsd-arm64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz#9eb32af104ac3dacf4edca01f596664aab0c73ef"
+  integrity sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==
+
+"@esbuild/openbsd-x64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz#febed2402d6088225e91f20fb4ce2522ad0a4efd"
+  integrity sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==
+
+"@esbuild/openharmony-arm64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz#85641c3d466428bfbccea5f21c26836663fef5ce"
+  integrity sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==
+
+"@esbuild/sunos-x64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz#a736f9d8962481045fc4c3e54f5479f22c870fb4"
+  integrity sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==
+
+"@esbuild/win32-arm64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz#ee5ab40fad186201b652a33f8a5eb149e9e42532"
+  integrity sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==
+
+"@esbuild/win32-ia32@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz#c40d28a6d99a127da6711f2afd74b11cb63b06a7"
+  integrity sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==
+
+"@esbuild/win32-x64@0.28.2":
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87"
+  integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==
 
 "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1":
   version "4.9.1"
@@ -917,100 +917,100 @@
   dependencies:
     "@types/estree" "*"
 
-"@typescript-eslint/eslint-plugin@^8.65.0":
-  version "8.65.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz#0a58df6fea8c0bf6b396f518077099bc8b762bb5"
-  integrity sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==
+"@typescript-eslint/eslint-plugin@^8.67.0":
+  version "8.67.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz#52f9f0e47d5a7571c4336e69bfeea581509ef2cf"
+  integrity sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==
   dependencies:
     "@eslint-community/regexpp" "^4.12.2"
-    "@typescript-eslint/scope-manager" "8.65.0"
-    "@typescript-eslint/type-utils" "8.65.0"
-    "@typescript-eslint/utils" "8.65.0"
-    "@typescript-eslint/visitor-keys" "8.65.0"
+    "@typescript-eslint/scope-manager" "8.67.0"
+    "@typescript-eslint/type-utils" "8.67.0"
+    "@typescript-eslint/utils" "8.67.0"
+    "@typescript-eslint/visitor-keys" "8.67.0"
     ignore "^7.0.5"
     natural-compare "^1.4.0"
     ts-api-utils "^2.5.0"
 
-"@typescript-eslint/parser@^8.65.0":
-  version "8.65.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.65.0.tgz#5295c1058c0a1dd746ef28baaf9c0341dbdf03dc"
-  integrity sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==
+"@typescript-eslint/parser@^8.67.0":
+  version "8.67.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.67.0.tgz#0158022ec9927e0afcd58a8cc2ad57e01d892f5c"
+  integrity sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==
   dependencies:
-    "@typescript-eslint/scope-manager" "8.65.0"
-    "@typescript-eslint/types" "8.65.0"
-    "@typescript-eslint/typescript-estree" "8.65.0"
-    "@typescript-eslint/visitor-keys" "8.65.0"
+    "@typescript-eslint/scope-manager" "8.67.0"
+    "@typescript-eslint/types" "8.67.0"
+    "@typescript-eslint/typescript-estree" "8.67.0"
+    "@typescript-eslint/visitor-keys" "8.67.0"
     debug "^4.4.3"
 
-"@typescript-eslint/project-service@8.65.0":
-  version "8.65.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.65.0.tgz#65fbbc9a1591abffaeab5513200f848271cb0aa5"
-  integrity sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==
+"@typescript-eslint/project-service@8.67.0":
+  version "8.67.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.67.0.tgz#1552db007ca9206a1c6c7acf49e210bd17a8c56f"
+  integrity sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==
   dependencies:
-    "@typescript-eslint/tsconfig-utils" "^8.65.0"
-    "@typescript-eslint/types" "^8.65.0"
+    "@typescript-eslint/tsconfig-utils" "^8.67.0"
+    "@typescript-eslint/types" "^8.67.0"
     debug "^4.4.3"
 
-"@typescript-eslint/scope-manager@8.65.0":
-  version "8.65.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz#9547202ce7e608e7b6283df585703b980a0ea70d"
-  integrity sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==
+"@typescript-eslint/scope-manager@8.67.0":
+  version "8.67.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz#4d4c2da09560d10dd7d947cba2d29d14d25af16d"
+  integrity sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==
   dependencies:
-    "@typescript-eslint/types" "8.65.0"
-    "@typescript-eslint/visitor-keys" "8.65.0"
+    "@typescript-eslint/types" "8.67.0"
+    "@typescript-eslint/visitor-keys" "8.67.0"
 
-"@typescript-eslint/tsconfig-utils@8.65.0", "@typescript-eslint/tsconfig-utils@^8.65.0":
-  version "8.65.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz#36f168fcdbb1295f7446ff0379667f98c3cf1bf3"
-  integrity sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==
+"@typescript-eslint/tsconfig-utils@8.67.0", "@typescript-eslint/tsconfig-utils@^8.67.0":
+  version "8.67.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz#f45a3eba6b9132fb47141ec03ce2f275f1ea991d"
+  integrity sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==
 
-"@typescript-eslint/type-utils@8.65.0":
-  version "8.65.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz#d316d7522d93cff4cd14f305e02f3df2d804f9c1"
-  integrity sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==
+"@typescript-eslint/type-utils@8.67.0":
+  version "8.67.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz#96bed105275559df3bcf0449b73a6414d35c59ce"
+  integrity sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==
   dependencies:
-    "@typescript-eslint/types" "8.65.0"
-    "@typescript-eslint/typescript-estree" "8.65.0"
-    "@typescript-eslint/utils" "8.65.0"
+    "@typescript-eslint/types" "8.67.0"
+    "@typescript-eslint/typescript-estree" "8.67.0"
+    "@typescript-eslint/utils" "8.67.0"
     debug "^4.4.3"
     ts-api-utils "^2.5.0"
 
-"@typescript-eslint/types@8.65.0", "@typescript-eslint/types@^8.65.0":
-  version "8.65.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.65.0.tgz#3e86738416a777c8b8925ab46745f48ecf904c9f"
-  integrity sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==
+"@typescript-eslint/types@8.67.0", "@typescript-eslint/types@^8.67.0":
+  version "8.67.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.67.0.tgz#4a8d00cc1faba5c14feabc60f85b7a32652f34b6"
+  integrity sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==
 
-"@typescript-eslint/typescript-estree@8.65.0":
-  version "8.65.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz#f1f514808f6aa713e2d678ae8ff592a65e1632af"
-  integrity sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==
+"@typescript-eslint/typescript-estree@8.67.0":
+  version "8.67.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz#116c3a47c06119c5a050e8851861d6497dd64bc2"
+  integrity sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==
   dependencies:
-    "@typescript-eslint/project-service" "8.65.0"
-    "@typescript-eslint/tsconfig-utils" "8.65.0"
-    "@typescript-eslint/types" "8.65.0"
-    "@typescript-eslint/visitor-keys" "8.65.0"
+    "@typescript-eslint/project-service" "8.67.0"
+    "@typescript-eslint/tsconfig-utils" "8.67.0"
+    "@typescript-eslint/types" "8.67.0"
+    "@typescript-eslint/visitor-keys" "8.67.0"
     debug "^4.4.3"
     minimatch "^10.2.2"
     semver "^7.7.3"
     tinyglobby "^0.2.15"
     ts-api-utils "^2.5.0"
 
-"@typescript-eslint/utils@8.65.0":
-  version "8.65.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.65.0.tgz#afedd974a0c8deeef553b509df5800bafd615a72"
-  integrity sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==
+"@typescript-eslint/utils@8.67.0":
+  version "8.67.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.67.0.tgz#3e478a3d69d330a1fc50c12746cc2ee0732ccfcd"
+  integrity sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==
   dependencies:
     "@eslint-community/eslint-utils" "^4.9.1"
-    "@typescript-eslint/scope-manager" "8.65.0"
-    "@typescript-eslint/types" "8.65.0"
-    "@typescript-eslint/typescript-estree" "8.65.0"
+    "@typescript-eslint/scope-manager" "8.67.0"
+    "@typescript-eslint/types" "8.67.0"
+    "@typescript-eslint/typescript-estree" "8.67.0"
 
-"@typescript-eslint/visitor-keys@8.65.0":
-  version "8.65.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz#e3704c13cb4a1c22454c1abf28ff4737e15018c6"
-  integrity sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==
+"@typescript-eslint/visitor-keys@8.67.0":
+  version "8.67.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz#601d40af9acf82a28da2286f3edafc69bba9017f"
+  integrity sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==
   dependencies:
-    "@typescript-eslint/types" "8.65.0"
+    "@typescript-eslint/types" "8.67.0"
     eslint-visitor-keys "^5.0.0"
 
 "@unrs/resolver-binding-android-arm-eabi@1.11.1":
@@ -1478,10 +1478,10 @@ debug@^4.3.1, debug@^4.3.2, debug@^4.4.1, debug@^4.4.3:
   dependencies:
     ms "^2.1.3"
 
-decode-uri-component@^0.4.1:
-  version "0.4.1"
-  resolved "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz"
-  integrity sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==
+decode-uri-component@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.5.0.tgz#4592fa1e1d640ec5e2760e2e168ad2ab5f2c9da1"
+  integrity sha512-1BiQVoK8C9gUbQU6NzAtO/tkz2qOFpEObMWpcFvhx4fYnj4Oc5yzaJN/LD36ihkVUdXyh5ZekzX+yM+ty/SrPg==
 
 deep-is@^0.1.3:
   version "0.1.4"
@@ -1744,37 +1744,37 @@ esbuild-sass-plugin@^3.7.0:
     resolve "^1.22.11"
     sass "^1.97.3"
 
-esbuild@^0.28.1:
-  version "0.28.1"
-  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578"
-  integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==
+esbuild@^0.28.2:
+  version "0.28.2"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816"
+  integrity sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==
   optionalDependencies:
-    "@esbuild/aix-ppc64" "0.28.1"
-    "@esbuild/android-arm" "0.28.1"
-    "@esbuild/android-arm64" "0.28.1"
-    "@esbuild/android-x64" "0.28.1"
-    "@esbuild/darwin-arm64" "0.28.1"
-    "@esbuild/darwin-x64" "0.28.1"
-    "@esbuild/freebsd-arm64" "0.28.1"
-    "@esbuild/freebsd-x64" "0.28.1"
-    "@esbuild/linux-arm" "0.28.1"
-    "@esbuild/linux-arm64" "0.28.1"
-    "@esbuild/linux-ia32" "0.28.1"
-    "@esbuild/linux-loong64" "0.28.1"
-    "@esbuild/linux-mips64el" "0.28.1"
-    "@esbuild/linux-ppc64" "0.28.1"
-    "@esbuild/linux-riscv64" "0.28.1"
-    "@esbuild/linux-s390x" "0.28.1"
-    "@esbuild/linux-x64" "0.28.1"
-    "@esbuild/netbsd-arm64" "0.28.1"
-    "@esbuild/netbsd-x64" "0.28.1"
-    "@esbuild/openbsd-arm64" "0.28.1"
-    "@esbuild/openbsd-x64" "0.28.1"
-    "@esbuild/openharmony-arm64" "0.28.1"
-    "@esbuild/sunos-x64" "0.28.1"
-    "@esbuild/win32-arm64" "0.28.1"
-    "@esbuild/win32-ia32" "0.28.1"
-    "@esbuild/win32-x64" "0.28.1"
+    "@esbuild/aix-ppc64" "0.28.2"
+    "@esbuild/android-arm" "0.28.2"
+    "@esbuild/android-arm64" "0.28.2"
+    "@esbuild/android-x64" "0.28.2"
+    "@esbuild/darwin-arm64" "0.28.2"
+    "@esbuild/darwin-x64" "0.28.2"
+    "@esbuild/freebsd-arm64" "0.28.2"
+    "@esbuild/freebsd-x64" "0.28.2"
+    "@esbuild/linux-arm" "0.28.2"
+    "@esbuild/linux-arm64" "0.28.2"
+    "@esbuild/linux-ia32" "0.28.2"
+    "@esbuild/linux-loong64" "0.28.2"
+    "@esbuild/linux-mips64el" "0.28.2"
+    "@esbuild/linux-ppc64" "0.28.2"
+    "@esbuild/linux-riscv64" "0.28.2"
+    "@esbuild/linux-s390x" "0.28.2"
+    "@esbuild/linux-x64" "0.28.2"
+    "@esbuild/netbsd-arm64" "0.28.2"
+    "@esbuild/netbsd-x64" "0.28.2"
+    "@esbuild/openbsd-arm64" "0.28.2"
+    "@esbuild/openbsd-x64" "0.28.2"
+    "@esbuild/openharmony-arm64" "0.28.2"
+    "@esbuild/sunos-x64" "0.28.2"
+    "@esbuild/win32-arm64" "0.28.2"
+    "@esbuild/win32-ia32" "0.28.2"
+    "@esbuild/win32-x64" "0.28.2"
 
 escape-string-regexp@^4.0.0:
   version "4.0.0"
@@ -1881,10 +1881,10 @@ eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1:
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
   integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
 
-eslint@^10.8.0:
-  version "10.8.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.8.0.tgz#e6d19907a3f090a53a022261ba34c5ff6d04908b"
-  integrity sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==
+eslint@^10.8.1:
+  version "10.8.1"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.8.1.tgz#fb37d514c19b6dd5b2d6b70169fd26fddfa97967"
+  integrity sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==
   dependencies:
     "@eslint-community/eslint-utils" "^4.8.0"
     "@eslint-community/regexpp" "^4.12.2"
@@ -2168,10 +2168,10 @@ globals@^14.0.0:
   resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"
   integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
 
-globals@^17.8.0:
-  version "17.8.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-17.8.0.tgz#a1f213a06adcd0eec38004c5cd39fef7af7e0830"
-  integrity sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==
+globals@^17.9.0:
+  version "17.9.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-17.9.0.tgz#e43f252d6bbe71508da43902a1709c8895a59f70"
+  integrity sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==
 
 globalthis@^1.0.3, globalthis@^1.0.4:
   version "1.0.4"
@@ -2964,10 +2964,10 @@ prettier-linter-helpers@^1.0.1:
   dependencies:
     fast-diff "^1.1.2"
 
-prettier@^3.9.5:
-  version "3.9.5"
-  resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.5.tgz#4fec97736e33b9d0b620b48914fe93b530e835ad"
-  integrity sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==
+prettier@^3.9.6:
+  version "3.9.6"
+  resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.6.tgz#b3ea5146515d40fc53f18aa63f74dfab1e10dbf6"
+  integrity sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==
 
 punycode.js@^2.3.1:
   version "2.3.1"
@@ -2979,12 +2979,12 @@ punycode@^2.1.0:
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
   integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
 
-query-string@9.4.1:
-  version "9.4.1"
-  resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.4.1.tgz#fc5b1cc5c1944eaa1c8e6d4912b5bb90dc66fa2d"
-  integrity sha512-lSyJeN3RuaG7DZGWThtYRhk96+kEyZ/+doZpERuWbjeFL+Ok3vEat/swU498rAI0NcVt5/RJp8UDuLz7FckxrA==
+query-string@9.5.0:
+  version "9.5.0"
+  resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.5.0.tgz#e6f4003dcb321580dd043109b6fbe2a3890afff6"
+  integrity sha512-YlJmwNyi0RGYjlxYcuDncMsxFU7YyutbuI7gTm8ySxIGBlwx5yiBCOD5ig9ZNoHkawk/1Dey0N5mEfcUybMVAA==
   dependencies:
-    decode-uri-component "^0.4.1"
+    decode-uri-component "^0.5.0"
     filter-obj "^5.1.0"
     split-on-first "^3.0.0"
 

+ 1 - 2
netbox/release.yaml

@@ -1,4 +1,3 @@
 version: "4.6.8"
 edition: "Community"
-build: "rc2"
-published: "2026-08-04"
+published: "2026-08-11"

+ 30 - 23
netbox/templates/htmx/delete_form.html

@@ -24,31 +24,38 @@
       </p>
       <div class="accordion mb-3" id="deleteAccordion">
         {% for model, instances in dependent_objects.items %}
-          <div class="accordion-item">
-            <h2 class="accordion-header h4" id="deleteheading{{ forloop.counter }}">
-              <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}" aria-expanded="false" aria-controls="collapse{{ forloop.counter }}">
-                {% with object_count=instances|length %}
-                  {{ object_count }}
-                  {% if object_count == 1 %}
-                    {{ model|meta:"verbose_name" }}
-                  {% else %}
-                    {{ model|meta:"verbose_name_plural" }}
-                  {% endif %}
-                {% endwith %}
-              </button>
-            </h2>
-            <div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse" aria-labelledby="deleteheading{{ forloop.counter }}" data-bs-parent="#deleteAccordion">
-              <div class="accordion-body p-0">
-                <div class="list-group list-group-flush">
-                  {% for instance in instances %}
-                    {% with url=instance.get_absolute_url %}
-                      <a {% if url %}href="{{ url }}" {% endif %}class="list-group-item list-group-item-action">{{ instance }}</a>
-                    {% endwith %}
-                  {% endfor %}
+          {% with object_count=instances|length %}
+            <div class="accordion-item">
+              <h2 class="accordion-header h4" id="deleteheading{{ forloop.counter }}">
+                {# High-cardinality relations (e.g. jobs) are summarized by count and are not #}
+                {# expandable, since their instances are intentionally not loaded (see #22812). #}
+                {% if instances.count_only %}
+                  <div class="accordion-button collapsed" style="cursor: default;">
+                    {{ object_count }}
+                    {% if object_count == 1 %}{{ model|meta:"verbose_name" }}{% else %}{{ model|meta:"verbose_name_plural" }}{% endif %}
+                  </div>
+                {% else %}
+                  <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}" aria-expanded="false" aria-controls="collapse{{ forloop.counter }}">
+                    {{ object_count }}
+                    {% if object_count == 1 %}{{ model|meta:"verbose_name" }}{% else %}{{ model|meta:"verbose_name_plural" }}{% endif %}
+                  </button>
+                {% endif %}
+              </h2>
+              {% if not instances.count_only %}
+                <div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse" aria-labelledby="deleteheading{{ forloop.counter }}" data-bs-parent="#deleteAccordion">
+                  <div class="accordion-body p-0">
+                    <div class="list-group list-group-flush">
+                      {% for instance in instances %}
+                        {% with url=instance.get_absolute_url %}
+                          <a {% if url %}href="{{ url }}" {% endif %}class="list-group-item list-group-item-action">{{ instance }}</a>
+                        {% endwith %}
+                      {% endfor %}
+                    </div>
+                  </div>
                 </div>
-              </div>
+              {% endif %}
             </div>
-          </div>
+          {% endwith %}
         {% endfor %}
       </div>
     {% endif %}

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


Разница между файлами не показана из-за своего большого размера
+ 156 - 156
netbox/translations/cs/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 156 - 156
netbox/translations/da/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 156 - 156
netbox/translations/de/LC_MESSAGES/django.po


+ 61 - 45
netbox/translations/en/LC_MESSAGES/django.po

@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-08-07 05:30+0000\n"
+"POT-Creation-Date: 2026-08-13 05:30+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -1443,7 +1443,7 @@ msgstr ""
 #: netbox/extras/models/models.py:183 netbox/extras/models/models.py:361
 #: netbox/extras/models/models.py:467 netbox/extras/models/models.py:529
 #: netbox/extras/models/models.py:619 netbox/extras/models/models.py:761
-#: netbox/extras/models/notifications.py:127 netbox/extras/models/scripts.py:29
+#: netbox/extras/models/notifications.py:127 netbox/extras/models/scripts.py:30
 #: netbox/ipam/models/asns.py:18 netbox/ipam/models/fhrp.py:24
 #: netbox/ipam/models/services.py:63 netbox/ipam/models/services.py:91
 #: netbox/ipam/models/vlans.py:42 netbox/ipam/models/vlans.py:231
@@ -2333,7 +2333,7 @@ msgstr ""
 #: netbox/core/models/files.py:30 netbox/core/models/jobs.py:60
 #: netbox/extras/models/models.py:974 netbox/extras/models/notifications.py:39
 #: netbox/extras/models/notifications.py:196
-#: netbox/netbox/models/features.py:62 netbox/users/models/tokens.py:51
+#: netbox/netbox/models/features.py:63 netbox/users/models/tokens.py:51
 msgid "created"
 msgstr ""
 
@@ -2456,7 +2456,7 @@ msgid ""
 msgstr ""
 
 #: netbox/core/models/data.py:294 netbox/core/models/files.py:34
-#: netbox/netbox/models/features.py:68
+#: netbox/netbox/models/features.py:69
 msgid "last updated"
 msgstr ""
 
@@ -4197,7 +4197,7 @@ msgstr ""
 #: netbox/extras/forms/model_forms.py:694
 #: netbox/extras/forms/model_forms.py:779
 #: netbox/extras/forms/model_forms.py:831 netbox/extras/ui/panels.py:103
-#: netbox/netbox/forms/bulk_import.py:27 netbox/netbox/forms/mixins.py:135
+#: netbox/netbox/forms/bulk_import.py:28 netbox/netbox/forms/mixins.py:135
 #: netbox/netbox/tables/columns.py:503
 #: netbox/templates/circuits/inc/circuit_termination.html:29
 #: netbox/templates/circuits/panels/circuit_circuit_termination.html:29
@@ -8345,6 +8345,22 @@ msgstr ""
 msgid "Scheduled time must be in the future."
 msgstr ""
 
+#: netbox/extras/api/views.py:370
+msgid "The script has been enqueued for execution."
+msgstr ""
+
+#: netbox/extras/api/views.py:383
+msgid "This token does not permit write operations (running a script)."
+msgstr ""
+
+#: netbox/extras/api/views.py:388
+msgid "This user does not have permission to run this script."
+msgstr ""
+
+#: netbox/extras/api/views.py:398
+msgid "This script is not currently executable."
+msgstr ""
+
 #: netbox/extras/choices.py:30 netbox/extras/forms/misc.py:14
 msgid "Text"
 msgstr ""
@@ -8664,12 +8680,12 @@ msgstr ""
 msgid "Ignoring invalid action_data on event rule \"{rule}\" (got {data_type})"
 msgstr ""
 
-#: netbox/extras/events.py:276
+#: netbox/extras/events.py:278
 #, python-brace-format
 msgid "Unknown action type for an event rule: {action_type}"
 msgstr ""
 
-#: netbox/extras/events.py:319
+#: netbox/extras/events.py:321
 #, python-brace-format
 msgid "Cannot import events pipeline {name} error: {error}"
 msgstr ""
@@ -10192,23 +10208,23 @@ msgstr ""
 msgid "subscriptions"
 msgstr ""
 
-#: netbox/extras/models/scripts.py:41
+#: netbox/extras/models/scripts.py:42
 msgid "is executable"
 msgstr ""
 
-#: netbox/extras/models/scripts.py:66
+#: netbox/extras/models/scripts.py:67
 msgid "script"
 msgstr ""
 
-#: netbox/extras/models/scripts.py:67
+#: netbox/extras/models/scripts.py:68
 msgid "scripts"
 msgstr ""
 
-#: netbox/extras/models/scripts.py:113
+#: netbox/extras/models/scripts.py:114
 msgid "script module"
 msgstr ""
 
-#: netbox/extras/models/scripts.py:114
+#: netbox/extras/models/scripts.py:115
 msgid "script modules"
 msgstr ""
 
@@ -12514,13 +12530,13 @@ msgstr ""
 msgid "Remove tags"
 msgstr ""
 
-#: netbox/netbox/forms/bulk_import.py:31
+#: netbox/netbox/forms/bulk_import.py:32
 msgid ""
 "Tag slugs separated by commas, encased with double quotes (e.g. \"tag1,tag2,"
 "tag3\")"
 msgstr ""
 
-#: netbox/netbox/forms/bulk_import.py:79
+#: netbox/netbox/forms/bulk_import.py:105
 msgid "Name of the object's owner"
 msgstr ""
 
@@ -12577,53 +12593,53 @@ msgstr ""
 msgid "Single sign-on failed. Please try again or contact your administrator."
 msgstr ""
 
-#: netbox/netbox/models/deletion.py:76
+#: netbox/netbox/models/deletion.py:142
 #, python-brace-format
 msgid ""
 "{object_name} object can't be deleted because its {pk_attname} attribute is "
 "set to None."
 msgstr ""
 
-#: netbox/netbox/models/features.py:311
+#: netbox/netbox/models/features.py:312
 #, python-brace-format
 msgid "Invalid value for custom field '{name}': {error}"
 msgstr ""
 
-#: netbox/netbox/models/features.py:320
+#: netbox/netbox/models/features.py:321
 #, python-brace-format
 msgid "Custom field '{name}' must have a unique value."
 msgstr ""
 
-#: netbox/netbox/models/features.py:327
+#: netbox/netbox/models/features.py:328
 #, python-brace-format
 msgid "Missing required custom field '{name}'."
 msgstr ""
 
-#: netbox/netbox/models/features.py:524
+#: netbox/netbox/models/features.py:565
 msgid "Remote data source"
 msgstr ""
 
-#: netbox/netbox/models/features.py:534
+#: netbox/netbox/models/features.py:575
 msgid "data path"
 msgstr ""
 
-#: netbox/netbox/models/features.py:538
+#: netbox/netbox/models/features.py:579
 msgid "Path to remote file (relative to data source root)"
 msgstr ""
 
-#: netbox/netbox/models/features.py:541
+#: netbox/netbox/models/features.py:582
 msgid "auto sync enabled"
 msgstr ""
 
-#: netbox/netbox/models/features.py:543
+#: netbox/netbox/models/features.py:584
 msgid "Enable automatic synchronization of data when the data file is updated"
 msgstr ""
 
-#: netbox/netbox/models/features.py:546
+#: netbox/netbox/models/features.py:587
 msgid "date synced"
 msgstr ""
 
-#: netbox/netbox/models/features.py:639
+#: netbox/netbox/models/features.py:680
 #, python-brace-format
 msgid "{class_name} must implement a sync_data() method."
 msgstr ""
@@ -13036,7 +13052,7 @@ msgstr ""
 #: netbox/templates/generic/bulk_delete.html:21
 #: netbox/templates/generic/bulk_delete.html:79
 #: netbox/templates/generic/object_delete.html:19
-#: netbox/templates/htmx/delete_form.html:70
+#: netbox/templates/htmx/delete_form.html:77
 #: netbox/templates/ipam/inc/panels/fhrp_groups.html:48
 #: netbox/templates/ipam/panels/fhrp_groups.html:34
 #: netbox/users/ui/panels.py:59 netbox/utilities/templatetags/buttons.py:146
@@ -13226,71 +13242,71 @@ msgstr ""
 msgid "Cannot delete stores from registry"
 msgstr ""
 
-#: netbox/netbox/settings.py:897
+#: netbox/netbox/settings.py:893
 msgid "Czech"
 msgstr ""
 
-#: netbox/netbox/settings.py:898
+#: netbox/netbox/settings.py:894
 msgid "Danish"
 msgstr ""
 
-#: netbox/netbox/settings.py:899
+#: netbox/netbox/settings.py:895
 msgid "German"
 msgstr ""
 
-#: netbox/netbox/settings.py:900
+#: netbox/netbox/settings.py:896
 msgid "English"
 msgstr ""
 
-#: netbox/netbox/settings.py:901
+#: netbox/netbox/settings.py:897
 msgid "Spanish"
 msgstr ""
 
-#: netbox/netbox/settings.py:902
+#: netbox/netbox/settings.py:898
 msgid "French"
 msgstr ""
 
-#: netbox/netbox/settings.py:903
+#: netbox/netbox/settings.py:899
 msgid "Italian"
 msgstr ""
 
-#: netbox/netbox/settings.py:904
+#: netbox/netbox/settings.py:900
 msgid "Japanese"
 msgstr ""
 
-#: netbox/netbox/settings.py:905
+#: netbox/netbox/settings.py:901
 msgid "Korean"
 msgstr ""
 
-#: netbox/netbox/settings.py:906
+#: netbox/netbox/settings.py:902
 msgid "Latvian"
 msgstr ""
 
-#: netbox/netbox/settings.py:907
+#: netbox/netbox/settings.py:903
 msgid "Dutch"
 msgstr ""
 
-#: netbox/netbox/settings.py:908
+#: netbox/netbox/settings.py:904
 msgid "Polish"
 msgstr ""
 
-#: netbox/netbox/settings.py:909
+#: netbox/netbox/settings.py:905
 msgid "Portuguese"
 msgstr ""
 
-#: netbox/netbox/settings.py:910
+#: netbox/netbox/settings.py:906
 msgid "Russian"
 msgstr ""
 
-#: netbox/netbox/settings.py:911
+#: netbox/netbox/settings.py:907
 msgid "Turkish"
 msgstr ""
 
-#: netbox/netbox/settings.py:912
+#: netbox/netbox/settings.py:908
 msgid "Ukrainian"
 msgstr ""
 
-#: netbox/netbox/settings.py:913
+#: netbox/netbox/settings.py:909
 msgid "Chinese"
 msgstr ""
 
@@ -13603,8 +13619,8 @@ msgstr ""
 #: netbox/templates/generic/bulk_rename.html:101
 #: netbox/templates/generic/confirmation_form.html:19
 #: netbox/templates/generic/object_edit.html:74
-#: netbox/templates/htmx/delete_form.html:66
-#: netbox/templates/htmx/delete_form.html:68
+#: netbox/templates/htmx/delete_form.html:73
+#: netbox/templates/htmx/delete_form.html:75
 #: netbox/templates/htmx/quick_add.html:21
 #: netbox/templates/ipam/ipaddress_assign.html:28
 #: netbox/templates/users/token_edit.html:32

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


Разница между файлами не показана из-за своего большого размера
+ 156 - 156
netbox/translations/es/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 156 - 156
netbox/translations/fr/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 156 - 156
netbox/translations/it/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 161 - 161
netbox/translations/ja/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 161 - 161
netbox/translations/ko/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 158 - 158
netbox/translations/lv/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 156 - 156
netbox/translations/nl/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 156 - 156
netbox/translations/pl/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 156 - 156
netbox/translations/pt/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 154 - 154
netbox/translations/ru/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 156 - 156
netbox/translations/tr/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 158 - 158
netbox/translations/uk/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 169 - 169
netbox/translations/zh/LC_MESSAGES/django.po


+ 8 - 8
requirements.txt

@@ -1,10 +1,10 @@
 colorama==0.4.6
 Django==6.1
 django-cors-headers==4.9.0
-django-debug-toolbar==7.0.0
+django-debug-toolbar==7.1.0
 django-filter==26.1
 django-graphiql-debug-toolbar==0.2.0
-django-htmx==1.28.0
+django-htmx==1.29.0
 django-mptt==0.18.0
 django-pgware==1.0.0
 django-prometheus==2.4.0
@@ -17,12 +17,12 @@ django-taggit==6.1.0
 django-timezone-field==7.2.2
 djangorestframework==3.18.0
 drf-spectacular==0.30.0
-drf-spectacular-sidecar==2026.7.1
+drf-spectacular-sidecar==2026.8.1
 feedparser==6.0.14
 gunicorn==26.0.0
 Jinja2==3.1.6
 jsonschema==4.26.0
-Markdown==3.10.2
+Markdown==3.10.3
 mkdocs==1.6.1
 mkdocs-material==9.7.7
 mkdocstrings==1.0.6
@@ -38,9 +38,9 @@ rq==2.10.0
 social-auth-app-django==6.0.1
 social-auth-core==5.1.0
 sorl-thumbnail==13.0.0
-strawberry-graphql==0.323.2
-strawberry-graphql-django==0.86.5
+strawberry-graphql==0.324.0
+strawberry-graphql-django==0.87.0
 svgwrite==1.4.3
-tablib==3.9.0
+tablib==3.10.0
 tzdata==2026.3
-zensical==0.0.52
+zensical==0.0.53

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