Merge branch 'main' into feature
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
f12fe46486
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'):
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
version: "4.6.8"
|
||||
edition: "Community"
|
||||
build: "rc2"
|
||||
published: "2026-08-04"
|
||||
published: "2026-08-11"
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue