Răsfoiți Sursa

Merge branch 'main' into feature

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Jeremy Stretch 4 zile în urmă
părinte
comite
f12fe46486
62 a modificat fișierele cu 4116 adăugiri și 3115 ștergeri
  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",
     "openapi": "3.0.3",
     "info": {
     "info": {
         "title": "NetBox REST API",
         "title": "NetBox REST API",
-        "version": "4.6.7",
+        "version": "4.6.8",
         "license": {
         "license": {
             "name": "Apache v2 License"
             "name": "Apache v2 License"
         }
         }
@@ -150938,33 +150938,6 @@
                         "description": ""
                         "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}/": {
         "/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": [
                 "parameters": [
                     {
                     {
                         "in": "path",
                         "in": "path",
@@ -151075,94 +151048,11 @@
                         "content": {
                         "content": {
                             "application/json": {
                             "application/json": {
                                 "schema": {
                                 "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"
                     "user"
                 ]
                 ]
             },
             },
-            "BriefJobRequest": {
-                "type": "object",
-                "properties": {
-                    "completed": {
-                        "type": "string",
-                        "format": "date-time",
-                        "nullable": true
-                    }
-                }
-            },
             "BriefL2VPN": {
             "BriefL2VPN": {
                 "type": "object",
                 "type": "object",
                 "description": "Base serializer class for models inheriting from PrimaryModel.",
                 "description": "Base serializer class for models inheriting from PrimaryModel.",
@@ -249575,11 +249455,13 @@
                         "enum": [
                         "enum": [
                             "IATA",
                             "IATA",
                             "ISO_3166",
                             "ISO_3166",
-                            "UN_LOCODE"
+                            "UN_LOCODE",
+                            null
                         ],
                         ],
                         "type": "string",
                         "type": "string",
                         "description": "* `IATA` - IATA (Airport codes)\n* `ISO_3166` - ISO 3166 (Country codes)\n* `UN_LOCODE` - UN/LOCODE (Location codes)",
                         "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": {
                     "extra_choices": {
                         "type": "array",
                         "type": "array",
@@ -260331,7 +260213,7 @@
                         "type": "string",
                         "type": "string",
                         "minLength": 1,
                         "minLength": 1,
                         "title": "URL",
                         "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
                         "maxLength": 500
                     },
                     },
                     "http_method": {
                     "http_method": {
@@ -266122,7 +266004,8 @@
                                 "enum": [
                                 "enum": [
                                     "IATA",
                                     "IATA",
                                     "ISO_3166",
                                     "ISO_3166",
-                                    "UN_LOCODE"
+                                    "UN_LOCODE",
+                                    null
                                 ],
                                 ],
                                 "type": "string",
                                 "type": "string",
                                 "description": "* `IATA` - IATA (Airport codes)\n* `ISO_3166` - ISO 3166 (Country codes)\n* `UN_LOCODE` - UN/LOCODE (Location codes)",
                                 "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)"
                                     "UN/LOCODE (Location codes)"
                                 ]
                                 ]
                             }
                             }
-                        }
+                        },
+                        "nullable": true
                     },
                     },
                     "extra_choices": {
                     "extra_choices": {
                         "type": "array",
                         "type": "array",
@@ -266228,11 +266112,13 @@
                         "enum": [
                         "enum": [
                             "IATA",
                             "IATA",
                             "ISO_3166",
                             "ISO_3166",
-                            "UN_LOCODE"
+                            "UN_LOCODE",
+                            null
                         ],
                         ],
                         "type": "string",
                         "type": "string",
                         "description": "* `IATA` - IATA (Airport codes)\n* `ISO_3166` - ISO 3166 (Country codes)\n* `UN_LOCODE` - UN/LOCODE (Location codes)",
                         "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": {
                     "extra_choices": {
                         "type": "array",
                         "type": "array",
@@ -287323,11 +287209,13 @@
                         "enum": [
                         "enum": [
                             "IATA",
                             "IATA",
                             "ISO_3166",
                             "ISO_3166",
-                            "UN_LOCODE"
+                            "UN_LOCODE",
+                            null
                         ],
                         ],
                         "type": "string",
                         "type": "string",
                         "description": "* `IATA` - IATA (Airport codes)\n* `ISO_3166` - ISO 3166 (Country codes)\n* `UN_LOCODE` - UN/LOCODE (Location codes)",
                         "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": {
                     "extra_choices": {
                         "type": "array",
                         "type": "array",
@@ -297860,7 +297748,7 @@
                         "type": "string",
                         "type": "string",
                         "minLength": 1,
                         "minLength": 1,
                         "title": "URL",
                         "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
                         "maxLength": 500
                     },
                     },
                     "http_method": {
                     "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": {
             "PatchedScriptModuleRequest": {
                 "type": "object",
                 "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)",
                 "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",
                         "type": "string",
                         "minLength": 1,
                         "minLength": 1,
                         "title": "URL",
                         "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
                         "maxLength": 500
                     },
                     },
                     "http_method": {
                     "http_method": {
@@ -318061,6 +317920,71 @@
                     "vars"
                     "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": {
             "ScriptInputRequest": {
                 "type": "object",
                 "type": "object",
                 "properties": {
                 "properties": {
@@ -324076,7 +324000,7 @@
                     "payload_url": {
                     "payload_url": {
                         "type": "string",
                         "type": "string",
                         "title": "URL",
                         "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
                         "maxLength": 500
                     },
                     },
                     "http_method": {
                     "http_method": {
@@ -324185,7 +324109,7 @@
                         "type": "string",
                         "type": "string",
                         "minLength": 1,
                         "minLength": 1,
                         "title": "URL",
                         "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
                         "maxLength": 500
                     },
                     },
                     "http_method": {
                     "http_method": {

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

@@ -1,5 +1,32 @@
 # NetBox v4.6
 # 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)
 ## v4.6.7 (2026-07-30)
 
 
 ### Performance Improvements
 ### Performance Improvements

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

@@ -1,7 +1,7 @@
 from django import forms
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.forms.array import SimpleArrayField
 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.functional import lazy
 from django.utils.html import format_html
 from django.utils.html import format_html
 from django.utils.safestring import SafeString, mark_safe
 from django.utils.safestring import SafeString, mark_safe
@@ -1894,17 +1894,19 @@ class CableImportForm(PrimaryModelImportForm):
                     **side_b_parent_params
                     **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.
         # 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):
     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()
         instance.refresh_from_db()
         self.assertIsNone(instance.primary_mac_address)
         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):
 class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = FrontPort
     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 django.urls import include, path
 
 
-from netbox.api.routers import NetBoxRouter
-
 from . import views
 from . import views
+from .routers import ScriptRouter
 
 
-router = NetBoxRouter()
+router = ScriptRouter()
 router.APIRootView = views.ExtrasRootView
 router.APIRootView = views.ExtrasRootView
 
 
 router.register('event-rules', views.EventRuleViewSet)
 router.register('event-rules', views.EventRuleViewSet)

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

@@ -1,16 +1,15 @@
 from django.http import Http404
 from django.http import Http404
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 from django.utils.translation import gettext_lazy as _
 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 import status
 from rest_framework.decorators import action
 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.generics import RetrieveUpdateDestroyAPIView
 from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin
 from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin
 from rest_framework.renderers import JSONRenderer
 from rest_framework.renderers import JSONRenderer
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
 from rest_framework.routers import APIRootView
-from rest_framework.viewsets import ModelViewSet
 
 
 from core.choices import ManagedFileRootPathChoices
 from core.choices import ManagedFileRootPathChoices
 from extras import filtersets
 from extras import filtersets
@@ -308,30 +307,44 @@ class ScriptModuleViewSet(ObjectValidationMixin, CreateModelMixin, UpdateModelMi
         return obj
         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]
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
     queryset = Script.objects.all()
     queryset = Script.objects.all()
     serializer_class = serializers.ScriptSerializer
     serializer_class = serializers.ScriptSerializer
     filterset_class = filtersets.ScriptFilterSet
     filterset_class = filtersets.ScriptFilterSet
 
 
-    _ignore_model_permissions = True
     lookup_value_regex = '[^/]+'  # Allow dots
     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):
     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)
             return get_object_or_404(self.queryset, pk=pk)
 
 
         # Default to retrieval by module & name
         # 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)
         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)
         script = self._get_script(pk)
         serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
         serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
 
 
         return Response(serializer.data)
         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
         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:
         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
         # Check that at least one RQ worker is running
         if not any_workers_for_queue('default'):
         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 functools import cached_property
 
 
 from django.contrib.contenttypes.fields import GenericRelation
 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.db.models import Q
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 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.choices import ManagedFileRootPathChoices
 from core.models import ManagedFile
 from core.models import ManagedFile
 from extras.utils import is_script
 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 utilities.querysets import RestrictedQuerySet
 
 
 from .mixins import PythonModuleMixin
 from .mixins import PythonModuleMixin
@@ -119,6 +120,29 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
     def __str__(self):
     def __str__(self):
         return self.python_name
         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
     @property
     def ordered_scripts(self):
     def ordered_scripts(self):
         script_objects = {s.name: s for s in self.scripts.all()}
         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.contrib.contenttypes.models import ContentType
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.db import IntegrityError
 from django.db import IntegrityError
+from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.timezone import make_aware, now
 from django.utils.timezone import make_aware, now
 from rest_framework import status
 from rest_framework import status
 
 
 from core.choices import ManagedFileRootPathChoices
 from core.choices import ManagedFileRootPathChoices
 from core.events import *
 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 dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Rack, RackRole, Site
 from extras.api.serializers import EventRuleSerializer
 from extras.api.serializers import EventRuleSerializer
 from extras.choices import *
 from extras.choices import *
@@ -26,7 +27,7 @@ from netbox.registry import registry
 from users.constants import TOKEN_PREFIX
 from users.constants import TOKEN_PREFIX
 from users.models import Group, ObjectPermission, Token, User
 from users.models import Group, ObjectPermission, Token, User
 from utilities.tables import get_table_for_model
 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):
 class AppTestCase(APITestCase):
@@ -1532,6 +1533,34 @@ class ScriptTestCase(APITestCase):
         self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
         self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
         self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
         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):
     def test_schedule_script_past_time_rejected(self):
         """
         """
         Scheduling with past schedule_at should fail.
         Scheduling with past schedule_at should fail.
@@ -1602,45 +1631,216 @@ class ScriptTestCase(APITestCase):
             # Restore the original setting for other tests
             # Restore the original setting for other tests
             self.TestScriptClass.Meta.scheduling_enabled = original
             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')
         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.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)
         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
         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')
         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
         # Authenticate via session rather than a token; request.auth is None
         self.client.force_authenticate(user=self.user)
         self.client.force_authenticate(user=self.user)
         response = self.client.post(self.url, payload, format='json')
         response = self.client.post(self.url, payload, format='json')
         self.assertHttpStatus(response, status.HTTP_200_OK)
         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):
 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(
     protocol = django_filters.MultipleChoiceFilter(
         choices=ServiceProtocolChoices,
         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
     # 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
     # 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.
         # See filter_queryset(), which applies `protocol` and the port lookups as one correlated predicate.
         return queryset
         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]):
     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
         # 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.
         # 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 django.db.models import ProtectedError, RestrictedError
 from rest_framework import mixins as drf_mixins
 from rest_framework import mixins as drf_mixins
 from rest_framework import status
 from rest_framework import status
+from rest_framework.exceptions import MethodNotAllowed
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.viewsets import GenericViewSet
 from rest_framework.viewsets import GenericViewSet
 
 
@@ -87,6 +88,13 @@ class BaseViewSet(GenericViewSet):
     def initial(self, request, *args, **kwargs):
     def initial(self, request, *args, **kwargs):
         super().initial(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
         # Restrict the view's QuerySet to allow only the permitted objects
         if request.user.is_authenticated:
         if request.user.is_authenticated:
             if action := HTTP_ACTIONS[request.method]:
             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
 # Placeholder text for empty tables
 EMPTY_TABLE_TEXT = 'No results found'
 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
     :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 import forms
+from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
 from django.db import models
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
@@ -70,6 +71,31 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
 
 
         return cleaned
         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):
 class OwnerCSVMixin(forms.Form):
     owner = CSVModelChoiceField(
     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")
 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):
 class CustomCollector(Collector):
     """
     """
     Override Django's stock Collector to handle GenericRelations and ensure proper ordering of cascading deletions.
     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.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
-from django.db import models
+from django.db import models, router, transaction
 from django.db.models import Q
 from django.db.models import Q
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 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.managers import NetBoxTaggableManager, NetBoxTaggableManagerField
 from extras.utils import is_taggable
 from extras.utils import is_taggable
 from netbox.config import get_config
 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.models.deletion import DeleteMixin
 from netbox.plugins import PluginConfig
 from netbox.plugins import PluginConfig
 from netbox.registry import registry
 from netbox.registry import registry
@@ -43,6 +43,7 @@ __all__ = (
     'NotificationsMixin',
     'NotificationsMixin',
     'SyncedDataMixin',
     'SyncedDataMixin',
     'TagsMixin',
     'TagsMixin',
+    'batch_delete_jobs',
     'get_model_features',
     'get_model_features',
     'has_feature',
     'has_feature',
     'model_is_public',
     'model_is_public',
@@ -456,9 +457,39 @@ class NotificationsMixin(models.Model):
         abstract = True
         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):
 class JobsMixin(models.Model):
     """
     """
     Enables support for job results.
     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(
     jobs = GenericRelation(
         to='core.Job',
         to='core.Job',
@@ -470,6 +501,16 @@ class JobsMixin(models.Model):
     class Meta:
     class Meta:
         abstract = True
         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):
     def get_latest_jobs(self):
         """
         """
         Return a list of the most recent jobs for this instance.
         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 = {
 DEBUG_TOOLBAR_CONFIG = {
     'SHOW_TOOLBAR_CALLBACK': 'utilities.debug.show_toolbar',
     '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 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.forms.models import ALL_FIELDS
 from django.test import TestCase
 from django.test import TestCase
 
 
@@ -22,12 +27,8 @@ from vpn.forms import TunnelCreateForm, TunnelTerminationForm
 from wireless.forms import WirelessLANForm
 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
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -319,6 +320,102 @@ class NetBoxModelImportFormCleanTestCase(TestCase):
         self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
         self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
         self.assertIsNone(form.cleaned_data['wwn'])
         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):
 class HTMXPartialSwapRenderingTestCase(TestCase):
     """Ensure each form field's HTMX swap wiring is present on the widget bound to the field."""
     """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.contrib import messages
 from django.db import router, transaction
 from django.db import router, transaction
 from django.db.models import ProtectedError, RestrictedError
 from django.db.models import ProtectedError, RestrictedError
-from django.db.models.deletion import Collector
 from django.http import HttpResponse
 from django.http import HttpResponse
 from django.shortcuts import redirect, render
 from django.shortcuts import redirect, render
 from django.urls import reverse
 from django.urls import reverse
@@ -13,6 +12,7 @@ from django.utils.safestring import mark_safe
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from core.signals import clear_events
 from core.signals import clear_events
+from netbox.models.deletion import ConfirmCollector, CountOnly
 from netbox.object_actions import BulkDelete, BulkEdit, CloneObject, DeleteObject, EditObject
 from netbox.object_actions import BulkDelete, BulkEdit, CloneObject, DeleteObject, EditObject
 from utilities.error_handlers import handle_protectederror
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortRequest, PermissionsViolation
 from utilities.exceptions import AbortRequest, PermissionsViolation
@@ -385,14 +385,19 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
 
 
     def _get_dependent_objects(self, obj):
     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:
         Args:
             obj: The object to return dependent objects for
             obj: The object to return dependent objects for
         """
         """
         using = router.db_for_write(obj._meta.model)
         using = router.db_for_write(obj._meta.model)
-        collector = Collector(using=using)
+        collector = ConfirmCollector(using=using)
         collector.collect([obj])
         collector.collect([obj])
 
 
         # Compile a mapping of models to instances
         # Compile a mapping of models to instances
@@ -406,7 +411,13 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
                 continue
                 continue
             dependent_objects[model].append(instances)
             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):
     def _handle_protected_objects(self, obj, protected_objects, request, exc):
         """
         """

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/netbox.js


Fișier diff suprimat deoarece este prea mare
+ 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",
     "htmx.org": "2.0.10",
     "markdown-it": "^14.3.0",
     "markdown-it": "^14.3.0",
     "picomatch": "4.0.5",
     "picomatch": "4.0.5",
-    "query-string": "9.4.1",
+    "query-string": "9.5.0",
     "sass": "1.102.0",
     "sass": "1.102.0",
     "tom-select": "2.6.2",
     "tom-select": "2.6.2",
     "typeface-inter": "3.18.1",
     "typeface-inter": "3.18.1",
@@ -45,17 +45,17 @@
     "@types/bootstrap": "5.2.11",
     "@types/bootstrap": "5.2.11",
     "@types/cookie": "^1.0.0",
     "@types/cookie": "^1.0.0",
     "@types/node": "^24.10.1",
     "@types/node": "^24.10.1",
-    "@typescript-eslint/eslint-plugin": "^8.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",
     "esbuild-sass-plugin": "^3.7.0",
-    "eslint": "^10.8.0",
+    "eslint": "^10.8.1",
     "eslint-config-prettier": "^10.1.8",
     "eslint-config-prettier": "^10.1.8",
     "eslint-import-resolver-typescript": "^4.4.5",
     "eslint-import-resolver-typescript": "^4.4.5",
     "eslint-plugin-import": "^2.32.0",
     "eslint-plugin-import": "^2.32.0",
     "eslint-plugin-prettier": "^5.5.6",
     "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"
     "typescript": "^5.9.3"
   },
   },
   "resolutions": {
   "resolutions": {

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

@@ -24,135 +24,135 @@
   dependencies:
   dependencies:
     tslib "^2.4.0"
     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":
 "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1":
   version "4.9.1"
   version "4.9.1"
@@ -917,100 +917,100 @@
   dependencies:
   dependencies:
     "@types/estree" "*"
     "@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:
   dependencies:
     "@eslint-community/regexpp" "^4.12.2"
     "@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"
     ignore "^7.0.5"
     natural-compare "^1.4.0"
     natural-compare "^1.4.0"
     ts-api-utils "^2.5.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:
   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"
     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:
   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"
     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:
   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:
   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"
     debug "^4.4.3"
     ts-api-utils "^2.5.0"
     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:
   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"
     debug "^4.4.3"
     minimatch "^10.2.2"
     minimatch "^10.2.2"
     semver "^7.7.3"
     semver "^7.7.3"
     tinyglobby "^0.2.15"
     tinyglobby "^0.2.15"
     ts-api-utils "^2.5.0"
     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:
   dependencies:
     "@eslint-community/eslint-utils" "^4.9.1"
     "@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:
   dependencies:
-    "@typescript-eslint/types" "8.65.0"
+    "@typescript-eslint/types" "8.67.0"
     eslint-visitor-keys "^5.0.0"
     eslint-visitor-keys "^5.0.0"
 
 
 "@unrs/resolver-binding-android-arm-eabi@1.11.1":
 "@unrs/resolver-binding-android-arm-eabi@1.11.1":
@@ -1478,10 +1478,10 @@ debug@^4.3.1, debug@^4.3.2, debug@^4.4.1, debug@^4.4.3:
   dependencies:
   dependencies:
     ms "^2.1.3"
     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:
 deep-is@^0.1.3:
   version "0.1.4"
   version "0.1.4"
@@ -1744,37 +1744,37 @@ esbuild-sass-plugin@^3.7.0:
     resolve "^1.22.11"
     resolve "^1.22.11"
     sass "^1.97.3"
     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:
   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:
 escape-string-regexp@^4.0.0:
   version "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"
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
   integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
   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:
   dependencies:
     "@eslint-community/eslint-utils" "^4.8.0"
     "@eslint-community/eslint-utils" "^4.8.0"
     "@eslint-community/regexpp" "^4.12.2"
     "@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"
   resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"
   integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
   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:
 globalthis@^1.0.3, globalthis@^1.0.4:
   version "1.0.4"
   version "1.0.4"
@@ -2964,10 +2964,10 @@ prettier-linter-helpers@^1.0.1:
   dependencies:
   dependencies:
     fast-diff "^1.1.2"
     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:
 punycode.js@^2.3.1:
   version "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"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
   integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
   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:
   dependencies:
-    decode-uri-component "^0.4.1"
+    decode-uri-component "^0.5.0"
     filter-obj "^5.1.0"
     filter-obj "^5.1.0"
     split-on-first "^3.0.0"
     split-on-first "^3.0.0"
 
 

+ 1 - 2
netbox/release.yaml

@@ -1,4 +1,3 @@
 version: "4.6.8"
 version: "4.6.8"
 edition: "Community"
 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>
       </p>
       <div class="accordion mb-3" id="deleteAccordion">
       <div class="accordion mb-3" id="deleteAccordion">
         {% for model, instances in dependent_objects.items %}
         {% 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>
-              </div>
+              {% endif %}
             </div>
             </div>
-          </div>
+          {% endwith %}
         {% endfor %}
         {% endfor %}
       </div>
       </div>
     {% endif %}
     {% endif %}

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


Fișier diff suprimat deoarece este prea mare
+ 156 - 156
netbox/translations/cs/LC_MESSAGES/django.po


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


Fișier diff suprimat deoarece este prea mare
+ 156 - 156
netbox/translations/da/LC_MESSAGES/django.po


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


Fișier diff suprimat deoarece este prea mare
+ 156 - 156
netbox/translations/de/LC_MESSAGES/django.po


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

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

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


Fișier diff suprimat deoarece este prea mare
+ 156 - 156
netbox/translations/es/LC_MESSAGES/django.po


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


Fișier diff suprimat deoarece este prea mare
+ 156 - 156
netbox/translations/fr/LC_MESSAGES/django.po


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


Fișier diff suprimat deoarece este prea mare
+ 156 - 156
netbox/translations/it/LC_MESSAGES/django.po


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


Fișier diff suprimat deoarece este prea mare
+ 161 - 161
netbox/translations/ja/LC_MESSAGES/django.po


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


Fișier diff suprimat deoarece este prea mare
+ 161 - 161
netbox/translations/ko/LC_MESSAGES/django.po


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


Fișier diff suprimat deoarece este prea mare
+ 158 - 158
netbox/translations/lv/LC_MESSAGES/django.po


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


Fișier diff suprimat deoarece este prea mare
+ 156 - 156
netbox/translations/nl/LC_MESSAGES/django.po


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


Fișier diff suprimat deoarece este prea mare
+ 156 - 156
netbox/translations/pl/LC_MESSAGES/django.po


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


Fișier diff suprimat deoarece este prea mare
+ 156 - 156
netbox/translations/pt/LC_MESSAGES/django.po


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


Fișier diff suprimat deoarece este prea mare
+ 154 - 154
netbox/translations/ru/LC_MESSAGES/django.po


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


Fișier diff suprimat deoarece este prea mare
+ 156 - 156
netbox/translations/tr/LC_MESSAGES/django.po


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


Fișier diff suprimat deoarece este prea mare
+ 158 - 158
netbox/translations/uk/LC_MESSAGES/django.po


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


Fișier diff suprimat deoarece este prea mare
+ 169 - 169
netbox/translations/zh/LC_MESSAGES/django.po


+ 8 - 8
requirements.txt

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

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff