Merge branch 'main' into feature

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jeremy Stretch 2026-08-17 09:16:45 -04:00
commit f12fe46486
62 changed files with 7404 additions and 6071 deletions

View File

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

View File

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

View File

@ -1,7 +1,7 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms.array import SimpleArrayField
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
from django.core.exceptions import NON_FIELD_ERRORS, MultipleObjectsReturned, ObjectDoesNotExist
from django.utils.functional import lazy
from django.utils.html import format_html
from django.utils.safestring import SafeString, mark_safe
@ -1894,17 +1894,19 @@ class CableImportForm(PrimaryModelImportForm):
**side_b_parent_params
)
def add_error(self, field, error):
def _update_errors(self, errors):
# Remap any termination errors raised by Cable.clean() onto the relevant import column.
# Without this, Django raises ValueError for an error reported against a field which does
# not exist on the form. Errors are dispatched per key so that both sides can fall back to
# a non-field error without colliding.
if field is None and hasattr(error, 'error_dict'):
for name, errors in error.error_dict.items():
super().add_error(self._map_termination_field(name), errors)
return
# 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().add_error(self._map_termination_field(field), error)
super()._update_errors(errors)
def _map_termination_field(self, field):
"""

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)

View File

@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@ -24,135 +24,135 @@
dependencies:
tslib "^2.4.0"
"@esbuild/aix-ppc64@0.28.1":
version "0.28.1"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be"
integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==
"@esbuild/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.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-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.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-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.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/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.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-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.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/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.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-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.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/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.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-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.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-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.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-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.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-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.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-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.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-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.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-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.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-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.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/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.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-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.1":
version "0.28.1"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347"
integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==
"@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.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-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.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/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.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/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.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/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.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-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.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-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.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/win32-x64@0.28.2":
version "0.28.2"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87"
integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==
"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1":
version "4.9.1"
@ -917,100 +917,100 @@
dependencies:
"@types/estree" "*"
"@typescript-eslint/eslint-plugin@^8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz#0a58df6fea8c0bf6b396f518077099bc8b762bb5"
integrity sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==
"@typescript-eslint/eslint-plugin@^8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz#52f9f0e47d5a7571c4336e69bfeea581509ef2cf"
integrity sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==
dependencies:
"@eslint-community/regexpp" "^4.12.2"
"@typescript-eslint/scope-manager" "8.65.0"
"@typescript-eslint/type-utils" "8.65.0"
"@typescript-eslint/utils" "8.65.0"
"@typescript-eslint/visitor-keys" "8.65.0"
"@typescript-eslint/scope-manager" "8.67.0"
"@typescript-eslint/type-utils" "8.67.0"
"@typescript-eslint/utils" "8.67.0"
"@typescript-eslint/visitor-keys" "8.67.0"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@^8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.65.0.tgz#5295c1058c0a1dd746ef28baaf9c0341dbdf03dc"
integrity sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==
"@typescript-eslint/parser@^8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.67.0.tgz#0158022ec9927e0afcd58a8cc2ad57e01d892f5c"
integrity sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==
dependencies:
"@typescript-eslint/scope-manager" "8.65.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/typescript-estree" "8.65.0"
"@typescript-eslint/visitor-keys" "8.65.0"
"@typescript-eslint/scope-manager" "8.67.0"
"@typescript-eslint/types" "8.67.0"
"@typescript-eslint/typescript-estree" "8.67.0"
"@typescript-eslint/visitor-keys" "8.67.0"
debug "^4.4.3"
"@typescript-eslint/project-service@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.65.0.tgz#65fbbc9a1591abffaeab5513200f848271cb0aa5"
integrity sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==
"@typescript-eslint/project-service@8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.67.0.tgz#1552db007ca9206a1c6c7acf49e210bd17a8c56f"
integrity sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.65.0"
"@typescript-eslint/types" "^8.65.0"
"@typescript-eslint/tsconfig-utils" "^8.67.0"
"@typescript-eslint/types" "^8.67.0"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz#9547202ce7e608e7b6283df585703b980a0ea70d"
integrity sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==
"@typescript-eslint/scope-manager@8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz#4d4c2da09560d10dd7d947cba2d29d14d25af16d"
integrity sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==
dependencies:
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/visitor-keys" "8.65.0"
"@typescript-eslint/types" "8.67.0"
"@typescript-eslint/visitor-keys" "8.67.0"
"@typescript-eslint/tsconfig-utils@8.65.0", "@typescript-eslint/tsconfig-utils@^8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz#36f168fcdbb1295f7446ff0379667f98c3cf1bf3"
integrity sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==
"@typescript-eslint/tsconfig-utils@8.67.0", "@typescript-eslint/tsconfig-utils@^8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz#f45a3eba6b9132fb47141ec03ce2f275f1ea991d"
integrity sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==
"@typescript-eslint/type-utils@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz#d316d7522d93cff4cd14f305e02f3df2d804f9c1"
integrity sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==
"@typescript-eslint/type-utils@8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz#96bed105275559df3bcf0449b73a6414d35c59ce"
integrity sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==
dependencies:
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/typescript-estree" "8.65.0"
"@typescript-eslint/utils" "8.65.0"
"@typescript-eslint/types" "8.67.0"
"@typescript-eslint/typescript-estree" "8.67.0"
"@typescript-eslint/utils" "8.67.0"
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@typescript-eslint/types@8.65.0", "@typescript-eslint/types@^8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.65.0.tgz#3e86738416a777c8b8925ab46745f48ecf904c9f"
integrity sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==
"@typescript-eslint/types@8.67.0", "@typescript-eslint/types@^8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.67.0.tgz#4a8d00cc1faba5c14feabc60f85b7a32652f34b6"
integrity sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==
"@typescript-eslint/typescript-estree@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz#f1f514808f6aa713e2d678ae8ff592a65e1632af"
integrity sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==
"@typescript-eslint/typescript-estree@8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz#116c3a47c06119c5a050e8851861d6497dd64bc2"
integrity sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==
dependencies:
"@typescript-eslint/project-service" "8.65.0"
"@typescript-eslint/tsconfig-utils" "8.65.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/visitor-keys" "8.65.0"
"@typescript-eslint/project-service" "8.67.0"
"@typescript-eslint/tsconfig-utils" "8.67.0"
"@typescript-eslint/types" "8.67.0"
"@typescript-eslint/visitor-keys" "8.67.0"
debug "^4.4.3"
minimatch "^10.2.2"
semver "^7.7.3"
tinyglobby "^0.2.15"
ts-api-utils "^2.5.0"
"@typescript-eslint/utils@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.65.0.tgz#afedd974a0c8deeef553b509df5800bafd615a72"
integrity sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==
"@typescript-eslint/utils@8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.67.0.tgz#3e478a3d69d330a1fc50c12746cc2ee0732ccfcd"
integrity sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.65.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/typescript-estree" "8.65.0"
"@typescript-eslint/scope-manager" "8.67.0"
"@typescript-eslint/types" "8.67.0"
"@typescript-eslint/typescript-estree" "8.67.0"
"@typescript-eslint/visitor-keys@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz#e3704c13cb4a1c22454c1abf28ff4737e15018c6"
integrity sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==
"@typescript-eslint/visitor-keys@8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz#601d40af9acf82a28da2286f3edafc69bba9017f"
integrity sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==
dependencies:
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/types" "8.67.0"
eslint-visitor-keys "^5.0.0"
"@unrs/resolver-binding-android-arm-eabi@1.11.1":
@ -1478,10 +1478,10 @@ debug@^4.3.1, debug@^4.3.2, debug@^4.4.1, debug@^4.4.3:
dependencies:
ms "^2.1.3"
decode-uri-component@^0.4.1:
version "0.4.1"
resolved "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz"
integrity sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==
decode-uri-component@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.5.0.tgz#4592fa1e1d640ec5e2760e2e168ad2ab5f2c9da1"
integrity sha512-1BiQVoK8C9gUbQU6NzAtO/tkz2qOFpEObMWpcFvhx4fYnj4Oc5yzaJN/LD36ihkVUdXyh5ZekzX+yM+ty/SrPg==
deep-is@^0.1.3:
version "0.1.4"
@ -1744,37 +1744,37 @@ esbuild-sass-plugin@^3.7.0:
resolve "^1.22.11"
sass "^1.97.3"
esbuild@^0.28.1:
version "0.28.1"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578"
integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==
esbuild@^0.28.2:
version "0.28.2"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816"
integrity sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==
optionalDependencies:
"@esbuild/aix-ppc64" "0.28.1"
"@esbuild/android-arm" "0.28.1"
"@esbuild/android-arm64" "0.28.1"
"@esbuild/android-x64" "0.28.1"
"@esbuild/darwin-arm64" "0.28.1"
"@esbuild/darwin-x64" "0.28.1"
"@esbuild/freebsd-arm64" "0.28.1"
"@esbuild/freebsd-x64" "0.28.1"
"@esbuild/linux-arm" "0.28.1"
"@esbuild/linux-arm64" "0.28.1"
"@esbuild/linux-ia32" "0.28.1"
"@esbuild/linux-loong64" "0.28.1"
"@esbuild/linux-mips64el" "0.28.1"
"@esbuild/linux-ppc64" "0.28.1"
"@esbuild/linux-riscv64" "0.28.1"
"@esbuild/linux-s390x" "0.28.1"
"@esbuild/linux-x64" "0.28.1"
"@esbuild/netbsd-arm64" "0.28.1"
"@esbuild/netbsd-x64" "0.28.1"
"@esbuild/openbsd-arm64" "0.28.1"
"@esbuild/openbsd-x64" "0.28.1"
"@esbuild/openharmony-arm64" "0.28.1"
"@esbuild/sunos-x64" "0.28.1"
"@esbuild/win32-arm64" "0.28.1"
"@esbuild/win32-ia32" "0.28.1"
"@esbuild/win32-x64" "0.28.1"
"@esbuild/aix-ppc64" "0.28.2"
"@esbuild/android-arm" "0.28.2"
"@esbuild/android-arm64" "0.28.2"
"@esbuild/android-x64" "0.28.2"
"@esbuild/darwin-arm64" "0.28.2"
"@esbuild/darwin-x64" "0.28.2"
"@esbuild/freebsd-arm64" "0.28.2"
"@esbuild/freebsd-x64" "0.28.2"
"@esbuild/linux-arm" "0.28.2"
"@esbuild/linux-arm64" "0.28.2"
"@esbuild/linux-ia32" "0.28.2"
"@esbuild/linux-loong64" "0.28.2"
"@esbuild/linux-mips64el" "0.28.2"
"@esbuild/linux-ppc64" "0.28.2"
"@esbuild/linux-riscv64" "0.28.2"
"@esbuild/linux-s390x" "0.28.2"
"@esbuild/linux-x64" "0.28.2"
"@esbuild/netbsd-arm64" "0.28.2"
"@esbuild/netbsd-x64" "0.28.2"
"@esbuild/openbsd-arm64" "0.28.2"
"@esbuild/openbsd-x64" "0.28.2"
"@esbuild/openharmony-arm64" "0.28.2"
"@esbuild/sunos-x64" "0.28.2"
"@esbuild/win32-arm64" "0.28.2"
"@esbuild/win32-ia32" "0.28.2"
"@esbuild/win32-x64" "0.28.2"
escape-string-regexp@^4.0.0:
version "4.0.0"
@ -1881,10 +1881,10 @@ eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
eslint@^10.8.0:
version "10.8.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.8.0.tgz#e6d19907a3f090a53a022261ba34c5ff6d04908b"
integrity sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==
eslint@^10.8.1:
version "10.8.1"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.8.1.tgz#fb37d514c19b6dd5b2d6b70169fd26fddfa97967"
integrity sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==
dependencies:
"@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.2"
@ -2168,10 +2168,10 @@ globals@^14.0.0:
resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
globals@^17.8.0:
version "17.8.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.8.0.tgz#a1f213a06adcd0eec38004c5cd39fef7af7e0830"
integrity sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==
globals@^17.9.0:
version "17.9.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.9.0.tgz#e43f252d6bbe71508da43902a1709c8895a59f70"
integrity sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==
globalthis@^1.0.3, globalthis@^1.0.4:
version "1.0.4"
@ -2964,10 +2964,10 @@ prettier-linter-helpers@^1.0.1:
dependencies:
fast-diff "^1.1.2"
prettier@^3.9.5:
version "3.9.5"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.5.tgz#4fec97736e33b9d0b620b48914fe93b530e835ad"
integrity sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==
prettier@^3.9.6:
version "3.9.6"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.6.tgz#b3ea5146515d40fc53f18aa63f74dfab1e10dbf6"
integrity sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==
punycode.js@^2.3.1:
version "2.3.1"
@ -2979,12 +2979,12 @@ punycode@^2.1.0:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
query-string@9.4.1:
version "9.4.1"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.4.1.tgz#fc5b1cc5c1944eaa1c8e6d4912b5bb90dc66fa2d"
integrity sha512-lSyJeN3RuaG7DZGWThtYRhk96+kEyZ/+doZpERuWbjeFL+Ok3vEat/swU498rAI0NcVt5/RJp8UDuLz7FckxrA==
query-string@9.5.0:
version "9.5.0"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.5.0.tgz#e6f4003dcb321580dd043109b6fbe2a3890afff6"
integrity sha512-YlJmwNyi0RGYjlxYcuDncMsxFU7YyutbuI7gTm8ySxIGBlwx5yiBCOD5ig9ZNoHkawk/1Dey0N5mEfcUybMVAA==
dependencies:
decode-uri-component "^0.4.1"
decode-uri-component "^0.5.0"
filter-obj "^5.1.0"
split-on-first "^3.0.0"

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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