Merge branch 'main' into feature

# Conflicts:
#	contrib/openapi.json
#	netbox/core/forms/filtersets.py
#	netbox/core/tests/test_openapi_schema.py
#	netbox/dcim/forms/mixins.py
#	netbox/extras/events.py
#	netbox/ipam/forms/bulk_edit.py
#	netbox/ipam/forms/model_forms.py
#	netbox/ipam/models/services.py
#	netbox/ipam/tests/test_forms.py
#	netbox/ipam/tests/test_models.py
#	netbox/ipam/tests/test_views.py
#	netbox/netbox/jobs.py
#	netbox/project-static/dist/netbox.js
#	netbox/project-static/dist/netbox.js.map
#	netbox/release.yaml
#	requirements.txt
This commit is contained in:
Jeremy Stretch 2026-09-01 16:46:44 -04:00
commit 56693d62ae
88 changed files with 7806 additions and 5473 deletions

37
.github/workflows/enforce-milestone.yml vendored Normal file
View File

@ -0,0 +1,37 @@
name: Enforce milestone on close
on:
issues:
types:
- closed
permissions:
issues: write
jobs:
check-milestone:
name: Check Milestone
if: github.repository == 'netbox-community/netbox' && github.event.issue.state_reason == 'completed'
runs-on: ubuntu-slim
steps:
- name: Reopen issues completed without a milestone
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
ISSUE: ${{ github.event.issue.number }}
run: |
# Grace period, in case the milestone is assigned immediately after closure
sleep 90
# Re-check the issue: bail out if it has been reopened or a milestone has since been set
DATA=$(gh issue view "$ISSUE" --json state,milestone)
STATE=$(jq -r '.state' <<< "$DATA")
MILESTONE=$(jq -r '.milestone.title // ""' <<< "$DATA")
if [ "$STATE" != "CLOSED" ] || [ -n "$MILESTONE" ]; then
echo "Nothing to do (state=$STATE, milestone=${MILESTONE:-none})"
exit 0
fi
gh issue reopen "$ISSUE" --comment \
"This issue was closed as completed without a milestone assigned, and has been reopened automatically. Please assign the milestone for the upcoming release, then close the issue again."

View File

@ -203919,15 +203919,27 @@
"in": "query",
"name": "cluster",
"schema": {
"type": "integer"
}
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
},
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "cluster_group",
"schema": {
"type": "integer"
}
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
},
"explode": true,
"style": "form"
},
{
"in": "query",
@ -204383,8 +204395,14 @@
"in": "query",
"name": "location",
"schema": {
"type": "integer"
}
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
},
"explode": true,
"style": "form"
},
{
"in": "query",
@ -204689,22 +204707,40 @@
"in": "query",
"name": "rack",
"schema": {
"type": "integer"
}
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
},
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "rack_group",
"schema": {
"type": "number"
}
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
},
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "region",
"schema": {
"type": "integer"
}
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
},
"explode": true,
"style": "form"
},
{
"in": "query",
@ -204819,15 +204855,27 @@
"in": "query",
"name": "site",
"schema": {
"type": "integer"
}
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
},
"explode": true,
"style": "form"
},
{
"in": "query",
"name": "site_group",
"schema": {
"type": "integer"
}
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
},
"explode": true,
"style": "form"
},
{
"in": "query",
@ -270547,6 +270595,42 @@
"user"
]
},
"BriefASN": {
"type": "object",
"description": "Base serializer class for models inheriting from PrimaryModel.",
"properties": {
"id": {
"type": "integer",
"readOnly": true
},
"url": {
"type": "string",
"format": "uri",
"readOnly": true
},
"display": {
"type": "string",
"readOnly": true
},
"asn": {
"type": "integer",
"maximum": 4294967295,
"minimum": 1,
"format": "int64",
"description": "16- or 32-bit autonomous system number"
},
"description": {
"type": "string",
"maxLength": 200
}
},
"required": [
"asn",
"display",
"id",
"url"
]
},
"BriefCable": {
"type": "object",
"description": "Base serializer class for models inheriting from PrimaryModel.",
@ -271220,6 +271304,57 @@
"url"
]
},
"BriefContactGroup": {
"type": "object",
"description": "Base serializer class for models inheriting from NestedGroupModel.",
"properties": {
"id": {
"type": "integer",
"readOnly": true
},
"url": {
"type": "string",
"format": "uri",
"readOnly": true
},
"display": {
"type": "string",
"readOnly": true
},
"name": {
"type": "string",
"maxLength": 100
},
"slug": {
"type": "string",
"maxLength": 100,
"pattern": "^[-a-zA-Z0-9_]+$"
},
"description": {
"type": "string",
"maxLength": 200
},
"contact_count": {
"type": "integer",
"readOnly": true,
"default": 0
},
"_depth": {
"type": "integer",
"readOnly": true,
"title": " depth"
}
},
"required": [
"_depth",
"contact_count",
"display",
"id",
"name",
"slug",
"url"
]
},
"BriefContactRequest": {
"type": "object",
"description": "Base serializer class for models inheriting from PrimaryModel.",
@ -271905,6 +272040,39 @@
"protocol"
]
},
"BriefGroup": {
"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": {
"type": "string",
"readOnly": true
},
"name": {
"type": "string",
"maxLength": 150
},
"description": {
"type": "string",
"maxLength": 200
}
},
"required": [
"display",
"id",
"name",
"url"
]
},
"BriefIKEPolicy": {
"type": "object",
"description": "Base serializer class for models inheriting from PrimaryModel.",
@ -271956,6 +272124,39 @@
"name"
]
},
"BriefIKEProposal": {
"type": "object",
"description": "Base serializer class for models inheriting from PrimaryModel.",
"properties": {
"id": {
"type": "integer",
"readOnly": true
},
"url": {
"type": "string",
"format": "uri",
"readOnly": true
},
"display": {
"type": "string",
"readOnly": true
},
"name": {
"type": "string",
"maxLength": 100
},
"description": {
"type": "string",
"maxLength": 200
}
},
"required": [
"display",
"id",
"name",
"url"
]
},
"BriefIPAddress": {
"type": "object",
"description": "Base serializer class for models inheriting from PrimaryModel.",
@ -272182,6 +272383,39 @@
"name"
]
},
"BriefIPSecProposal": {
"type": "object",
"description": "Base serializer class for models inheriting from PrimaryModel.",
"properties": {
"id": {
"type": "integer",
"readOnly": true
},
"url": {
"type": "string",
"format": "uri",
"readOnly": true
},
"display": {
"type": "string",
"readOnly": true
},
"name": {
"type": "string",
"maxLength": 100
},
"description": {
"type": "string",
"maxLength": 200
}
},
"required": [
"display",
"id",
"name",
"url"
]
},
"BriefInterface": {
"type": "object",
"description": "Mixin for Interface and VMInterface serializers that adds a `mac_address` shortcut field for\ncreating/updating the primary MACAddress in a single request. The validated write is centralized\non the interface model (BaseInterface.set_primary_mac_address); this mixin is a thin adapter that\nowns the permission check, the shortcut-vs-primary_mac_address conflict guard, and translating the\nmodel's validation errors into API errors.",
@ -272804,6 +273038,66 @@
"url"
]
},
"BriefModuleBayType": {
"type": "object",
"description": "Base serializer class for models inheriting from PrimaryModel.",
"properties": {
"id": {
"type": "integer",
"readOnly": true
},
"url": {
"type": "string",
"format": "uri",
"readOnly": true
},
"display": {
"type": "string",
"readOnly": true
},
"name": {
"type": "string",
"maxLength": 100
},
"slug": {
"type": "string",
"maxLength": 100,
"pattern": "^[-a-zA-Z0-9_]+$"
},
"manufacturer": {
"allOf": [
{
"$ref": "#/components/schemas/BriefManufacturer"
}
],
"nullable": true
},
"color": {
"oneOf": [
{
"type": "string",
"pattern": "^[0-9a-f]{6}$",
"maxLength": 6
},
{
"type": "string",
"maxLength": 0
}
]
},
"description": {
"type": "string",
"maxLength": 200
}
},
"required": [
"display",
"id",
"name",
"slug",
"url"
]
},
"BriefModuleRequest": {
"type": "object",
"description": "Base serializer class for models inheriting from PrimaryModel.",
@ -272973,6 +273267,58 @@
"model"
]
},
"BriefObjectPermission": {
"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": {
"type": "string",
"readOnly": true
},
"name": {
"type": "string",
"maxLength": 100
},
"description": {
"type": "string",
"maxLength": 200
},
"enabled": {
"type": "boolean"
},
"object_types": {
"type": "array",
"items": {
"type": "string"
}
},
"actions": {
"type": "array",
"items": {
"type": "string",
"maxLength": 30
},
"description": "The list of actions granted by this permission"
}
},
"required": [
"actions",
"display",
"id",
"name",
"object_types",
"url"
]
},
"BriefOwner": {
"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)",
@ -273990,6 +274336,40 @@
"slug"
]
},
"BriefRouteTarget": {
"type": "object",
"description": "Base serializer class for models inheriting from PrimaryModel.",
"properties": {
"id": {
"type": "integer",
"readOnly": true
},
"url": {
"type": "string",
"format": "uri",
"readOnly": true
},
"display": {
"type": "string",
"readOnly": true
},
"name": {
"type": "string",
"description": "Route target value (formatted in accordance with RFC 4360)",
"maxLength": 21
},
"description": {
"type": "string",
"maxLength": 200
}
},
"required": [
"display",
"id",
"name",
"url"
]
},
"BriefSite": {
"type": "object",
"description": "Base serializer class for models inheriting from PrimaryModel.",
@ -274931,6 +275311,49 @@
"slug"
]
},
"BriefVirtualDeviceContext": {
"type": "object",
"description": "Base serializer class for models inheriting from PrimaryModel.",
"properties": {
"id": {
"type": "integer",
"readOnly": true
},
"url": {
"type": "string",
"format": "uri",
"readOnly": true
},
"display": {
"type": "string",
"readOnly": true
},
"name": {
"type": "string",
"maxLength": 64
},
"device": {
"$ref": "#/components/schemas/BriefDevice"
},
"identifier": {
"type": "integer",
"maximum": 32767,
"minimum": 0,
"nullable": true
},
"description": {
"type": "string",
"maxLength": 200
}
},
"required": [
"device",
"display",
"id",
"name",
"url"
]
},
"BriefVirtualMachine": {
"type": "object",
"description": "Base serializer class for models inheriting from PrimaryModel.",
@ -275046,6 +275469,39 @@
"slug"
]
},
"BriefWirelessLAN": {
"type": "object",
"description": "Base serializer class for models inheriting from PrimaryModel.",
"properties": {
"id": {
"type": "integer",
"readOnly": true
},
"url": {
"type": "string",
"format": "uri",
"readOnly": true
},
"display": {
"type": "string",
"readOnly": true
},
"ssid": {
"type": "string",
"maxLength": 32
},
"description": {
"type": "string",
"maxLength": 200
}
},
"required": [
"display",
"id",
"ssid",
"url"
]
},
"BriefWirelessLANGroup": {
"type": "object",
"description": "Base serializer class for models inheriting from NestedGroupModel.",
@ -292096,73 +292552,73 @@
"regions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Region"
"$ref": "#/components/schemas/BriefRegion"
}
},
"site_groups": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SiteGroup"
"$ref": "#/components/schemas/BriefSiteGroup"
}
},
"sites": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Site"
"$ref": "#/components/schemas/BriefSite"
}
},
"locations": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Location"
"$ref": "#/components/schemas/BriefLocation"
}
},
"device_types": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeviceType"
"$ref": "#/components/schemas/BriefDeviceType"
}
},
"roles": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeviceRole"
"$ref": "#/components/schemas/BriefDeviceRole"
}
},
"platforms": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Platform"
"$ref": "#/components/schemas/BriefPlatform"
}
},
"cluster_types": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ClusterType"
"$ref": "#/components/schemas/BriefClusterType"
}
},
"cluster_groups": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ClusterGroup"
"$ref": "#/components/schemas/BriefClusterGroup"
}
},
"clusters": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Cluster"
"$ref": "#/components/schemas/BriefCluster"
}
},
"tenant_groups": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TenantGroup"
"$ref": "#/components/schemas/BriefTenantGroup"
}
},
"tenants": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Tenant"
"$ref": "#/components/schemas/BriefTenant"
}
},
"owner": {
@ -293863,7 +294319,7 @@
"groups": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ContactGroup"
"$ref": "#/components/schemas/BriefContactGroup"
}
},
"name": {
@ -301161,7 +301617,7 @@
"permissions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ObjectPermission"
"$ref": "#/components/schemas/BriefObjectPermission"
}
},
"user_count": {
@ -301277,7 +301733,7 @@
"proposals": {
"type": "array",
"items": {
"$ref": "#/components/schemas/IKEProposal"
"$ref": "#/components/schemas/BriefIKEProposal"
}
},
"preshared_key": {
@ -302433,7 +302889,7 @@
"proposals": {
"type": "array",
"items": {
"$ref": "#/components/schemas/IPSecProposal"
"$ref": "#/components/schemas/BriefIPSecProposal"
}
},
"pfs_group": {
@ -303218,7 +303674,7 @@
"vdcs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/VirtualDeviceContext"
"$ref": "#/components/schemas/BriefVirtualDeviceContext"
}
},
"module": {
@ -304388,7 +304844,7 @@
"tagged_vlans": {
"type": "array",
"items": {
"$ref": "#/components/schemas/VLAN"
"$ref": "#/components/schemas/BriefVLAN"
}
},
"qinq_svlan": {
@ -304455,7 +304911,7 @@
"wireless_lans": {
"type": "array",
"items": {
"$ref": "#/components/schemas/WirelessLAN"
"$ref": "#/components/schemas/BriefWirelessLAN"
}
},
"vrf": {
@ -307459,13 +307915,13 @@
"import_targets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RouteTarget"
"$ref": "#/components/schemas/BriefRouteTarget"
}
},
"export_targets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RouteTarget"
"$ref": "#/components/schemas/BriefRouteTarget"
}
},
"description": {
@ -308513,7 +308969,7 @@
"module_bay_types": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ModuleBayType"
"$ref": "#/components/schemas/BriefModuleBayType"
}
},
"installed_module": {
@ -308741,7 +309197,7 @@
"module_bay_types": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ModuleBayType"
"$ref": "#/components/schemas/BriefModuleBayType"
}
},
"created": {
@ -309278,7 +309734,7 @@
"module_bay_types": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ModuleBayType"
"$ref": "#/components/schemas/BriefModuleBayType"
}
},
"owner": {
@ -310990,13 +311446,13 @@
"groups": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Group"
"$ref": "#/components/schemas/BriefGroup"
}
},
"users": {
"type": "array",
"items": {
"$ref": "#/components/schemas/User"
"$ref": "#/components/schemas/BriefUser"
}
}
},
@ -311453,13 +311909,13 @@
"user_groups": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Group"
"$ref": "#/components/schemas/BriefGroup"
}
},
"users": {
"type": "array",
"items": {
"$ref": "#/components/schemas/User"
"$ref": "#/components/schemas/BriefUser"
}
}
},
@ -346926,7 +347382,7 @@
"asns": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ASN"
"$ref": "#/components/schemas/BriefASN"
}
},
"tags": {
@ -351193,7 +351649,7 @@
"ipaddresses": {
"type": "array",
"items": {
"$ref": "#/components/schemas/IPAddress"
"$ref": "#/components/schemas/BriefIPAddress"
}
},
"description": {
@ -351668,7 +352124,7 @@
"asns": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ASN"
"$ref": "#/components/schemas/BriefASN"
}
},
"tags": {
@ -353816,13 +354272,13 @@
"groups": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Group"
"$ref": "#/components/schemas/BriefGroup"
}
},
"permissions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ObjectPermission"
"$ref": "#/components/schemas/BriefObjectPermission"
}
}
},
@ -354728,7 +355184,7 @@
"tagged_vlans": {
"type": "array",
"items": {
"$ref": "#/components/schemas/VLAN"
"$ref": "#/components/schemas/BriefVLAN"
}
},
"qinq_svlan": {

View File

@ -1,6 +1,6 @@
# Front Ports
Front ports are pass-through ports which represent physical cable connections that comprise part of a longer path. For example, the ports on the front face of a UTP patch panel would be modeled in NetBox as front ports. Each port is assigned a physical type, and must be mapped to a specific [rear port](./rearport.md) on the same device. A single rear port may be mapped to multiple front ports, using numeric positions to annotate the specific alignment of each.
Front ports are pass-through ports which represent physical cable connections that comprise part of a longer path. For example, the ports on the front face of a UTP patch panel would be modeled in NetBox as front ports. Each port is assigned a physical type, and must be mapped to one or more [rear port](./rearport.md) positions on the same device. A single rear port may be mapped to multiple front ports, using numeric positions to annotate the specific alignment of each.
!!! tip
Like most device components, front ports are instantiated automatically from [front port templates](./frontporttemplate.md) assigned to the selected device type when a device is created.
@ -27,12 +27,16 @@ An alternative physical label identifying the port.
The port's termination type.
### Positions
The number of [rear port](./rearport.md) positions to which this front port maps. For a front port which passes through to a single rear port position, set this to `1`.
### Rear Ports
The rear port and position to which this front port maps.
!!! tip
When creating multiple front ports using a patterned name (e.g. `Port [1-12]`), you may select the equivalent number of rear port-position mappings from the list.
When creating multiple front ports using a patterned name (e.g. `Port [1-12]`), select one rear port-position mapping for every position of every front port being created. For example, 12 front ports with two positions each requires 24 mappings, which are assigned to the generated ports in order.
### Color

View File

@ -1,5 +1,29 @@
# NetBox v4.6
## v4.6.10 (2026-09-01)
### Performance Improvements
* [#22988](https://github.com/netbox-community/netbox/issues/22988) - Prefetch the related fields of nested serializers referenced by `SerializedPKRelatedField`
* [#23060](https://github.com/netbox-community/netbox/issues/23060) - Include reverse many-to-many relationships when determining REST API prefetches
### Bug Fixes
* [#22671](https://github.com/netbox-community/netbox/issues/22671) - Support the selection of multiple values when filtering VLAN groups by scope (e.g. by site group)
* [#22872](https://github.com/netbox-community/netbox/issues/22872) - Gracefully handle invalid values assigned to a custom script's `Meta` attributes
* [#22989](https://github.com/netbox-community/netbox/issues/22989) - Reference brief components in the OpenAPI response schemas for nested `SerializedPKRelatedField` fields
* [#23035](https://github.com/netbox-community/netbox/issues/23035) - Expand the active navigation menu section when resizing from a mobile to a desktop viewport
* [#23038](https://github.com/netbox-community/netbox/issues/23038) - Omit the cache-busting query parameter from signed static file URLs (e.g. when using S3 storage)
* [#23040](https://github.com/netbox-community/netbox/issues/23040) - Fix the validation error raised when removing the scope assigned to a VLAN group
* [#23043](https://github.com/netbox-community/netbox/issues/23043) - Correct the validation of front port positions when creating front ports in bulk
* [#23052](https://github.com/netbox-community/netbox/issues/23052) - Fix the duplicated `owner` field on the power outlet and service forms
* [#23066](https://github.com/netbox-community/netbox/issues/23066) - Include the configured Redis username in the default Django cache settings
* [#23072](https://github.com/netbox-community/netbox/issues/23072) - Retain cable paths when applying a cable profile via bulk edit
* [#23078](https://github.com/netbox-community/netbox/issues/23078) - Normalize `update_fields` to avoid consuming a generator in `VLANGroup.save()` and `CircuitTermination.save()`
* [#23090](https://github.com/netbox-community/netbox/issues/23090) - Fix the filtering of background jobs by user in the UI
---
## v4.6.9 (2026-08-25)
### Enhancements

View File

@ -17,6 +17,7 @@ from netbox.models.features import (
TagsMixin,
)
from netbox.models.mixins import DistanceMixin
from utilities.data import normalize_update_fields
from utilities.string import title
from .base import BaseCircuitType
@ -382,7 +383,7 @@ class CircuitTermination(
def save(self, *args, **kwargs):
is_new = self._state.adding
update_fields = kwargs.get('update_fields')
update_fields = normalize_update_fields(kwargs)
# Only consider circuit/term_side changes if those fields
# are actually being persisted

View File

@ -76,6 +76,28 @@ class CircuitTerminationTestCase(TestCase):
# New circuit's cache should be populated
self.assertEqual(self.circuits[1].termination_a, termination)
def test_circuit_termination_circuit_change_with_generator_update_fields(self):
"""
A one-shot iterable passed as update_fields must still reach the database, so the
circuit change is persisted and both caches are updated.
"""
termination = CircuitTermination.objects.create(
circuit=self.circuits[0],
term_side='A',
termination=self.sites[0],
)
termination.circuit = self.circuits[1]
termination.save(update_fields=(field for field in ('circuit',)))
termination.refresh_from_db()
self.circuits[0].refresh_from_db()
self.circuits[1].refresh_from_db()
self.assertEqual(termination.circuit, self.circuits[1])
self.assertIsNone(self.circuits[0].termination_a)
self.assertEqual(self.circuits[1].termination_a, termination)
def test_circuit_termination_term_side_change_clears_old_cache(self):
"""
When a CircuitTermination's term_side is changed, the old side's cache should be cleared

View File

@ -320,8 +320,10 @@ class NetBoxAutoSchema(AutoSchema):
def _get_serializer_name(self, serializer, direction, bypass_extensions=False) -> str:
name = super()._get_serializer_name(serializer, direction, bypass_extensions)
# If this serializer is nested, prepend its name with "Brief"
if getattr(serializer, 'nested', False):
# If this serializer is nested, prepend its name with "Brief". Serializers which declare an explicit
# Meta.ref_name are exempt: those are brief by design and have no complete form in the schema, so the
# prefix would only rename an existing component to no purpose. See #22989.
if getattr(serializer, 'nested', False) and not getattr(getattr(serializer, 'Meta', None), 'ref_name', None):
name = f'Brief{name}'
return name
@ -536,7 +538,11 @@ class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
def map_serializer_field(self, auto_schema, direction):
if direction == "response":
component = auto_schema.resolve_serializer(self.target.serializer, direction)
# Resolve an instance of the serializer carrying the field's nested setting, so that the brief
# component is referenced wherever the field renders a brief representation. (The field's
# to_representation() passes nested in the same manner.) See #22989.
serializer = self.target.serializer(nested=self.target.nested)
component = auto_schema.resolve_serializer(serializer, direction)
return component.ref if component else None
return build_basic_type(OpenApiTypes.INT)

View File

@ -75,7 +75,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
model = Job
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type_id', 'status', 'queue_name', 'user', name=_('Attributes')),
FieldSet('object_type_id', 'status', 'queue_name', 'user_id', name=_('Attributes')),
FieldSet(
'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
'started__after', 'completed__before', 'completed__after', name=_('Scheduling')
@ -136,7 +136,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
required=False,
widget=DateTimePicker()
)
user = DynamicModelMultipleChoiceField(
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
label=_('User')

View File

@ -5,21 +5,30 @@ Refs: #20638
"""
import json
from django.test import TestCase
from django.test import SimpleTestCase, TestCase
from core.api.schema import NetBoxAutoSchema
from core.api.schema import FixSerializedPKRelatedField, NetBoxAutoSchema
from dcim.api.serializers import SiteSerializer
from dcim.models import Site
from ipam.api.serializers import ServiceSerializer
from netbox.api.fields import SerializedPKRelatedField
from netbox.api.serializers import BulkOperationErrorSerializer
class OpenAPISchemaTestCase(TestCase):
"""Tests for OpenAPI schema generation."""
def setUp(self):
"""Fetch schema via API endpoint."""
response = self.client.get('/api/schema/', {'format': 'json'})
self.assertEqual(response.status_code, 200)
self.schema = json.loads(response.content)
@classmethod
def setUpClass(cls):
"""
Fetch the schema via the API endpoint. Schema generation is expensive and its output is
immutable across these tests, so do this once for the class rather than per test method.
"""
super().setUpClass()
response = cls.client_class().get('/api/schema/', {'format': 'json'})
assert response.status_code == 200, f'Failed to generate OpenAPI schema (HTTP {response.status_code})'
cls.schema = json.loads(response.content)
def test_post_operation_documents_single_or_array(self):
"""
@ -241,6 +250,87 @@ class OpenAPISchemaTestCase(TestCase):
for field in ('port_mappings', 'protocol', 'ports'):
self.assertIn(field, properties, f"{ref} should document the '{field}' field")
def test_nested_related_fields_reference_brief_components(self):
"""
A SerializedPKRelatedField declared with nested=True must reference the brief component in
response schemas, as that is what the API returns.
Refs: #22989
"""
components = self.schema['components']['schemas']
for component, field, ref in (
('Site', 'asns', 'BriefASN'),
('ConfigContext', 'sites', 'BriefSite'),
('Interface', 'tagged_vlans', 'BriefVLAN'),
):
with self.subTest(component=component, field=field):
self.assertEqual(
components[component]['properties'][field]['items']['$ref'],
f'#/components/schemas/{ref}'
)
# The brief component must advertise only the serializer's brief fields
self.assertEqual(
set(components['BriefASN']['properties']),
{'id', 'url', 'display', 'asn', 'description'}
)
def test_ref_name_exempts_serializer_from_brief_prefix(self):
"""
A serializer which declares an explicit Meta.ref_name keeps that name when nested, rather than
acquiring a Brief prefix. These serializers are brief by design and have no complete form in the
schema, so prefixing them would rename an existing component to no purpose.
Refs: #22989
"""
components = self.schema['components']['schemas']
for component, field, ref in (
('ASN', 'sites', 'ASNSite'),
('ObjectPermission', 'groups', 'NestedGroup'),
('ObjectPermission', 'users', 'NestedUser'),
):
with self.subTest(component=component, field=field):
self.assertEqual(
components[component]['properties'][field]['items']['$ref'],
f'#/components/schemas/{ref}'
)
self.assertNotIn(f'Brief{ref}', components)
def test_non_nested_related_fields_reference_full_components(self):
"""
A SerializedPKRelatedField declared without nested=True must continue to reference the
complete component.
Refs: #22989
"""
components = self.schema['components']['schemas']
for field in ('import_targets', 'export_targets'):
with self.subTest(field=field):
self.assertEqual(
components['VRF']['properties'][field]['items']['$ref'],
'#/components/schemas/RouteTarget'
)
def test_nested_related_fields_accept_pks_on_write(self):
"""
Request schemas for a SerializedPKRelatedField must continue to accept an array of integer
primary keys.
Refs: #22989
"""
components = self.schema['components']['schemas']
for component, field in (
('SiteRequest', 'asns'),
('ConfigContextRequest', 'sites'),
('ASNRequest', 'sites'),
):
with self.subTest(component=component, field=field):
self.assertEqual(components[component]['properties'][field]['items']['type'], 'integer')
class WritableFieldRebuildTestCase(TestCase):
"""
@ -284,3 +374,57 @@ class WritableFieldRebuildTestCase(TestCase):
def test_serializer_without_model(self):
"""A serializer with no Meta.model has nothing to rebuild from."""
self.assertFalse(NetBoxAutoSchema._rebuilds_as_writable(BulkOperationErrorSerializer(), 'id'))
class SerializedPKRelatedFieldSchemaTestCase(SimpleTestCase):
"""Tests for the schema extension which maps SerializedPKRelatedField."""
class DummyComponent:
ref = {'$ref': '#/components/schemas/Dummy'}
class DummyAutoSchema:
"""Records the serializer resolved by the extension, in place of generating a component."""
def __init__(self):
self.resolved = []
def resolve_serializer(self, serializer, direction):
self.resolved.append(serializer)
return SerializedPKRelatedFieldSchemaTestCase.DummyComponent
def test_nested_flag_is_passed_to_serializer(self):
"""
The field's serializer must be instantiated with the field's nested setting, so that the
component matching the rendered representation is referenced.
Refs: #22989
"""
for nested in (True, False):
with self.subTest(nested=nested):
field = SerializedPKRelatedField(
serializer=SiteSerializer,
queryset=Site.objects.all(),
nested=nested
)
auto_schema = self.DummyAutoSchema()
schema = FixSerializedPKRelatedField(field).map_serializer_field(auto_schema, 'response')
serializer = auto_schema.resolved[0]
self.assertIsInstance(serializer, SiteSerializer)
self.assertEqual(serializer.nested, nested)
self.assertEqual(schema, self.DummyComponent.ref)
def test_request_schema_is_an_integer(self):
"""
Request schemas must document an integer primary key, regardless of the nested setting.
Refs: #22989
"""
field = SerializedPKRelatedField(serializer=SiteSerializer, queryset=Site.objects.all(), nested=True)
auto_schema = self.DummyAutoSchema()
schema = FixSerializedPKRelatedField(field).map_serializer_field(auto_schema, 'request')
self.assertEqual(schema['type'], 'integer')
self.assertEqual(auto_schema.resolved, [])

View File

@ -109,19 +109,26 @@ class FrontPortFormMixin(forms.Form):
def clean(self):
super().clean()
# Check that the total number of FrontPorts and positions matches the selected number of RearPort:position
# mappings. Note that `name` will be a list under FrontPortCreateForm, in which cases we multiply the number of
# FrontPorts being creation by the number of positions.
positions = self.cleaned_data['positions']
frontport_count = len(self.cleaned_data['name']) if type(self.cleaned_data['name']) is list else 1
rearport_count = len(self.cleaned_data['rear_ports'])
if frontport_count * positions != rearport_count:
# All three are required fields, so bail out if any of them failed its own validation
positions = self.cleaned_data.get('positions')
name = self.cleaned_data.get('name')
rear_ports = self.cleaned_data.get('rear_ports')
if not (positions and name and rear_ports):
return
# `name` is a list under FrontPortCreateForm, and each generated FrontPort consumes `positions` mappings
frontport_count = len(name) if isinstance(name, list) else 1
frontport_position_count = frontport_count * positions
rearport_count = len(rear_ports)
# {frontport_count} receives the position total. Its name is unchanged to keep existing translations valid.
if frontport_position_count != rearport_count:
raise forms.ValidationError({
'rear_ports': _(
"The total number of front port positions ({frontport_count}) must match the selected number of "
"rear port positions ({rearport_count})."
).format(
frontport_count=frontport_count,
frontport_count=frontport_position_count,
rearport_count=rearport_count
)
})

View File

@ -1953,7 +1953,7 @@ class PowerOutletForm(ModularDeviceComponentForm):
fieldsets = (
FieldSet(
'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected',
'description', 'owner', 'tags',
'description', 'tags',
),
)
@ -1961,7 +1961,7 @@ class PowerOutletForm(ModularDeviceComponentForm):
model = PowerOutlet
fields = [
'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected',
'description', 'tags',
'description', 'owner', 'tags',
]

View File

@ -71,18 +71,20 @@ class ComponentCreateForm(forms.Form):
return
pattern_count = len(patterns)
for field_name in self.replication_fields:
value_count = len(self.cleaned_data[field_name])
if self.cleaned_data[field_name]:
if value_count == 1:
# If the field resolves to a single value (because no pattern was used), multiply it by the number
# of expected values. This allows us to reuse the same label when creating multiple components.
self.cleaned_data[field_name] = self.cleaned_data[field_name] * pattern_count
elif value_count != pattern_count:
raise forms.ValidationError({
field_name: _(
"The provided pattern specifies {value_count} values, but {pattern_count} are expected."
).format(value_count=value_count, pattern_count=pattern_count)
}, code='label_pattern_mismatch')
# A field is absent from cleaned_data if it failed its own validation, e.g. an inverted numeric range
if not (values := self.cleaned_data.get(field_name)):
continue
value_count = len(values)
if value_count == 1:
# If the field resolves to a single value (because no pattern was used), multiply it by the number
# of expected values. This allows us to reuse the same label when creating multiple components.
self.cleaned_data[field_name] = values * pattern_count
elif value_count != pattern_count:
raise forms.ValidationError({
field_name: _(
"The provided pattern specifies {value_count} values, but {pattern_count} are expected."
).format(value_count=value_count, pattern_count=pattern_count)
}, code='label_pattern_mismatch')
#

View File

@ -23,6 +23,7 @@ from dcim.utils import decompile_path_node, object_to_path_node
from netbox.choices import ColorChoices
from netbox.models import ChangeLoggedModel, PrimaryModel
from utilities.conversion import to_meters
from utilities.data import normalize_update_fields
from utilities.exceptions import AbortRequest
from utilities.fields import ColorField, GenericArrayForeignKey
from utilities.querysets import RestrictedQuerySet, chunked_update
@ -321,6 +322,11 @@ class Cable(PrimaryModel):
def save(self, *args, force_insert=False, force_update=False, using=None, update_fields=None):
_created = self.pk is None
save_kwargs = {
'using': using,
'update_fields': update_fields,
}
update_fields = normalize_update_fields(save_kwargs)
# Store the given length (if any) in meters for use in database ordering
if self.length is not None and self.length_unit:
@ -332,24 +338,35 @@ class Cable(PrimaryModel):
if self.length is None:
self.length_unit = None
# A field counts as changed only when this save actually writes it
status_written = update_fields is None or 'status' in update_fields
profile_written = update_fields is None or 'profile' in update_fields
# If this is a new Cable, save it before attempting to create its CableTerminations
if self._state.adding:
super().save(*args, force_insert=True, using=using, update_fields=update_fields)
super().save(*args, force_insert=True, **save_kwargs)
# Update the private PK used in __str__()
self._pk = self.pk
if self._orig_profile != self.profile:
if profile_written and self._orig_profile != self.profile:
self.update_terminations(force=True)
elif self._terminations_modified:
self.update_terminations()
super().save(*args, force_update=True, using=using, update_fields=update_fields)
super().save(*args, force_update=True, **save_kwargs)
try:
trace_paths.send(Cable, instance=self, created=_created)
except UnsupportedCablePath as e:
raise AbortRequest(e)
# Reset change tracking for the next save of this instance
if status_written:
self._orig_status = self.status
if profile_written:
self._orig_profile = self.profile
self._terminations_modified = False
def delete(self, *args, **kwargs):
# Track this Cable as being deleted so the post_delete signal handler
# for cascaded CableTerminations can skip redundant path retracing;
@ -501,6 +518,9 @@ class Cable(PrimaryModel):
if force_b and not hasattr(self, '_b_terminations'):
self._b_terminations = list(b_terminations.keys())
# Recreating terminations invalidates existing paths, even when the endpoints are unchanged
self._terminations_modified = True
# Delete any stale CableTerminations
for termination, ct in a_terminations.items():
if force_a or (termination.pk and termination not in self.a_terminations):

View File

@ -33,7 +33,7 @@
"frontport:api_list_objects": 14,
"frontport:list_objects_with_permission": 22,
"frontporttemplate:api_list_objects": 12,
"interface:api_list_objects": 22,
"interface:api_list_objects": 23,
"interface:list_objects_with_permission": 18,
"interfaceconnection:list_objects_with_permission": 41,
"interfacetemplate:api_list_objects": 11,

View File

@ -3517,6 +3517,8 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
VirtualDeviceContext(name='VDC 2', identifier=2, device=device)
)
VirtualDeviceContext.objects.bulk_create(vdcs)
for interface in interfaces:
interface.vdcs.set(vdcs)
vlans = (
VLAN(name='VLAN 1', vid=1),

View File

@ -15,7 +15,7 @@ class CablePathTestCase(BaseCablePathTestCase):
Tests are numbered as follows:
1XX: Test direct connections using each profile
2XX: Topology tests replicated from the legacy test case and adapted to use profiles
3XX: Dynamic port mapping and termination changes
3XX: Dynamic port mapping, profile and termination changes
"""
def test_101_cable_profile_single_1c1p(self):
@ -2512,3 +2512,276 @@ class CablePathTestCase(BaseCablePathTestCase):
is_complete=True,
is_active=True
)
def test_307_change_cable_profile_rebuilds_paths(self):
"""
[IF1] --C1-- [IF2]
Applying a profile to an existing cable rebuilds its paths.
"""
interfaces = [
Interface.objects.create(device=self.device, name='Interface 1'),
Interface.objects.create(device=self.device, name='Interface 2'),
]
# Create cable 1 without a profile
cable1 = Cable(
a_terminations=[interfaces[0]],
b_terminations=[interfaces[1]],
)
cable1.clean()
cable1.save()
self.assertPathExists(
(interfaces[0], cable1, interfaces[1]),
is_complete=True,
is_active=True
)
self.assertPathExists(
(interfaces[1], cable1, interfaces[0]),
is_complete=True,
is_active=True
)
# Reload so _terminations_modified starts False, as it does in a bulk edit
cable1 = Cable.objects.get(pk=cable1.pk)
cable1.profile = CableProfileChoices.SINGLE_1C1P
cable1.full_clean()
cable1.save()
path1 = self.assertPathExists(
(interfaces[0], cable1, interfaces[1]),
is_complete=True,
is_active=True
)
path2 = self.assertPathExists(
(interfaces[1], cable1, interfaces[0]),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
interfaces[0].refresh_from_db()
interfaces[1].refresh_from_db()
self.assertPathIsSet(interfaces[0], path1)
self.assertPathIsSet(interfaces[1], path2)
def test_308_change_cable_profile_regroups_trunk_paths(self):
"""
[IF1] --C1-- [IF3]
[IF2] [IF4]
Applying a trunk profile to an existing cable regroups its paths by connector, and
clearing it again collapses them.
"""
interfaces = [
Interface.objects.create(device=self.device, name='Interface 1'),
Interface.objects.create(device=self.device, name='Interface 2'),
Interface.objects.create(device=self.device, name='Interface 3'),
Interface.objects.create(device=self.device, name='Interface 4'),
]
# Create cable 1 without a profile
cable1 = Cable(
a_terminations=[interfaces[0], interfaces[1]],
b_terminations=[interfaces[2], interfaces[3]],
)
cable1.clean()
cable1.save()
# Without a profile both terminations on each end share a single path
self.assertPathExists(
((interfaces[0], interfaces[1]), cable1, (interfaces[2], interfaces[3])),
is_complete=True,
is_active=True
)
self.assertPathExists(
((interfaces[2], interfaces[3]), cable1, (interfaces[0], interfaces[1])),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
# Reload so _terminations_modified starts False, as it does in a bulk edit
cable1 = Cable.objects.get(pk=cable1.pk)
cable1.profile = CableProfileChoices.TRUNK_2C1P
cable1.full_clean()
cable1.save()
path1 = self.assertPathExists(
(interfaces[0], cable1, interfaces[2]),
is_complete=True,
is_active=True
)
path2 = self.assertPathExists(
(interfaces[1], cable1, interfaces[3]),
is_complete=True,
is_active=True
)
path3 = self.assertPathExists(
(interfaces[2], cable1, interfaces[0]),
is_complete=True,
is_active=True
)
path4 = self.assertPathExists(
(interfaces[3], cable1, interfaces[1]),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 4)
for interface in interfaces:
interface.refresh_from_db()
self.assertPathIsSet(interfaces[0], path1)
self.assertPathIsSet(interfaces[1], path2)
self.assertPathIsSet(interfaces[2], path3)
self.assertPathIsSet(interfaces[3], path4)
self.assertEqual(interfaces[0].cable_connector, 1)
self.assertEqual(interfaces[1].cable_connector, 2)
self.assertEqual(interfaces[2].cable_connector, 1)
self.assertEqual(interfaces[3].cable_connector, 2)
for interface in interfaces:
self.assertEqual(interface.cable_positions, [1])
# Clearing the profile is a bulk-edit action in its own right
cable1 = Cable.objects.get(pk=cable1.pk)
cable1.profile = ''
cable1.full_clean()
cable1.save()
path5 = self.assertPathExists(
((interfaces[0], interfaces[1]), cable1, (interfaces[2], interfaces[3])),
is_complete=True,
is_active=True
)
path6 = self.assertPathExists(
((interfaces[2], interfaces[3]), cable1, (interfaces[0], interfaces[1])),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
for interface in interfaces:
interface.refresh_from_db()
self.assertIsNone(interface.cable_connector)
self.assertPathIsSet(interfaces[0], path5)
self.assertPathIsSet(interfaces[1], path5)
self.assertPathIsSet(interfaces[2], path6)
self.assertPathIsSet(interfaces[3], path6)
def test_309_change_midspan_cable_profile_rebuilds_paths(self):
"""
[IF1] --C1-- [FP1][RP1] --C3-- [RP2][FP2] --C2-- [IF2]
Applying a profile to a cable which terminates on pass-through ports rebuilds the
paths traversing it. The rear ports are not path origins, so a missing rebuild
truncates those paths rather than deleting them.
"""
interfaces = [
Interface.objects.create(device=self.device, name='Interface 1'),
Interface.objects.create(device=self.device, name='Interface 2'),
]
rear_ports = [
RearPort.objects.create(device=self.device, name='Rear Port 1'),
RearPort.objects.create(device=self.device, name='Rear Port 2'),
]
front_ports = [
FrontPort.objects.create(device=self.device, name='Front Port 1'),
FrontPort.objects.create(device=self.device, name='Front Port 2'),
]
for front_port, rear_port in zip(front_ports, rear_ports):
PortMapping.objects.create(
device=self.device,
front_port=front_port,
front_port_position=1,
rear_port=rear_port,
rear_port_position=1
)
cable1 = Cable(a_terminations=[interfaces[0]], b_terminations=[front_ports[0]])
cable1.clean()
cable1.save()
cable2 = Cable(a_terminations=[front_ports[1]], b_terminations=[interfaces[1]])
cable2.clean()
cable2.save()
# Create the mid-span cable without a profile
cable3 = Cable(a_terminations=[rear_ports[0]], b_terminations=[rear_ports[1]])
cable3.clean()
cable3.save()
nodes_a_to_b = (
interfaces[0], cable1, front_ports[0], rear_ports[0], cable3, rear_ports[1], front_ports[1], cable2,
interfaces[1],
)
nodes_b_to_a = (
interfaces[1], cable2, front_ports[1], rear_ports[1], cable3, rear_ports[0], front_ports[0], cable1,
interfaces[0],
)
self.assertPathExists(nodes_a_to_b, is_complete=True, is_active=True)
self.assertPathExists(nodes_b_to_a, is_complete=True, is_active=True)
self.assertEqual(CablePath.objects.count(), 2)
# Reload so _terminations_modified starts False, as it does in a bulk edit
cable3 = Cable.objects.get(pk=cable3.pk)
cable3.profile = CableProfileChoices.SINGLE_1C1P
cable3.full_clean()
cable3.save()
path1 = self.assertPathExists(nodes_a_to_b, is_complete=True, is_active=True)
path2 = self.assertPathExists(nodes_b_to_a, is_complete=True, is_active=True)
self.assertEqual(CablePath.objects.count(), 2)
for interface in interfaces:
interface.refresh_from_db()
self.assertPathIsSet(interfaces[0], path1)
self.assertPathIsSet(interfaces[1], path2)
def test_310_repeat_save_does_not_recreate_paths(self):
"""
[IF1] --C1-- [IF2]
Saving an unchanged cable again leaves its terminations and paths untouched.
"""
interfaces = [
Interface.objects.create(device=self.device, name='Interface 1'),
Interface.objects.create(device=self.device, name='Interface 2'),
]
cable1 = Cable(
a_terminations=[interfaces[0]],
b_terminations=[interfaces[1]],
)
cable1.clean()
cable1.save()
path_pks = set(CablePath.objects.values_list('pk', flat=True))
termination_pks = set(CableTermination.objects.filter(cable=cable1).values_list('pk', flat=True))
self.assertEqual(len(path_pks), 2)
self.assertEqual(len(termination_pks), 2)
# Saving the same instance again must not duplicate its paths
cable1.save()
self.assertEqual(set(CablePath.objects.values_list('pk', flat=True)), path_pks)
self.assertEqual(
set(CableTermination.objects.filter(cable=cable1).values_list('pk', flat=True)),
termination_pks
)
# Reload so _terminations_modified starts False, as it does in a bulk edit
cable1 = Cable.objects.get(pk=cable1.pk)
cable1.profile = CableProfileChoices.SINGLE_1C1P
cable1.full_clean()
cable1.save()
path_pks = set(CablePath.objects.values_list('pk', flat=True))
termination_pks = set(CableTermination.objects.filter(cable=cable1).values_list('pk', flat=True))
self.assertEqual(len(path_pks), 2)
self.assertEqual(len(termination_pks), 2)
# The profile change is applied once, so a second save must not recreate anything
cable1.save()
self.assertEqual(set(CablePath.objects.values_list('pk', flat=True)), path_pks)
self.assertEqual(
set(CableTermination.objects.filter(cable=cable1).values_list('pk', flat=True)),
termination_pks
)

View File

@ -637,6 +637,13 @@ class FrontPortTestCase(TestCase):
RearPort(name='RearPort4', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
)
RearPort.objects.bulk_create(cls.rear_ports)
cls.rear_port_templates = (
RearPortTemplate(name='RearPort1', device_type=cls.device.device_type, type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(name='RearPort2', device_type=cls.device.device_type, type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(name='RearPort3', device_type=cls.device.device_type, type=PortTypeChoices.TYPE_8P8C),
RearPortTemplate(name='RearPort4', device_type=cls.device.device_type, type=PortTypeChoices.TYPE_8P8C),
)
RearPortTemplate.objects.bulk_create(cls.rear_port_templates)
def test_front_port_label_count_valid(self):
"""
@ -671,6 +678,124 @@ class FrontPortTestCase(TestCase):
self.assertFalse(form.is_valid())
self.assertIn('label', form.errors)
def test_front_port_position_count_valid(self):
"""
Test that generating front ports with multiple positions each passes form validation.
"""
front_port_data = {
'device': self.device.pk,
'name': 'FrontPort[1-2]',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 2,
'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
}
form = FrontPortCreateForm(front_port_data)
self.assertTrue(form.is_valid(), form.errors)
def test_front_port_position_count_mismatch(self):
"""
Check that the mismatch error reports the total number of front port positions, not the port count.
"""
bad_front_port_data = {
'device': self.device.pk,
'name': 'FrontPort[1-2]',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 2,
'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports[:2]],
}
form = FrontPortCreateForm(bad_front_port_data)
self.assertFalse(form.is_valid())
self.assertIn(
'The total number of front port positions (4) must match the selected number of rear port '
'positions (2).',
form.errors['rear_ports']
)
def test_front_port_template_position_count_mismatch(self):
"""
Check that the front port template form reports the same corrected position total.
"""
bad_front_port_template_data = {
'device_type': self.device.device_type.pk,
'name': 'FrontPort[1-2]',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 2,
'rear_ports': [f'{rear_port_template.pk}:1' for rear_port_template in self.rear_port_templates[:2]],
}
form = FrontPortTemplateCreateForm(bad_front_port_template_data)
self.assertFalse(form.is_valid())
self.assertIn(
'The total number of front port positions (4) must match the selected number of rear port '
'positions (2).',
form.errors['rear_ports']
)
def test_front_port_missing_rear_ports(self):
"""
Check that omitting the rear port selection reports a field error rather than raising an exception.
"""
bad_front_port_data = {
'device': self.device.pk,
'name': 'FrontPort[1-2]',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 1,
}
form = FrontPortCreateForm(bad_front_port_data)
self.assertFalse(form.is_valid())
self.assertIn('rear_ports', form.errors)
def test_front_port_invalid_positions(self):
"""
Check that a non-numeric position count reports a field error rather than raising an exception.
"""
bad_front_port_data = {
'device': self.device.pk,
'name': 'FrontPort[1-2]',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 'two',
'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports[:2]],
}
form = FrontPortCreateForm(bad_front_port_data)
self.assertFalse(form.is_valid())
self.assertIn('positions', form.errors)
def test_front_port_template_missing_rear_ports(self):
"""
Check that the front port template form also reports a field error rather than raising an exception.
"""
bad_front_port_template_data = {
'device_type': self.device.device_type.pk,
'name': 'FrontPort[1-2]',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 1,
}
form = FrontPortTemplateCreateForm(bad_front_port_template_data)
self.assertFalse(form.is_valid())
self.assertIn('rear_ports', form.errors)
def test_front_port_invalid_label_range(self):
"""
Check that an inverted label range reports a field error rather than raising an exception.
"""
bad_front_port_data = {
'device': self.device.pk,
'name': 'FrontPort[1-2]',
'label': 'Port[2-1]',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 1,
'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports[:2]],
}
form = FrontPortCreateForm(bad_front_port_data)
self.assertFalse(form.is_valid())
self.assertIn('label', form.errors)
class InterfaceTestCase(TestCase):

View File

@ -2413,6 +2413,33 @@ class CableTestCase(TestCase):
with self.assertRaises(ValidationError):
cable.clean()
def test_partial_save_does_not_apply_an_unwritten_profile(self):
"""
A save excluding profile must leave the terminations alone but keep the change pending.
"""
cable = Cable.objects.first()
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
termination_pks = set(CableTermination.objects.filter(cable=cable).values_list('pk', flat=True))
cable.profile = CableProfileChoices.SINGLE_1C1P
cable.save(update_fields=['label'])
interface1.refresh_from_db()
# Requery rather than refresh, so the pending profile stays on the instance under test
self.assertEqual(Cable.objects.get(pk=cable.pk).profile, '')
self.assertIsNone(interface1.cable_connector)
self.assertEqual(
set(CableTermination.objects.filter(cable=cable).values_list('pk', flat=True)),
termination_pks
)
# _orig_profile was not advanced, so the pending change still applies here
cable.save()
interface1.refresh_from_db()
self.assertEqual(Cable.objects.get(pk=cable.pk).profile, CableProfileChoices.SINGLE_1C1P)
self.assertEqual(interface1.cable_connector, 1)
def test_cable_profile_change_preserves_terminations(self):
"""
When a Cable's profile is changed via save() without explicitly setting terminations (as happens during

View File

@ -680,8 +680,7 @@ class CableSignalTestCase(TestCase):
cable.save()
self.assertTrue(all(cp.is_active for cp in CablePath.objects.all()))
# Reload the cable so _orig_status reflects the persisted value and
# _terminations_modified resets to False.
# Reload to exercise status tracking on a freshly loaded instance, as a request does
cable = Cable.objects.get(pk=cable.pk)
cable.status = LinkStatusChoices.STATUS_PLANNED
cable.save()
@ -705,6 +704,42 @@ class CableSignalTestCase(TestCase):
self.assertTrue(all(cp.is_active for cp in CablePath.objects.all()))
def test_toggling_cable_status_on_one_instance_reactivates_paths(self):
interface_a = Interface.objects.create(device=self.device, name='Interface A')
interface_b = Interface.objects.create(device=self.device, name='Interface B')
cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
cable.save()
# Reuse the same instance for both changes, as a script would
cable = Cable.objects.get(pk=cable.pk)
cable.status = LinkStatusChoices.STATUS_PLANNED
cable.save()
self.assertFalse(any(cp.is_active for cp in CablePath.objects.all()))
cable.status = LinkStatusChoices.STATUS_CONNECTED
cable.save()
self.assertTrue(all(cp.is_active for cp in CablePath.objects.all()))
def test_partial_save_does_not_consume_an_unwritten_status_change(self):
interface_a = Interface.objects.create(device=self.device, name='Interface A')
interface_b = Interface.objects.create(device=self.device, name='Interface B')
cable = Cable(
a_terminations=[interface_a],
b_terminations=[interface_b],
status=LinkStatusChoices.STATUS_PLANNED,
)
cable.save()
self.assertFalse(any(cp.is_active for cp in CablePath.objects.all()))
# A save that excludes status must not advance the status snapshot
cable.status = LinkStatusChoices.STATUS_CONNECTED
cable.save(update_fields=['label'])
self.assertFalse(any(cp.is_active for cp in CablePath.objects.all()))
# _orig_status was not advanced, so the change must still be detected
cable.save()
self.assertTrue(all(cp.is_active for cp in CablePath.objects.all()))
def test_deleting_cable_retraces_paths(self):
interface_a = Interface.objects.create(device=self.device, name='Interface A')
interface_b = Interface.objects.create(device=self.device, name='Interface B')

View File

@ -31,7 +31,7 @@ from netbox.choices import (
WeightUnitChoices,
)
from tenancy.models import Tenant
from users.models import ObjectPermission, User
from users.models import ObjectPermission, Owner, User
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
from wireless.models import WirelessLAN
@ -3832,6 +3832,8 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
)
PowerOutlet.objects.bulk_create(power_outlets)
owner = Owner.objects.create(name='Owner 1')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
@ -3842,6 +3844,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'A power outlet',
'owner': owner.pk,
'tags': [t.pk for t in tags],
}
@ -3853,6 +3856,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'A power outlet',
'owner': owner.pk,
'tags': [t.pk for t in tags],
}
@ -4370,6 +4374,43 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
self.assertEqual(response.status_code, 200)
self.assertFalse(FrontPort.objects.filter(name='Front Port 10').exists())
def test_create_multiple_objects_with_multiple_positions(self):
"""
Check that bulk creation gives each generated front port its own slice of the selected mappings.
"""
device = Device.objects.get(name='Device 1')
rear_ports = (
RearPort(device=device, name='Rear Port 7', positions=2),
RearPort(device=device, name='Rear Port 8', positions=2),
)
RearPort.objects.bulk_create(rear_ports)
self.add_permissions('dcim.add_frontport')
response = self.client.post(self._get_url('add'), post_data({
'device': device.pk,
'name': 'Multi Port [1-2]',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 2,
'rear_ports': [
f'{rear_ports[0].pk}:1',
f'{rear_ports[0].pk}:2',
f'{rear_ports[1].pk}:1',
f'{rear_ports[1].pk}:2',
],
}))
self.assertHttpStatus(response, 302)
for front_port_name, rear_port in (('Multi Port 1', rear_ports[0]), ('Multi Port 2', rear_ports[1])):
front_port = FrontPort.objects.get(device=device, name=front_port_name)
self.assertEqual(front_port.positions, 2)
self.assertEqual(
[
(m.front_port_position, m.rear_port_id, m.rear_port_position)
for m in front_port.mappings.order_by('front_port_position')
],
[(1, rear_port.pk, 1), (2, rear_port.pk, 2)]
)
def test_trace(self):
self.add_permissions(
'dcim.view_frontport',

View File

@ -1,3 +1,4 @@
from django.core.exceptions import ValidationError as DjangoValidationError
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _
@ -404,17 +405,23 @@ class ScriptViewSet(ListModelMixin, RetrieveModelMixin, BaseViewSet):
raise RQWorkerNotRunningException()
if input_serializer.is_valid():
ScriptJob.enqueue(
instance=script,
user=request.user,
data=input_serializer.data['data'],
request=copy_safe_request(request),
commit=input_serializer.data['commit'],
job_timeout=script.python_class.job_timeout,
schedule_at=input_serializer.validated_data.get('schedule_at'),
interval=input_serializer.validated_data.get('interval'),
notifications=input_serializer.validated_data.get('notifications'),
)
try:
ScriptJob.enqueue(
instance=script,
user=request.user,
data=input_serializer.data['data'],
request=copy_safe_request(request),
commit=input_serializer.data['commit'],
job_timeout=script.python_class.job_timeout,
schedule_at=input_serializer.validated_data.get('schedule_at'),
interval=input_serializer.validated_data.get('interval'),
notifications=input_serializer.validated_data.get('notifications'),
)
except DjangoValidationError as e:
# The script's execution configuration is invalid (see #22872). Surface it as a 400 rather than
# allowing the exception to bubble up as an HTTP 500. These are script-level config errors, not
# request-field errors, so report them under the non-field "detail" key.
raise ValidationError({'detail': e.messages}) from e
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
return Response(serializer.data)

View File

@ -1,3 +1,6 @@
import logging
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_rq import get_queue
@ -17,6 +20,8 @@ __all__ = (
'WebhookAction',
)
logger = logging.getLogger('netbox.events_processor')
class WebhookAction(EventRuleAction):
slug = EventRuleActionChoices.WEBHOOK
@ -79,8 +84,18 @@ class ScriptAction(EventRuleAction):
if 'request' in event_context:
params['request'] = copy_safe_request(event_context['request'], include_files=False)
# Enqueue the job
ScriptJob.enqueue(**params)
# Enqueue the job. If the script's Meta configuration is invalid (see #22872), log the error and skip this
# action rather than allowing the exception to abort the event pipeline (and, since events are processed
# in-request, the originating object change). Note this is intentionally asymmetric with the webhook
# action, which lets enqueue failures propagate: script Meta is validated eagerly at enqueue and a
# misconfigured script must not take down an unrelated object change.
try:
ScriptJob.enqueue(**params)
except ValidationError as e:
logger.error(
"Skipping script action for event rule %s: invalid script configuration: %s",
event_rule, '; '.join(e.messages)
)
def resolve_import_object(self, value):
from extras.scripts import get_module_and_script

View File

@ -13,6 +13,7 @@ from extras.choices import CustomFieldStatusChoices
from extras.constants import CUSTOMFIELD_JOB_TIMEOUT
from extras.models import CustomField
from extras.models import Script as ScriptModel
from extras.scripts import _UNSET
from netbox.context_managers import event_tracking
from netbox.jobs import JobRunner
from netbox.registry import registry
@ -270,6 +271,30 @@ class ScriptJob(JobRunner):
class Meta:
name = 'Run Script'
@classmethod
def enqueue(cls, *args, **kwargs):
"""
Validate the script's execution parameters before enqueueing. This is the single choke point through which
every script execution passes (interactive runs, the REST API, the runscript command, event-rule actions, and
recurring reschedules), so validating here surfaces a misconfigured script as an actionable error rather than
an unhandled exception at enqueue time (see #22872).
The values actually being enqueued are validated, not just the script's Meta defaults, so an explicit
job_timeout or notifications supplied by the caller is checked too.
"""
# The instance may be passed positionally (JobRunner.enqueue() forwards it to Job.enqueue()'s first argument)
# or by keyword. Resolve it for validation without consuming it, so the original arguments are forwarded to
# super() unchanged and the inherited calling contract is preserved.
instance = args[0] if args else kwargs.get('instance')
script_class = getattr(instance, 'python_class', None)
if script_class is not None:
script_class.validate_meta(
job_timeout=kwargs.get('job_timeout', _UNSET),
notifications=kwargs.get('notifications', _UNSET),
)
return super().enqueue(*args, **kwargs)
def run_script(self, script, request, data, commit):
"""
Core script execution task. We capture this within a method to allow for conditionally wrapping it with the

View File

@ -3,6 +3,7 @@ import logging
import sys
import uuid
from django.core.exceptions import ValidationError
from django.core.management.base import BaseCommand, CommandError
from extras.jobs import ScriptJob
@ -88,24 +89,29 @@ class Command(BaseCommand):
notifications = form.cleaned_data.pop('_notifications')
# Execute the script.
job = ScriptJob.enqueue(
instance=script_obj,
user=user,
immediate=True,
data=form.cleaned_data,
notifications=notifications,
request=NetBoxFakeRequest({
'META': {},
'COOKIES': {},
'POST': data,
'GET': {},
'FILES': {},
'user': user,
'method': 'POST',
'path': '',
'id': uuid.uuid4()
}),
commit=commit,
)
try:
job = ScriptJob.enqueue(
instance=script_obj,
user=user,
immediate=True,
data=form.cleaned_data,
notifications=notifications,
request=NetBoxFakeRequest({
'META': {},
'COOKIES': {},
'POST': data,
'GET': {},
'FILES': {},
'user': user,
'method': 'POST',
'path': '',
'id': uuid.uuid4()
}),
commit=commit,
)
except ValidationError as e:
# The script's Meta configuration is invalid (see #22872). Report it as a clean command error rather than
# an unhandled traceback.
raise CommandError('; '.join(e.messages))
logger.info(f"Script completed in {job.duration}")

View File

@ -4,11 +4,14 @@ import os
import re
from django import forms
from django.core.exceptions import ValidationError
from django.core.files.storage import storages
from django.core.validators import RegexValidator
from django.utils import timezone
from django.utils.functional import classproperty
from django.utils.translation import gettext as _
from rq.exceptions import TimeoutFormatError
from rq.utils import parse_timeout
from core.choices import JobNotificationChoices
from extras.choices import LogLevelChoices
@ -43,6 +46,9 @@ __all__ = (
'get_module_and_script',
)
# Sentinel distinguishing "argument not supplied" from an explicit None in validate_meta().
_UNSET = object()
#
# Script variables
@ -403,6 +409,51 @@ class BaseScript:
def notifications_default(self):
return getattr(self.Meta, 'notifications_default', JobNotificationChoices.NOTIFICATION_ALWAYS)
@classmethod
def validate_meta(cls, job_timeout=_UNSET, notifications=_UNSET):
"""
Validate the execution parameters used to run this script. Raises a ValidationError if any value is invalid,
so that a misconfigured script surfaces an actionable error rather than an unhandled exception when the job is
enqueued (see #22872).
The values actually enqueued are validated, not the raw Meta values: a caller may supply an explicit
`job_timeout` or `notifications` (e.g. via the REST API), in which case that value is checked. When a caller
omits a value, the corresponding Meta default is validated instead. Unset values fall back to valid defaults
and are not rejected.
"""
errors = {}
job_timeout = cls.job_timeout if job_timeout is _UNSET else job_timeout
if job_timeout is not None:
# parse_timeout() is what RQ applies to the timeout downstream. It raises TimeoutFormatError for
# malformed duration strings, but a job_timeout of an unexpected type (e.g. a list) instead raises
# TypeError/ValueError/AssertionError from its internal int()/assert. Catch them all so any invalid value
# surfaces as an actionable error rather than an unhandled 500.
try:
parsed_timeout = parse_timeout(job_timeout)
except (TimeoutFormatError, TypeError, ValueError, AssertionError):
parsed_timeout = None
errors['job_timeout'] = _(
"Invalid job_timeout value '{value}': must be an integer (seconds) or a duration string such as "
"'1h' or '30m'."
).format(value=job_timeout)
if parsed_timeout is not None and parsed_timeout <= 0:
errors['job_timeout'] = _(
"Invalid job_timeout value '{value}': must be a positive duration."
).format(value=job_timeout)
# A caller may pass notifications=None to mean "use the script's default"; treat that as unset.
if notifications is _UNSET or notifications is None:
notifications = cls.notifications_default
if notifications not in JobNotificationChoices.values():
valid = ', '.join(JobNotificationChoices.values())
errors['notifications_default'] = _(
"Invalid notifications value '{value}': must be one of {valid}."
).format(value=notifications, valid=valid)
if errors:
raise ValidationError(errors)
@property
def filename(self):
return inspect.getfile(self.__class__)

View File

@ -3,7 +3,7 @@ import hashlib
import io
import json
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, PropertyMock, patch
from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile
@ -1757,6 +1757,54 @@ class ScriptTestCase(APITestCase):
self.assertEqual(Job.objects.count(), len(lookups))
def test_run_script_invalid_job_timeout(self):
"""
A script whose Meta.job_timeout is invalid must be rejected with a 400, not raise an unhandled exception
(#22872).
"""
self.add_permissions('extras.run_script')
class BadTimeoutScript(PythonClass):
class Meta:
name = 'Bad Timeout'
job_timeout = 'not-a-timeout'
def run(self, data, commit=True):
pass
payload = {'data': {}, 'commit': True}
with patch.object(Script, 'python_class', new_callable=PropertyMock) as mock_python_class:
mock_python_class.return_value = BadTimeoutScript
with disable_warnings('django.request'):
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_invalid_notifications_default(self):
"""
A script whose Meta.notifications_default is invalid must be rejected with a 400, not raise an unhandled
exception (#22872).
"""
self.add_permissions('extras.run_script')
class BadNotificationsScript(PythonClass):
class Meta:
name = 'Bad Notifications'
notifications_default = 'on_error'
def run(self, data, commit=True):
pass
payload = {'data': {}, 'commit': True}
with patch.object(Script, 'python_class', new_callable=PropertyMock) as mock_python_class:
mock_python_class.return_value = BadNotificationsScript
with disable_warnings('django.request'):
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_modify_script_methods_disabled(self):
"""
Individual scripts are created, modified, and deleted through their module, so PUT/PATCH/DELETE on

View File

@ -3,7 +3,7 @@ import logging
import uuid
from io import BytesIO
from unittest import skipIf
from unittest.mock import Mock, patch
from unittest.mock import Mock, PropertyMock, patch
import django_rq
import requests
@ -1264,6 +1264,63 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
self.assertEqual(script_job.status, "completed")
self.assertEqual(script_job.data.get('output', ''), "finished successfully")
@tag('regression') # Issue #22872
def test_eventrule_script_action_invalid_meta_does_not_abort_change(self):
"""
A Script event-rule action whose Meta configuration is invalid must be logged and skipped without aborting
the triggering object change or raising an HTTP 500 (#22872). Because event rules are processed in-request,
an unhandled ValidationError here would fail the originating request.
"""
class BadMetaScript(ScriptBase):
class Meta:
name = "Bad Meta Script"
job_timeout = 'not-a-timeout'
def run(self, data, commit=True):
return "never reached"
with patch.object(ScriptModule, 'sync_classes'):
module = ScriptModule.objects.create(
file_root=ManagedFileRootPathChoices.SCRIPTS,
file_path='bad_meta_script.py',
)
script = Script.objects.create(module=module, name='Bad Meta Script', is_executable=True)
script_type = ObjectType.objects.get_for_model(Script)
# Trigger on Manufacturer rather than Site: the class-level event rules all target Site, so a Site-based rule
# here would collide with them and perturb other tests' queue expectations.
manufacturer_type = ObjectType.objects.get_for_model(Manufacturer)
event_rule = EventRule.objects.create(
name='Bad Meta Script Rule',
event_types=[OBJECT_UPDATED],
action_type=EventRuleActionChoices.SCRIPT,
action_object_type=script_type,
action_object_id=script.pk,
)
event_rule.object_types.set([manufacturer_type])
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
self.add_permissions('dcim.change_manufacturer')
url = reverse('dcim-api:manufacturer-detail', kwargs={'pk': manufacturer.pk})
# python_class is a property returning the script class; patch it to return our bad-Meta class so validate_meta
# (a classmethod on it) is exercised the way production reads it.
with patch.object(Script, 'python_class', new_callable=PropertyMock) as mock:
mock.return_value = BadMetaScript
with self.captureOnCommitCallbacks(execute=True):
with self.assertLogs('netbox.events_processor', 'ERROR') as captured:
response = self.client.patch(url, {'description': 'updated'}, format='json', **self.header)
# The triggering object change succeeds despite the misconfigured script
self.assertHttpStatus(response, status.HTTP_200_OK)
manufacturer.refresh_from_db()
self.assertEqual(manufacturer.description, 'updated')
# No script job was enqueued (nothing queued, no Job record), and the misconfiguration was logged
self.assertEqual(self.queue.count, 0)
self.assertEqual(Job.objects.filter(name=BadMetaScript.Meta.name).count(), 0)
self.assertTrue(any('Bad Meta Script Rule' in line for line in captured.output))
@tag('regression') # Issue #22852
def test_eventrule_script_action_honors_script_defaults(self):
"""A script run from an event rule uses the notification policy and job timeout from its Meta class."""

View File

@ -414,6 +414,31 @@ class RunScriptTestCase(TestCase):
self.assertEqual(enqueue.call_args.kwargs['user'], self.user)
def test_invalid_meta_raises_command_error(self):
"""
A script with an invalid Meta value must fail with a clean CommandError rather than an unhandled
exception (#22872).
"""
class BadMetaScript(Script):
class Meta:
job_timeout = 'not-a-timeout'
def run(self, data, commit):
return None
script_obj = SimpleNamespace(python_class=BadMetaScript)
# Note: ScriptJob.enqueue is intentionally NOT mocked here, so validate_meta() runs and raises.
with (
patch(
'extras.management.commands.runscript.get_module_and_script',
return_value=(None, script_obj),
),
patch('extras.management.commands.runscript.logging.getLogger'),
):
with self.assertRaises(CommandError):
call_command('runscript', 'test.Script', user='admin', stdout=StringIO())
class RebuildConfigContextCacheCommandTest(TestCase):

View File

@ -1,15 +1,22 @@
import io
import sys
import uuid
from datetime import UTC, date, datetime
from decimal import Decimal
from unittest.mock import patch
from unittest.mock import PropertyMock, patch
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from netaddr import IPAddress, IPNetwork
from core.choices import JobNotificationChoices, JobStatusChoices, ManagedFileRootPathChoices
from core.models import Job
from dcim.models import DeviceRole
from extras.constants import SCRIPT_MODULE_NAME_PREFIX
from extras.jobs import ScriptJob
from extras.models import Script as ScriptModel
from extras.models import ScriptModule
from extras.scripts import *
@ -469,3 +476,268 @@ class ScriptModuleLoadingTestCase(TestCase):
with self.assertLogs(logger_name, 'INFO') as captured:
script.log_success('Start')
self.assertIn('Start', captured.output[0])
class ScriptMetaValidationTestCase(TestCase):
"""
Tests for BaseScript.validate_meta() (#22872): invalid execution-related Meta values must raise an actionable
ValidationError, while unset/valid values must not.
"""
def test_valid_meta_passes(self):
class TestScript(Script):
class Meta:
job_timeout = 600
notifications_default = JobNotificationChoices.NOTIFICATION_ON_FAILURE
def run(self, data, commit):
pass
TestScript.validate_meta() # should not raise
def test_job_timeout_duration_string_passes(self):
class TestScript(Script):
class Meta:
job_timeout = '1h'
def run(self, data, commit):
pass
TestScript.validate_meta() # should not raise
def test_unset_meta_passes(self):
class TestScript(Script):
def run(self, data, commit):
pass
# job_timeout defaults to None and notifications_default to ALWAYS; neither should be rejected
TestScript.validate_meta()
def test_all_notification_choices_pass(self):
for choice in JobNotificationChoices.values():
class TestScript(Script):
class Meta:
notifications_default = choice
def run(self, data, commit):
pass
TestScript.validate_meta() # should not raise
def test_invalid_job_timeout_raises(self):
class TestScript(Script):
class Meta:
job_timeout = 'not-a-timeout'
def run(self, data, commit):
pass
with self.assertRaises(ValidationError) as cm:
TestScript.validate_meta()
self.assertIn('job_timeout', cm.exception.message_dict)
def test_invalid_notifications_default_raises(self):
class TestScript(Script):
class Meta:
notifications_default = 'on_error'
def run(self, data, commit):
pass
with self.assertRaises(ValidationError) as cm:
TestScript.validate_meta()
self.assertIn('notifications_default', cm.exception.message_dict)
def test_non_string_job_timeout_raises(self):
# A job_timeout of an unexpected type must surface as a ValidationError, not an unhandled TypeError.
class TestScript(Script):
class Meta:
job_timeout = [60]
def run(self, data, commit):
pass
with self.assertRaises(ValidationError) as cm:
TestScript.validate_meta()
self.assertIn('job_timeout', cm.exception.message_dict)
def test_non_positive_job_timeout_raises(self):
# parse_timeout() accepts 0 and negatives, but a non-positive timeout is nonsensical and must be rejected.
for value in (0, -30):
class TestScript(Script):
class Meta:
job_timeout = value
def run(self, data, commit):
pass
with self.assertRaises(ValidationError) as cm:
TestScript.validate_meta()
self.assertIn('job_timeout', cm.exception.message_dict)
class ScriptJobEnqueueValidationTestCase(TestCase):
"""
Tests that ScriptJob.enqueue() validates Meta before creating a Job (#22872). This is the choke point exercised by
event-rule actions and recurring reschedules, which have no request layer to catch the error.
"""
@classmethod
def setUpTestData(cls):
cls.user = get_user_model().objects.create_user('scriptrunner')
def _make_script(self, python_class):
with patch.object(ScriptModule, 'sync_classes'):
module = ScriptModule.objects.create(
file_root=ManagedFileRootPathChoices.SCRIPTS,
file_path=f'meta_validation_{id(python_class)}.py',
)
script = ScriptModel.objects.create(module=module, name=python_class.Meta.name, is_executable=True)
# Return the raw python_class regardless of on-disk module state
patcher = patch.object(ScriptModel, 'python_class', property(lambda self, pc=python_class: pc))
patcher.start()
self.addCleanup(patcher.stop)
return script
def test_enqueue_rejects_invalid_job_timeout(self):
class BadTimeout(Script):
class Meta:
name = 'Bad Timeout'
job_timeout = 'not-a-timeout'
def run(self, data, commit):
pass
script = self._make_script(BadTimeout)
with self.captureOnCommitCallbacks(execute=True):
with self.assertRaises(ValidationError):
ScriptJob.enqueue(
instance=script, user=self.user, job_timeout=BadTimeout.job_timeout,
notifications=BadTimeout.notifications_default, data={}, commit=True,
)
self.assertEqual(Job.objects.count(), 0)
def test_enqueue_rejects_invalid_notifications_default(self):
class BadNotifications(Script):
class Meta:
name = 'Bad Notifications'
notifications_default = 'on_error'
def run(self, data, commit):
pass
script = self._make_script(BadNotifications)
with self.captureOnCommitCallbacks(execute=True):
with self.assertRaises(ValidationError):
ScriptJob.enqueue(
instance=script, user=self.user, job_timeout=BadNotifications.job_timeout,
notifications=BadNotifications.notifications_default, data={}, commit=True,
)
self.assertEqual(Job.objects.count(), 0)
def test_enqueue_accepts_valid_meta(self):
class GoodScript(Script):
class Meta:
name = 'Good Script'
job_timeout = '1h'
notifications_default = JobNotificationChoices.NOTIFICATION_ALWAYS
def run(self, data, commit):
pass
script = self._make_script(GoodScript)
# Do not execute the on_commit callback: the Job row is created by Job.enqueue() before the RQ push is
# registered, so asserting the row exists needs no real enqueue. Executing it would leave a job in the shared
# Redis queue that races other tests under the parallel runner (see #22872).
with self.captureOnCommitCallbacks():
job = ScriptJob.enqueue(
instance=script, user=self.user, job_timeout=GoodScript.job_timeout,
notifications=GoodScript.notifications_default, data={}, commit=True,
)
self.assertIsNotNone(job)
self.assertEqual(Job.objects.count(), 1)
def test_enqueue_positional_instance_is_validated_and_forwarded(self):
"""
The instance may be passed positionally (JobRunner.enqueue() forwards it to Job.enqueue()'s first argument).
The override must validate it without breaking that inherited calling contract (#22872).
"""
class BadTimeout(Script):
class Meta:
name = 'Bad Timeout Positional'
job_timeout = 'not-a-timeout'
def run(self, data, commit):
pass
script = self._make_script(BadTimeout)
# Passed positionally, not instance=... — must still be validated and rejected.
with self.captureOnCommitCallbacks(execute=True):
with self.assertRaises(ValidationError):
ScriptJob.enqueue(script, user=self.user, data={}, commit=True)
self.assertEqual(Job.objects.count(), 0)
def test_enqueue_positional_instance_valid_meta_creates_job(self):
"""A valid script passed positionally must enqueue cleanly, i.e. the override forwards args unchanged."""
class GoodScript(Script):
class Meta:
name = 'Good Positional'
def run(self, data, commit):
pass
script = self._make_script(GoodScript)
# Do not execute the on_commit callback (see the note in test_enqueue_accepts_valid_meta): asserting the Job
# row exists needs no real RQ push, and executing it would leak a job into the shared queue (see #22872).
with self.captureOnCommitCallbacks():
job = ScriptJob.enqueue(script, user=self.user, data={}, commit=True)
self.assertIsNotNone(job)
self.assertEqual(Job.objects.count(), 1)
def test_reschedule_with_invalid_meta_preserves_completed_run(self):
"""
If a recurring script's Meta.job_timeout becomes invalid between runs, the occurrence that just ran to
completion must keep its COMPLETED status and not be re-terminated as ERRORED, no successor may be scheduled,
and the reschedule failure must be recorded on the job (#22872).
"""
class RecurringScript(Script):
class Meta:
name = 'Recurring'
# No custom job_timeout at schedule time: valid.
def run(self, data, commit):
pass
script = self._make_script(RecurringScript)
# Create a completed, recurring job as if a scheduled occurrence had just finished successfully.
job = Job.objects.create(
object=script,
name='Recurring',
status=JobStatusChoices.STATUS_COMPLETED,
user=self.user,
interval=60,
job_id=uuid.uuid4(),
)
# The script's Meta is edited to an invalid job_timeout before the reschedule fires.
class RecurringScriptBadTimeout(RecurringScript):
class Meta(RecurringScript.Meta):
job_timeout = 'not-a-timeout'
with patch.object(ScriptModel, 'python_class', new_callable=PropertyMock) as mock_pc:
mock_pc.return_value = RecurringScriptBadTimeout
with self.captureOnCommitCallbacks(execute=True):
# handle() runs the script (which succeeds) and then reschedules in its finally block; the reschedule
# enqueue is what fails validation here.
ScriptJob.handle(job, data={}, commit=False)
job.refresh_from_db()
# The completed run's status is preserved (not flipped to ERRORED)
self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
# No successor was scheduled
self.assertEqual(
Job.objects.filter(name='Recurring').exclude(pk=job.pk).count(), 0
)
# The reschedule failure was recorded on the job
self.assertTrue(any('not rescheduled' in entry.get('message', '') for entry in job.log_entries))

View File

@ -1349,6 +1349,63 @@ class ScriptValidationErrorTestCase(TestCase):
self.assertEqual(len(messages), 0)
class ScriptMetaValidationViewTestCase(TestCase):
"""
A script whose Meta declares an invalid job_timeout or notifications_default must surface an actionable error on
the run view rather than returning an HTTP 500 (#22872).
"""
user_permissions = ['extras.view_script', 'extras.run_script']
class BadTimeoutScript(PythonClass):
class Meta:
name = 'Bad Timeout'
job_timeout = 'not-a-timeout'
def run(self, data, commit):
return "Complete"
class BadNotificationsScript(PythonClass):
class Meta:
name = 'Bad Notifications'
notifications_default = 'on_error'
def run(self, data, commit):
return "Complete"
@classmethod
def setUpTestData(cls):
with patch.object(ScriptModule, 'sync_classes'):
module = ScriptModule.objects.create(
file_root=ManagedFileRootPathChoices.SCRIPTS,
file_path='bad_meta.py',
)
cls.script = Script.objects.create(module=module, name='Bad meta', is_executable=True)
def _run_and_assert(self, python_class):
url = reverse('extras:script', kwargs={'pk': self.script.pk})
with patch.object(Script, 'python_class', new_callable=PropertyMock) as mock_python_class:
mock_python_class.return_value = python_class
with patch('extras.views.any_workers_for_queue', return_value=True):
with self.captureOnCommitCallbacks(execute=True):
# Quick-run style: omit _notifications
response = self.client.post(url, {'_commit': 'true'})
# Re-render with an error message, not a 500, and no Job enqueued
self.assertEqual(response.status_code, 200)
messages = list(response.context['messages'])
self.assertEqual(len(messages), 1)
self.assertIn('Unable to run script', str(messages[0]))
self.assertEqual(Job.objects.count(), 0)
@tag('regression')
def test_invalid_job_timeout_shows_error(self):
self._run_and_assert(self.BadTimeoutScript)
@tag('regression')
def test_invalid_notifications_default_shows_error(self):
self._run_and_assert(self.BadNotificationsScript)
class ScriptDefaultValuesTestCase(TestCase):
user_permissions = ['extras.view_script', 'extras.run_script']

View File

@ -3,6 +3,7 @@ from datetime import datetime
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.paginator import EmptyPage
from django.db.models import Count, Q
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
@ -1751,19 +1752,25 @@ class ScriptView(BaseScriptView):
messages.error(request, _("Unable to run script: RQ worker process not running."))
elif form.is_valid():
ScriptJob = import_string("extras.jobs.ScriptJob")
job = ScriptJob.enqueue(
instance=script,
user=request.user,
schedule_at=form.cleaned_data.pop('_schedule_at'),
interval=form.cleaned_data.pop('_interval'),
notifications=form.cleaned_data.pop('_notifications'),
data=form.cleaned_data,
request=copy_safe_request(request),
job_timeout=script.python_class.job_timeout,
commit=form.cleaned_data.pop('_commit'),
)
return redirect('extras:script_result', job_pk=job.pk)
try:
job = ScriptJob.enqueue(
instance=script,
user=request.user,
schedule_at=form.cleaned_data.pop('_schedule_at'),
interval=form.cleaned_data.pop('_interval'),
notifications=form.cleaned_data.pop('_notifications'),
data=form.cleaned_data,
request=copy_safe_request(request),
job_timeout=script.python_class.job_timeout,
commit=form.cleaned_data.pop('_commit'),
)
except ValidationError as e:
# The script's Meta configuration is invalid (see #22872). Surface it as a form error rather than
# allowing the exception to bubble up as an HTTP 500.
for msg in e.messages:
messages.error(request, _("Unable to run script: {error}").format(error=msg))
else:
return redirect('extras:script_result', job_pk=job.pk)
else:
fieldset_fields = {field for _, fields in script_class.get_fieldsets() for field in fields}
hidden_errors = {

View File

@ -54,6 +54,7 @@ class ASNSiteSerializer(PrimaryModelSerializer):
model = Site
fields = ('id', 'url', 'display', 'name', 'description', 'slug')
brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug')
ref_name = 'ASNSite'
class ASNSerializer(PrimaryModelSerializer):

View File

@ -946,28 +946,28 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
@register_filterset
class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
scope_type = MultiValueContentTypeFilter()
region = django_filters.NumberFilter(
region = MultiValueNumberFilter(
method='filter_scope'
)
site_group = django_filters.NumberFilter(
site_group = MultiValueNumberFilter(
method='filter_scope'
)
site = django_filters.NumberFilter(
site = MultiValueNumberFilter(
method='filter_scope'
)
location = django_filters.NumberFilter(
location = MultiValueNumberFilter(
method='filter_scope'
)
rack_group = django_filters.NumberFilter(
rack_group = MultiValueNumberFilter(
method='filter_scope'
)
rack = django_filters.NumberFilter(
rack = MultiValueNumberFilter(
method='filter_scope'
)
cluster_group = django_filters.NumberFilter(
cluster_group = MultiValueNumberFilter(
method='filter_scope'
)
cluster = django_filters.NumberFilter(
cluster = MultiValueNumberFilter(
method='filter_scope'
)
contains_vid = django_filters.NumberFilter(
@ -992,7 +992,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
model_name = name.replace('_', '')
return queryset.filter(
scope_type=ContentType.objects.get(model=model_name),
scope_id=value
scope_id__in=value
)

View File

@ -895,7 +895,7 @@ class ServiceCreateForm(ServiceForm):
class Meta(ServiceForm.Meta):
fields = [
'service_template', 'name', 'port_mappings', 'ipaddresses', 'description',
'service_template', 'name', 'port_mappings', 'ipaddresses', 'description', 'owner',
'comments', 'tags',
]

View File

@ -16,6 +16,7 @@ from utilities.data import (
check_ranges_overlap,
get_inclusive_integer_range_bounds,
normalize_integer_range,
normalize_update_fields,
ranges_to_string,
ranges_to_string_list,
)
@ -100,13 +101,16 @@ class VLANGroup(OrganizationalModel):
verbose_name_plural = _('VLAN groups')
def clean(self):
super().clean()
# Validate scope assignment
# Validate the scope pair first, since BaseModel.clean() keys its errors to scope_id, which forms omit
if self.scope_type and not self.scope_id:
raise ValidationError(_("Cannot set scope_type without scope_id."))
scope_type = self.scope_type.model_class()
raise ValidationError(
_("Please select a {scope_type}.").format(scope_type=scope_type._meta.model_name)
)
if self.scope_id and not self.scope_type:
raise ValidationError(_("Cannot set scope_id without scope_type."))
raise ValidationError({'scope_type': _("Please select a scope type.")})
super().clean()
# Validate VID ranges
for vid_range in self.vid_ranges:
@ -145,10 +149,10 @@ class VLANGroup(OrganizationalModel):
self.total_vlan_ids += vid_range.upper - vid_range.lower
self.vid_ranges = vid_ranges
update_fields = kwargs.get('update_fields')
update_fields = normalize_update_fields(kwargs)
if update_fields is not None and 'vid_ranges' in update_fields:
# total_vlan_ids is a denormalized cache of vid_ranges; persist them together.
kwargs['update_fields'] = list(set(update_fields) | {'total_vlan_ids'})
kwargs['update_fields'] = update_fields | {'total_vlan_ids'}
super().save(*args, **kwargs)

View File

@ -1,7 +1,7 @@
{
"aggregate:api_list_objects": 13,
"aggregate:list_objects_with_permission": 21,
"asn:api_list_objects": 17,
"asn:api_list_objects": 16,
"asn:list_objects_with_permission": 28,
"asnrange:api_list_objects": 14,
"asnrange:list_objects_with_permission": 19,
@ -32,6 +32,6 @@
"vlantranslationpolicy:list_objects_with_permission": 17,
"vlantranslationrule:api_list_objects": 12,
"vlantranslationrule:list_objects_with_permission": 18,
"vrf:api_list_objects": 14,
"vrf:api_list_objects": 20,
"vrf:list_objects_with_permission": 17
}

View File

@ -224,6 +224,15 @@ class VRFTestCase(APIViewTestCases.APIViewTestCase):
@classmethod
def setUpTestData(cls):
tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
route_targets = (
RouteTarget(name='65000:1001', tenant=tenant),
RouteTarget(name='65000:1002', tenant=tenant),
RouteTarget(name='65000:1003', tenant=tenant),
)
RouteTarget.objects.bulk_create(route_targets)
vrfs = (
VRF(name='VRF 1', rd='65000:1'),
VRF(name='VRF 2', rd='65000:2'),
@ -231,6 +240,11 @@ class VRFTestCase(APIViewTestCases.APIViewTestCase):
)
VRF.objects.bulk_create(vrfs)
# Assigned so the query count baseline covers the non-nested route target expansion.
for vrf in vrfs:
vrf.import_targets.set(route_targets)
vrf.export_targets.set(route_targets)
class RouteTargetTestCase(APIViewTestCases.APIViewTestCase):
model = RouteTarget

View File

@ -5,7 +5,19 @@ from netaddr import IPNetwork
from circuits.models import Provider
from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices
from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup
from dcim.models import (
Device,
DeviceRole,
DeviceType,
Interface,
Location,
Manufacturer,
Rack,
RackGroup,
Region,
Site,
SiteGroup,
)
from ipam.choices import *
from ipam.filtersets import *
from ipam.models import *
@ -1764,32 +1776,104 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_region(self):
params = {'region': Region.objects.first().pk}
regions = (
Region.objects.get(slug='region-1'),
Region.objects.create(name='Region 2', slug='region-2'),
)
VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=regions[1])
params = {'region': [regions[0].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'region': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site_group(self):
params = {'site_group': SiteGroup.objects.first().pk}
site_groups = (
SiteGroup.objects.get(slug='site-group-1'),
SiteGroup.objects.create(name='Site Group 2', slug='site-group-2'),
)
VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=site_groups[1])
params = {'site_group': [site_groups[0].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'site_group': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
params = {'site': Site.objects.first().pk}
sites = (
Site.objects.get(slug='site-1'),
Site.objects.create(name='Site 2', slug='site-2'),
)
VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=sites[1])
params = {'site': [sites[0].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'site': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_location(self):
params = {'location': Location.objects.first().pk}
site = Site.objects.get(slug='site-1')
locations = (
Location.objects.get(slug='location-1'),
Location.objects.create(name='Location 2', slug='location-2', site=site),
)
VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=locations[1])
params = {'location': [locations[0].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'location': [locations[0].pk, locations[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rack_group(self):
rack_groups = (
RackGroup.objects.create(name='Rack Group 1', slug='rack-group-1'),
RackGroup.objects.create(name='Rack Group 2', slug='rack-group-2'),
)
VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=rack_groups[0])
VLANGroup.objects.create(name='VLAN Group 10', slug='vlan-group-10', scope=rack_groups[1])
params = {'rack_group': [rack_groups[0].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'rack_group': [rack_groups[0].pk, rack_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rack(self):
params = {'rack': Rack.objects.first().pk}
site = Site.objects.get(slug='site-1')
racks = (
Rack.objects.get(name='Rack 1'),
Rack.objects.create(name='Rack 2', site=site),
)
VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=racks[1])
params = {'rack': [racks[0].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'rack': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cluster_group(self):
params = {'cluster_group': ClusterGroup.objects.first().pk}
cluster_groups = (
ClusterGroup.objects.get(slug='cluster-group-1'),
ClusterGroup.objects.create(name='Cluster Group 2', slug='cluster-group-2'),
)
VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=cluster_groups[1])
params = {'cluster_group': [cluster_groups[0].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'cluster_group': [cluster_groups[0].pk, cluster_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cluster(self):
params = {'cluster': Cluster.objects.first().pk}
cluster_type = ClusterType.objects.get(slug='cluster-type-1')
clusters = (
Cluster.objects.get(name='Cluster 1'),
Cluster.objects.create(name='Cluster 2', type=cluster_type),
)
VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=clusters[1])
params = {'cluster': [clusters[0].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'cluster': [clusters[0].pk, clusters[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]

View File

@ -5,13 +5,15 @@ from django.test import TestCase
from dcim.constants import InterfaceTypeChoices
from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
from ipam.constants import SERVICE_PORT_MAX
from ipam.choices import PrefixStatusChoices
from ipam.constants import SERVICE_PORT_MAX, VLANGROUP_SCOPE_TYPES
from ipam.filtersets import ServiceFilterSet, ServiceTemplateFilterSet
from ipam.forms import PrefixForm, VLANIDBulkCreateForm
from ipam.forms import PrefixForm, VLANGroupBulkEditForm, VLANGroupForm, VLANIDBulkCreateForm
from ipam.forms.bulk_import import IPAddressImportForm, ServiceTemplateImportForm
from ipam.forms.fields import PortMappingField
from ipam.forms.filtersets import ServiceFilterForm, ServiceTemplateFilterForm
from ipam.forms.widgets import PortMappingWidget
from ipam.models import Prefix, VLANGroup
class PrefixFormTestCase(TestCase):
@ -60,6 +62,26 @@ class PrefixFormTestCase(TestCase):
})
assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs
def test_scope_type_change_without_scope(self):
"""Changing the scope type without selecting a scope is reported on the scope field."""
prefix = Prefix.objects.create(
prefix='10.0.0.0/24',
scope_type=ContentType.objects.get_for_model(Site),
scope_id=self.site.pk,
)
form = PrefixForm(
data={
'prefix': '10.0.0.0/24',
'status': PrefixStatusChoices.STATUS_ACTIVE,
'scope_content_type': ContentType.objects.get_for_model(Location).pk,
'scope_object_id': '',
},
instance=prefix,
)
self.assertFalse(form.is_valid())
self.assertIn('scope', form.errors)
class IPAddressImportFormTestCase(TestCase):
"""Tests for IPAddressImportForm bulk import behavior."""
@ -452,3 +474,76 @@ class ServiceFilterFormTestCase(TestCase):
form = form_class(data={'port_mappings': 'tcp/80'})
self.assertTrue(form.is_valid(), form.errors)
self.assertEqual(form.cleaned_data['port_mappings'], 'tcp/80')
class VLANGroupFormTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.site = Site.objects.create(name='Site 1', slug='site-1')
cls.site_type = ContentType.objects.get_for_model(Site)
cls.location_type = ContentType.objects.get_for_model(Location)
cls.vlan_group = VLANGroup.objects.create(
name='VLAN Group 1',
slug='vlan-group-1',
scope=cls.site,
)
def test_scope_can_be_cleared(self):
"""Clearing scope type and scope on an existing group nulls the assignment."""
form = VLANGroupForm(
data=self.get_form_data(scope_content_type='', scope_object_id=''),
instance=VLANGroup.objects.get(pk=self.vlan_group.pk),
)
self.assertTrue(form.is_valid(), form.errors)
vlan_group = form.save()
vlan_group.refresh_from_db()
self.assertIsNone(vlan_group.scope_type_id)
self.assertIsNone(vlan_group.scope_id)
def test_scope_required_with_scope_type(self):
"""A scope type without a scope is reported on the scope field."""
forms = {
'existing group': VLANGroupForm(
data=self.get_form_data(scope_object_id=''),
instance=VLANGroup.objects.get(pk=self.vlan_group.pk),
),
'new group': VLANGroupForm(
data=self.get_form_data(name='VLAN Group 2', slug='vlan-group-2', scope_object_id=''),
),
'retyped group': VLANGroupForm(
data=self.get_form_data(scope_content_type=self.location_type.pk, scope_object_id=''),
instance=VLANGroup.objects.get(pk=self.vlan_group.pk),
),
}
for case, form in forms.items():
with self.subTest(case=case):
self.assertFalse(form.is_valid())
self.assertIn('scope', form.errors)
def test_scope_initial_retained_for_new_group(self):
"""A prepopulated scope survives instantiation of an unsaved group."""
form = VLANGroupForm(initial={'scope': self.site})
self.assertEqual(form.initial['scope'], self.site)
def test_scope_type_choices(self):
"""Both VLAN group forms offer every VLAN group scope type."""
for form_class in (VLANGroupForm, VLANGroupBulkEditForm):
with self.subTest(form=form_class.__name__):
form = form_class()
models = set(
form.fields['scope'].content_type_queryset.values_list('model', flat=True)
)
self.assertEqual(models, set(VLANGROUP_SCOPE_TYPES))
def get_form_data(self, **overrides):
return {
'name': self.vlan_group.name,
'slug': self.vlan_group.slug,
'vid_ranges': '1-4094',
'scope_content_type': self.site_type.pk,
'scope_object_id': self.site.pk,
**overrides,
}

View File

@ -20,7 +20,7 @@ from ipam.utils import AvailableIPSpace
from ipam.views import AggregatePrefixesView, PrefixPrefixesView
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
from tenancy.models import Tenant
from users.models import Group, ObjectPermission
from users.models import Group, ObjectPermission, Owner
from utilities.testing import ViewTestCases, create_tags, post_data
@ -2817,6 +2817,8 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
IPAddress.objects.bulk_create(ip_addresses)
owner = Owner.objects.create(name='Owner 1')
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
@ -2826,6 +2828,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'port_mappings': '[{"protocol": "tcp", "ports": "104,105"}, {"protocol": "udp", "ports": "104"}]',
'ipaddresses': [],
'description': 'A new service',
'owner': owner.pk,
'tags': [t.pk for t in tags],
}

View File

@ -8,13 +8,14 @@ from io import BytesIO
from pathlib import Path
from django.contrib.auth import get_user_model
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
from django.core.exceptions import ImproperlyConfigured, PermissionDenied, ValidationError
from django.core.handlers.wsgi import WSGIRequest
from django.db.models import ProtectedError, RestrictedError
from django.http import Http404
from django.utils import timezone
from django.utils.functional import classproperty
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from django_pg_utils import advisory_lock
from rest_framework.exceptions import APIException
from rq.timeouts import JobTimeoutException
@ -157,26 +158,46 @@ class JobRunner(ABC):
**kwargs,
)
if cls in registry['system_jobs']:
# System jobs are also scheduled by `enqueue_once()` at worker startup,
# which races with this finally block and can produce duplicate schedules
# (see #22232). Acquire the same advisory lock used by `enqueue_once()`
# and skip rescheduling if a successor is already enqueued.
#
# This branch is limited to system jobs because generic recurring jobs
# (e.g. scheduled scripts) may have multiple legitimate schedules sharing
# the same runner/object/interval but differing in their runtime kwargs.
with advisory_lock(ADVISORY_LOCK_KEYS['job-schedules']):
successor_exists = Job.objects.filter(
name=cls.name,
object_id__isnull=True,
status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES,
interval=job.interval,
).exclude(pk=job.pk).exists()
if not successor_exists:
cls.enqueue(**enqueue_kwargs)
else:
cls.enqueue(**enqueue_kwargs)
# Reschedule the next occurrence. If the object's configuration has become invalid since this run was
# scheduled (e.g. a script's Meta.job_timeout was edited to an invalid value, see #22872), the enqueue
# will raise a ValidationError. Record it on this job and decline to reschedule rather than allowing an
# unhandled exception to escape the worker's finally block.
try:
if cls in registry['system_jobs']:
# System jobs are also scheduled by `enqueue_once()` at worker startup,
# which races with this finally block and can produce duplicate schedules
# (see #22232). Acquire the same advisory lock used by `enqueue_once()`
# and skip rescheduling if a successor is already enqueued.
#
# This branch is limited to system jobs because generic recurring jobs
# (e.g. scheduled scripts) may have multiple legitimate schedules sharing
# the same runner/object/interval but differing in their runtime kwargs.
with advisory_lock(ADVISORY_LOCK_KEYS['job-schedules']):
successor_exists = Job.objects.filter(
name=cls.name,
object_id__isnull=True,
status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES,
interval=job.interval,
).exclude(pk=job.pk).exists()
if not successor_exists:
cls.enqueue(**enqueue_kwargs)
else:
cls.enqueue(**enqueue_kwargs)
except ValidationError as e:
# The successor could not be scheduled because the object's configuration is now invalid. Record
# this against the (already-terminated) job without overwriting the outcome of the run that just
# completed — re-running terminate() here would clobber a successful run's status and fire a
# duplicate notification (see #22872).
error = _("Recurring job not rescheduled due to invalid configuration: {error}").format(
error='; '.join(e.messages)
)
logger.error(f"Job {job}: {error}")
job.log(logging.makeLogRecord({
'levelno': logging.ERROR,
'levelname': 'ERROR',
'msg': error,
}))
job.save()
@classmethod
def get_jobs(cls, instance=None):

View File

@ -425,6 +425,7 @@ CACHES = {
'LOCATION': CACHING_REDIS_URL,
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'USERNAME': CACHING_REDIS_USERNAME,
'PASSWORD': CACHING_REDIS_PASSWORD,
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -30,9 +30,9 @@
"flatpickr": "4.6.13",
"gridstack": "12.6.0",
"htmx.org": "2.0.10",
"markdown-it": "^14.3.0",
"picomatch": "4.0.5",
"query-string": "9.5.0",
"markdown-it": "^15.0.1",
"picomatch": "4.0.7",
"query-string": "9.5.1",
"sass": "1.103.1",
"tom-select": "2.6.2",
"typeface-inter": "3.18.1",

View File

@ -75,22 +75,34 @@ class SideNav {
toggler.addEventListener('click', event => this.onMobileToggle(event));
}
if (window.matchMedia(SIDENAV_DESKTOP_MEDIA).matches) {
if (this.state.get('pinned')) {
this.pin();
} else {
this.unpin();
}
} else {
this.bodyRemove('hide');
this.bodyAdd('hidden');
}
const desktopMedia = window.matchMedia(SIDENAV_DESKTOP_MEDIA);
this.setResponsiveState(desktopMedia.matches);
desktopMedia.addEventListener('change', event => {
this.setResponsiveState(event.matches);
this.initLinks();
});
window.addEventListener('resize', () => this.onResize());
this.base.addEventListener('mouseenter', () => this.onEnter());
this.base.addEventListener('mouseleave', () => this.onLeave());
}
/**
* Apply the appropriate sidenav state for the current responsive layout.
*/
private setResponsiveState(isDesktop: boolean): void {
this.bodyRemove('hide');
if (isDesktop && this.state.get('pinned')) {
this.bodyRemove('hidden');
this.bodyAdd('show', 'pinned');
} else {
this.bodyRemove('show', 'pinned');
this.bodyAdd('hidden');
}
}
/**
* If the sidenav is shown, expand active nav links. Otherwise, collapse them.
*/
@ -164,12 +176,14 @@ class SideNav {
switch (action) {
case 'expand':
groupLink.setAttribute('aria-expanded', 'true');
groupLink.classList.add('show');
groupItem.classList.add('active');
dropdownMenu.classList.add('show');
link.classList.add('active');
break;
case 'collapse':
groupLink.setAttribute('aria-expanded', 'false');
groupLink.classList.remove('show');
groupItem.classList.remove('active');
dropdownMenu.classList.remove('show');
link.classList.remove('active');

View File

@ -154,13 +154,20 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87"
integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==
"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1":
"@eslint-community/eslint-utils@^4.8.0":
version "4.9.1"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595"
integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==
dependencies:
eslint-visitor-keys "^3.4.3"
"@eslint-community/eslint-utils@^4.9.1":
version "4.10.1"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6"
integrity sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==
dependencies:
eslint-visitor-keys "^3.4.3"
"@eslint-community/regexpp@^4.12.2":
version "4.12.2"
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
@ -212,9 +219,9 @@
strip-json-comments "^3.1.1"
"@eslint/js@^9.39.2":
version "9.39.4"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.4.tgz#a3f83bfc6fd9bf33a853dfacd0b49b398eb596c1"
integrity sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==
version "9.39.5"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.5.tgz#6f2fbcff75500d229d535e0a949ae13472c84787"
integrity sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==
"@eslint/object-schema@^3.0.5":
version "3.0.5"
@ -918,99 +925,99 @@
"@types/estree" "*"
"@typescript-eslint/eslint-plugin@^8.68.0":
version "8.68.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz#a8fbdb1cf49aafaf16071b646daad890151bd149"
integrity sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==
version "8.69.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz#bf74cc392ebcaaf096bc8b4c4d7bbeb0677687b8"
integrity sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==
dependencies:
"@eslint-community/regexpp" "^4.12.2"
"@typescript-eslint/scope-manager" "8.68.0"
"@typescript-eslint/type-utils" "8.68.0"
"@typescript-eslint/utils" "8.68.0"
"@typescript-eslint/visitor-keys" "8.68.0"
"@typescript-eslint/scope-manager" "8.69.0"
"@typescript-eslint/type-utils" "8.69.0"
"@typescript-eslint/utils" "8.69.0"
"@typescript-eslint/visitor-keys" "8.69.0"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@^8.68.0":
version "8.68.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.68.0.tgz#61de31481354c50457bc9621a7ed746779f09ee7"
integrity sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==
version "8.69.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.69.0.tgz#de3ead2b35e5c71580eda40820adb4fd14834ca1"
integrity sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==
dependencies:
"@typescript-eslint/scope-manager" "8.68.0"
"@typescript-eslint/types" "8.68.0"
"@typescript-eslint/typescript-estree" "8.68.0"
"@typescript-eslint/visitor-keys" "8.68.0"
"@typescript-eslint/scope-manager" "8.69.0"
"@typescript-eslint/types" "8.69.0"
"@typescript-eslint/typescript-estree" "8.69.0"
"@typescript-eslint/visitor-keys" "8.69.0"
debug "^4.4.3"
"@typescript-eslint/project-service@8.68.0":
version "8.68.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.68.0.tgz#ea4b2869f59165c420cd7a4bbebc38039794e8cc"
integrity sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==
"@typescript-eslint/project-service@8.69.0":
version "8.69.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.69.0.tgz#cf728554436a50e644a5214a89fe02cb1ffa9af8"
integrity sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.68.0"
"@typescript-eslint/types" "^8.68.0"
"@typescript-eslint/tsconfig-utils" "^8.69.0"
"@typescript-eslint/types" "^8.69.0"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.68.0":
version "8.68.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz#e5a13a1159497faeab4e48279bf07576045b1499"
integrity sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==
"@typescript-eslint/scope-manager@8.69.0":
version "8.69.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz#13f3d1e25108e95a9ceb5a198806d1fa558f8c7a"
integrity sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==
dependencies:
"@typescript-eslint/types" "8.68.0"
"@typescript-eslint/visitor-keys" "8.68.0"
"@typescript-eslint/types" "8.69.0"
"@typescript-eslint/visitor-keys" "8.69.0"
"@typescript-eslint/tsconfig-utils@8.68.0", "@typescript-eslint/tsconfig-utils@^8.68.0":
version "8.68.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz#594d7a3c5952055b3c431fc563ca7fd1defcce18"
integrity sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==
"@typescript-eslint/tsconfig-utils@8.69.0", "@typescript-eslint/tsconfig-utils@^8.69.0":
version "8.69.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz#d3b0ccc781ab252a90a0b3989b9d1eb85ab59469"
integrity sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==
"@typescript-eslint/type-utils@8.68.0":
version "8.68.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz#8f3e838dbd740909db27053857468cd037b00220"
integrity sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==
"@typescript-eslint/type-utils@8.69.0":
version "8.69.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz#7ce68d2ebcbedd8421806c27a7f360755017159f"
integrity sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==
dependencies:
"@typescript-eslint/types" "8.68.0"
"@typescript-eslint/typescript-estree" "8.68.0"
"@typescript-eslint/utils" "8.68.0"
"@typescript-eslint/types" "8.69.0"
"@typescript-eslint/typescript-estree" "8.69.0"
"@typescript-eslint/utils" "8.69.0"
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@typescript-eslint/types@8.68.0", "@typescript-eslint/types@^8.68.0":
version "8.68.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.68.0.tgz#3f9d4e62fbe5728f09403cdc7b4d58af842ac1af"
integrity sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==
"@typescript-eslint/types@8.69.0", "@typescript-eslint/types@^8.69.0":
version "8.69.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.69.0.tgz#5d9ad3f707c2e4f70a2db540031104df3e63bcf5"
integrity sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==
"@typescript-eslint/typescript-estree@8.68.0":
version "8.68.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz#bf4165029825138ac27231a3ff02923ecd977f38"
integrity sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==
"@typescript-eslint/typescript-estree@8.69.0":
version "8.69.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz#efa915913ffe2049bbfd26092b95d1bc7c9c454f"
integrity sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==
dependencies:
"@typescript-eslint/project-service" "8.68.0"
"@typescript-eslint/tsconfig-utils" "8.68.0"
"@typescript-eslint/types" "8.68.0"
"@typescript-eslint/visitor-keys" "8.68.0"
"@typescript-eslint/project-service" "8.69.0"
"@typescript-eslint/tsconfig-utils" "8.69.0"
"@typescript-eslint/types" "8.69.0"
"@typescript-eslint/visitor-keys" "8.69.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.68.0":
version "8.68.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.68.0.tgz#00547f2c8de8aca2a3c21752a9711f73206fd36d"
integrity sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==
"@typescript-eslint/utils@8.69.0":
version "8.69.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.69.0.tgz#67ad9c00edf12fe2fbc0bf0a71b00822a8d02e97"
integrity sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.68.0"
"@typescript-eslint/types" "8.68.0"
"@typescript-eslint/typescript-estree" "8.68.0"
"@typescript-eslint/scope-manager" "8.69.0"
"@typescript-eslint/types" "8.69.0"
"@typescript-eslint/typescript-estree" "8.69.0"
"@typescript-eslint/visitor-keys@8.68.0":
version "8.68.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz#78db3c9bb258a0309d9e2b1b617127c3a8fb1f54"
integrity sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==
"@typescript-eslint/visitor-keys@8.69.0":
version "8.69.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz#f659785dbb79733c40499f71a65439e2033966b5"
integrity sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==
dependencies:
"@typescript-eslint/types" "8.68.0"
"@typescript-eslint/types" "8.69.0"
eslint-visitor-keys "^5.0.0"
"@unrs/resolver-binding-android-arm-eabi@1.11.1":
@ -1140,6 +1147,11 @@ argparse@^2.0.1:
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
argparse@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-3.0.1.tgz#328ad1798f5ab5558a13fb33d1cb3bb072b5c0f8"
integrity sha512-nM4mHF/KM1v59ZNKX7zfusQz5wUAxR511YG8Vo6TyiV4aqhu++rbJW4v04xsWhpSsHFj66flT8P7znVpyO20xQ==
aria-hidden@^1.2.3, aria-hidden@^1.2.4:
version "1.2.6"
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.6.tgz#73051c9b088114c795b1ea414e9c0fff874ffc1a"
@ -1277,10 +1289,10 @@ brace-expansion@^1.1.7:
balanced-match "^1.0.0"
concat-map "0.0.1"
brace-expansion@^5.0.5:
version "5.0.6"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.6.tgz#ec68fe0a641a29d8711579caf641d05bae1f2285"
integrity sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==
brace-expansion@^5.0.8:
version "5.0.9"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf"
integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==
dependencies:
balanced-match "^4.0.2"
@ -1537,11 +1549,16 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1:
es-errors "^1.3.0"
gopd "^1.2.0"
entities@^4.4.0, entities@^4.5.0:
entities@^4.4.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
entities@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-8.0.0.tgz#c1df5fe3602429747fa233d0dd26f142f0ce4743"
integrity sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==
es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.2:
version "1.23.3"
resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz"
@ -2169,9 +2186,9 @@ globals@^14.0.0:
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
globals@^17.11.0:
version "17.11.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.11.0.tgz#d643485bb30220d7751e511cf4f68c73d3870d87"
integrity sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==
version "17.12.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.12.0.tgz#910a368b8f093d3feb3f77a56dcefa05647d698e"
integrity sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==
globalthis@^1.0.3, globalthis@^1.0.4:
version "1.0.4"
@ -2293,9 +2310,9 @@ ignore@^5.2.0:
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
ignore@^7.0.5:
version "7.0.5"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9"
integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==
version "7.0.8"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.8.tgz#84d8466899958458ee30b4190c839ee1446cc88d"
integrity sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==
immutable@^5.1.5:
version "5.1.5"
@ -2697,13 +2714,20 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
linkify-it@^5.0.1, linkify-it@^5.0.2:
linkify-it@^5.0.1:
version "5.0.2"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.2.tgz#d3be0a693af3da9df3883f1e346a0e97461a8c19"
integrity sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==
dependencies:
uc.micro "^2.0.0"
linkify-it@^6.0.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-6.1.0.tgz#cd750d70fe9295eb7169e632ee67a4ff43e7f62d"
integrity sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==
dependencies:
uc.micro "^3.0.0"
locate-path@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
@ -2718,7 +2742,7 @@ loose-envify@^1.1.0:
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
markdown-it@^14.1.0, markdown-it@^14.2.0, markdown-it@^14.3.0:
markdown-it@^14.1.0, markdown-it@^14.2.0:
version "14.2.0"
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.2.0.tgz#06d48d9035e77d5b1c85adb315482fc8240289ef"
integrity sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==
@ -2730,12 +2754,24 @@ markdown-it@^14.1.0, markdown-it@^14.2.0, markdown-it@^14.3.0:
punycode.js "^2.3.1"
uc.micro "^2.1.0"
markdown-it@^15.0.1:
version "15.0.1"
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-15.0.1.tgz#33b87eafff0feb08cbef5edfd9bc20b3d920f5d8"
integrity sha512-9/7gE95FNPkfUWrjJIoHZza2iLmuJlPD0UNMxPi7bxUrbCR525YZY0r+zyfes0dZI5ZZ/uNIXUJca0pJvtw41g==
dependencies:
argparse "^3.0.0"
entities "^8.0.0"
linkify-it "^6.0.0"
mdurl "^2.1.0"
punycode.js "^2.3.1"
uc.micro "^3.0.0"
math-intrinsics@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz"
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
mdurl@^2.0.0:
mdurl@^2.0.0, mdurl@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.1.0.tgz#d711d3f7bce7f22c487c91be78545f356fa96573"
integrity sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==
@ -2754,11 +2790,11 @@ micromatch@^4.0.5:
picomatch "^2.3.1"
minimatch@^10.2.2:
version "10.2.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1"
integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==
version "10.2.6"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef"
integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==
dependencies:
brace-expansion "^5.0.5"
brace-expansion "^5.0.8"
minimatch@^10.2.4, minimatch@^10.2.5, minimatch@^3.1.2, minimatch@^3.1.3, minimatch@^3.1.5:
version "3.1.5"
@ -2937,15 +2973,15 @@ picomatch@2.3.2, picomatch@^2.3.1:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601"
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
picomatch@4.0.4, picomatch@^4.0.3, picomatch@^4.0.4:
picomatch@4.0.4, picomatch@^4.0.3:
version "4.0.4"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
picomatch@4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab"
integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==
picomatch@4.0.7, picomatch@^4.0.4:
version "4.0.7"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f"
integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==
possible-typed-array-names@^1.0.0:
version "1.0.0"
@ -2979,10 +3015,10 @@ 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.5.0:
version "9.5.0"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.5.0.tgz#e6f4003dcb321580dd043109b6fbe2a3890afff6"
integrity sha512-YlJmwNyi0RGYjlxYcuDncMsxFU7YyutbuI7gTm8ySxIGBlwx5yiBCOD5ig9ZNoHkawk/1Dey0N5mEfcUybMVAA==
query-string@9.5.1:
version "9.5.1"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.5.1.tgz#aecdc091d3dc7ce293eed83957e220d523a3d0f7"
integrity sha512-/zO3RwuRCMTIcEgq6YMv4OrtEE1XzBG7w5N6zc6ydYnkWYOWsnLI/5894hYEzfESfMOT4cHCsRTIdxsSl1KjGg==
dependencies:
decode-uri-component "^0.5.0"
filter-obj "^5.1.0"
@ -3228,9 +3264,9 @@ semver@^7.7.1:
integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==
semver@^7.7.3:
version "7.8.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.0.tgz#ed0661039fcbcda2ce71f01fa6adbefaa77040df"
integrity sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==
version "7.8.5"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69"
integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==
set-function-length@^1.2.1, set-function-length@^1.2.2:
version "1.2.2"
@ -3448,9 +3484,9 @@ tinyglobby@^0.2.14:
picomatch "^4.0.3"
tinyglobby@^0.2.15:
version "0.2.16"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6"
integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==
version "0.2.17"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631"
integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==
dependencies:
fdir "^6.5.0"
picomatch "^4.0.4"
@ -3611,6 +3647,11 @@ uc.micro@^2.0.0, uc.micro@^2.1.0:
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
uc.micro@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-3.0.0.tgz#64d6cb0bfe2a558ce0ec7c8c0dc88a02d898f506"
integrity sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==
unbox-primitive@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,7 @@ class NestedGroupSerializer(WritableNestedSerializer):
class Meta:
model = models.Group
fields = ['id', 'url', 'display_url', 'display', 'name']
ref_name = 'NestedGroup'
class NestedUserSerializer(WritableNestedSerializer):
@ -22,6 +23,7 @@ class NestedUserSerializer(WritableNestedSerializer):
class Meta:
model = models.User
fields = ['id', 'url', 'display_url', 'display', 'username']
ref_name = 'NestedUser'
@extend_schema_field(OpenApiTypes.STR)
def get_display(self, obj):

View File

@ -1,7 +1,7 @@
{
"group:api_list_objects": 10,
"group:list_objects_with_permission": 16,
"objectpermission:api_list_objects": 14,
"objectpermission:api_list_objects": 12,
"objectpermission:list_objects_with_permission": 17,
"owner:api_list_objects": 11,
"owner:list_objects_with_permission": 18,

View File

@ -8,17 +8,18 @@ from django.core.exceptions import (
ObjectDoesNotExist,
ValidationError,
)
from django.db.models.fields.related import ManyToOneRel, RelatedField
from django.db.models.fields.related import ManyToManyRel, ManyToOneRel, RelatedField
from django.urls import reverse
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from rest_framework.permissions import BasePermission
from rest_framework.relations import ManyRelatedField
from rest_framework.serializers import ListSerializer, Serializer
from rest_framework.views import get_view_name as drf_get_view_name
from extras.constants import HTTP_CONTENT_TYPE_JSON
from netbox.api.exceptions import GraphQLTypeNotFound, SerializerNotFound
from netbox.api.fields import RelatedObjectCountField
from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField
from netbox.registry import registry
from .query import count_related, dict_to_filter_params
@ -158,6 +159,13 @@ def _get_nested_serializer(serializer_field):
if isinstance(serializer_field, ListSerializer):
serializer_field = serializer_field.child
# DRF wraps a many-valued related field, keeping the original field on child_relation
if isinstance(serializer_field, ManyRelatedField):
serializer_field = serializer_field.child_relation
if isinstance(serializer_field, SerializedPKRelatedField):
return serializer_field.serializer(nested=serializer_field.nested)
if isinstance(serializer_field, Serializer) and hasattr(serializer_field, 'nested'):
return serializer_field
@ -175,7 +183,7 @@ def _get_serializer_fields(serializer: Serializer):
return [field_name for field_name in fields if field_name not in omit]
def get_prefetches_for_serializer(serializer_class, fields=None, omit=None):
def get_prefetches_for_serializer(serializer_class, fields=None, omit=None, _serializer_states=None):
"""
Compile and return a list of fields which should be prefetched on the queryset for a serializer.
"""
@ -187,11 +195,18 @@ def get_prefetches_for_serializer(serializer_class, fields=None, omit=None):
# If fields are not specified, default to all
fields_to_include = fields or serializer_class.Meta.fields
fields_to_omit = omit or []
effective_fields = tuple(name for name in fields_to_include if name not in fields_to_omit)
# Break reference cycles on the current path. The field set is in the key because re-entry at a
# narrower depth is finite, and the states are copied per frame to keep sibling fields independent.
serializer_states = set(_serializer_states or ())
serializer_state = (serializer_class, effective_fields)
if serializer_state in serializer_states:
return []
serializer_states.add(serializer_state)
prefetch_fields = []
for field_name in fields_to_include:
if field_name in fields_to_omit:
continue
for field_name in effective_fields:
serializer_field = serializer_class._declared_fields.get(field_name)
# Determine the name of the model field referenced by the serializer field
@ -202,7 +217,7 @@ def get_prefetches_for_serializer(serializer_class, fields=None, omit=None):
# If the serializer field does not map to a discrete model field, skip it.
try:
field = model._meta.get_field(model_field_name)
if isinstance(field, (RelatedField, ManyToOneRel, GenericForeignKey)):
if isinstance(field, (RelatedField, ManyToOneRel, ManyToManyRel, GenericForeignKey)):
prefetch_fields.append(field.name)
except FieldDoesNotExist:
continue
@ -212,7 +227,9 @@ def get_prefetches_for_serializer(serializer_class, fields=None, omit=None):
# constraints set on that serializer field instance.
if nested_serializer := _get_nested_serializer(serializer_field):
subfields = _get_serializer_fields(nested_serializer)
for subfield in get_prefetches_for_serializer(type(nested_serializer), fields=subfields):
for subfield in get_prefetches_for_serializer(
type(nested_serializer), fields=subfields, _serializer_states=serializer_states
):
prefetch_fields.append(f'{field.name}__{subfield}')
return prefetch_fields

View File

@ -22,6 +22,14 @@ __all__ = (
register = template.Library()
# Query parameters which indicate that a URL has been cryptographically signed by the storage
# backend. Parameters must not be appended to such URLs, as doing so invalidates the signature.
SIGNED_URL_PARAMS = (
'signature', # AWS signature v2; Google Cloud Storage v2
'x-amz-signature', # AWS signature v4 (also MinIO, Ceph, Garage, Cloudflare R2, et al.)
'x-goog-signature', # Google Cloud Storage v4
)
@register.inclusion_tag('builtins/tag.html')
def tag(value, viewname=None):
@ -168,6 +176,11 @@ def static_with_params(path, **params):
parameter conflicts. A warning will be logged if any of the provided parameters
conflict with existing parameters in the URL.
URLs which have been cryptographically signed by the storage backend (e.g. S3 presigned
URLs) are returned unmodified, as appending parameters to them would invalidate their
signature. Such URLs embed an expiration and are regenerated on each request, so they
require no cache-busting parameters.
Args:
path: The static file path (e.g., 'setmode.js')
**params: Query parameters to append (e.g., v='4.3.1')
@ -179,6 +192,8 @@ def static_with_params(path, **params):
If any provided parameters conflict with existing URL parameters, a warning
will be logged and the new parameter value will override the existing one.
"""
logger = logging.getLogger('netbox.utilities.templatetags.tags')
# Get the base static URL
static_url = static(path)
@ -186,8 +201,17 @@ def static_with_params(path, **params):
parsed = urlparse(static_url)
existing_params = parse_qs(parsed.query)
# If the storage backend has signed the URL, return it as-is. Signature schemes such as AWS
# signature v4 cover the entire query string, so appending a parameter here would invalidate
# the signature and the request would be rejected by the storage backend.
if signature_params := [p for p in existing_params if p.lower() in SIGNED_URL_PARAMS]:
logger.debug(
"Static URL '%s' is signed (%s); omitting parameters %s",
static_url, ', '.join(signature_params), tuple(params)
)
return static_url
# Check for duplicate parameters and log warnings
logger = logging.getLogger('netbox.utilities.templatetags.tags')
for key, value in params.items():
if key in existing_params:
logger.warning(

View File

@ -1,3 +1,4 @@
from django.test.testcases import SerializeMixin
from django_rq import get_queue
from django_rq.workers import get_worker
from rq import SimpleWorker
@ -7,10 +8,17 @@ __all__ = (
)
class RQQueueTestMixin:
class RQQueueTestMixin(SerializeMixin):
"""
Clear RQ queues before and after each test.
Test classes using this mixin share a single RQ (Redis) instance. Under the parallel
test runner that Redis is not isolated per worker (unlike the database), so concurrent
classes that enqueue and assert exact queue counts race each other. SerializeMixin
holds an exclusive lock on `lockfile`, so no two classes using this mixin run at the
same time, which removes that cross-worker contention.
"""
lockfile = __file__
rq_queue_names = ('default', 'high', 'low')
@classmethod

View File

@ -11,11 +11,12 @@ from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField
from ipam.api.serializers import VLANSerializer
from ipam.models import VLAN
from netbox.api.fields import SerializedPKRelatedField
from netbox.api.serializers import BaseModelSerializer
from netbox.config import get_config
from netbox.plugins import register_serializer_resolver
from netbox.registry import registry
from users.models import ObjectPermission
from users.models import Group, ObjectPermission
from utilities.api import (
get_prefetches_for_serializer,
get_serializer_for_model,
@ -581,6 +582,213 @@ class GetPrefetchesForSerializerTestCase(TestCase):
['region', 'region__parent'],
)
def test_serialized_pk_related_field(self):
class RegionSerializer(BaseModelSerializer):
class Meta:
model = Region
fields = ('id', 'name', 'parent', 'sites')
brief_fields = ('id', 'parent')
class SiteSerializer(BaseModelSerializer):
region = SerializedPKRelatedField(
queryset=Region.objects.all(),
serializer=RegionSerializer,
nested=True,
)
class Meta:
model = Site
fields = ('id', 'region')
self.assertListEqual(
get_prefetches_for_serializer(SiteSerializer),
['region', 'region__parent'],
)
def test_many_serialized_pk_related_field(self):
class SiteSerializer(BaseModelSerializer):
class Meta:
model = Site
fields = ('id', 'name', 'region', 'group')
brief_fields = ('id', 'region')
class RegionSerializer(BaseModelSerializer):
sites = SerializedPKRelatedField(
queryset=Site.objects.all(),
serializer=SiteSerializer,
nested=True,
many=True,
)
class Meta:
model = Region
fields = ('id', 'sites')
self.assertListEqual(
get_prefetches_for_serializer(RegionSerializer),
['sites', 'sites__region'],
)
self.assertListEqual(
get_prefetches_for_serializer(RegionSerializer, fields=('id',)),
[],
)
self.assertListEqual(
get_prefetches_for_serializer(RegionSerializer, omit=('sites',)),
[],
)
def test_many_serialized_pk_related_field_not_nested(self):
class SiteSerializer(BaseModelSerializer):
class Meta:
model = Site
fields = ('id', 'name', 'region', 'group')
brief_fields = ('id', 'region')
class RegionSerializer(BaseModelSerializer):
sites = SerializedPKRelatedField(
queryset=Site.objects.all(),
serializer=SiteSerializer,
nested=False,
many=True,
)
class Meta:
model = Region
fields = ('id', 'sites')
self.assertListEqual(
get_prefetches_for_serializer(RegionSerializer),
['sites', 'sites__region', 'sites__group'],
)
def test_self_referential_serialized_pk_related_field(self):
class RegionSerializer(BaseModelSerializer):
class Meta:
model = Region
fields = ('id', 'parent', 'children')
# The field can only name its own serializer once the class exists.
RegionSerializer._declared_fields['children'] = SerializedPKRelatedField(
queryset=Region.objects.all(),
serializer=RegionSerializer,
many=True,
)
self.assertListEqual(
get_prefetches_for_serializer(RegionSerializer),
['parent', 'children'],
)
def test_self_referential_serialized_pk_related_field_with_brief_fields(self):
class RegionSerializer(BaseModelSerializer):
class Meta:
model = Region
fields = ('id', 'sites', 'children')
brief_fields = ('id', 'sites')
RegionSerializer._declared_fields['children'] = SerializedPKRelatedField(
queryset=Region.objects.all(),
serializer=RegionSerializer,
nested=True,
many=True,
)
# Re-entering the serializer at brief depth is not a cycle, so brief_fields must expand.
self.assertListEqual(
get_prefetches_for_serializer(RegionSerializer),
['sites', 'children', 'children__sites'],
)
def test_mutually_referential_serialized_pk_related_fields(self):
class RegionSerializer(BaseModelSerializer):
class Meta:
model = Region
fields = ('id', 'sites')
class SiteSerializer(BaseModelSerializer):
region = SerializedPKRelatedField(
queryset=Region.objects.all(),
serializer=RegionSerializer,
)
class Meta:
model = Site
fields = ('id', 'region')
RegionSerializer._declared_fields['sites'] = SerializedPKRelatedField(
queryset=Site.objects.all(),
serializer=SiteSerializer,
many=True,
)
self.assertListEqual(
get_prefetches_for_serializer(RegionSerializer),
['sites', 'sites__region'],
)
def test_serializer_class_reused_on_sibling_fields(self):
class TargetRegionSerializer(BaseModelSerializer):
class Meta:
model = Region
fields = ('id', 'sites')
class RegionSerializer(BaseModelSerializer):
parent = SerializedPKRelatedField(
queryset=Region.objects.all(),
serializer=TargetRegionSerializer,
)
children = SerializedPKRelatedField(
queryset=Region.objects.all(),
serializer=TargetRegionSerializer,
many=True,
)
class Meta:
model = Region
fields = ('id', 'parent', 'children')
self.assertListEqual(
get_prefetches_for_serializer(RegionSerializer),
['parent', 'parent__sites', 'children', 'children__sites'],
)
def test_reverse_many_to_many_relation_is_prefetched(self):
class ObjectPermissionSerializer(BaseModelSerializer):
class Meta:
model = ObjectPermission
fields = ('groups',)
self.assertListEqual(
get_prefetches_for_serializer(ObjectPermissionSerializer),
['groups'],
)
def test_reverse_many_to_many_serialized_related_field_is_prefetched(self):
class GroupSerializer(BaseModelSerializer):
class Meta:
model = Group
fields = ('id', 'name')
class ObjectPermissionSerializer(BaseModelSerializer):
groups = SerializedPKRelatedField(
queryset=Group.objects.all(),
serializer=GroupSerializer,
nested=True,
required=False,
many=True
)
class Meta:
model = ObjectPermission
fields = ('id', 'groups')
self.assertListEqual(
get_prefetches_for_serializer(ObjectPermissionSerializer),
['groups'],
)
class _ResolvedSerializerA(Serializer):
pass

View File

@ -132,6 +132,74 @@ class StaticWithParamsTestCase(TestCase):
self.assertIn('v=new_version', result)
self.assertNotIn('v=old_version', result)
@override_settings(STATIC_URL='https://s3.example.com/netbox/static/')
def test_static_with_params_sigv4_presigned_url(self):
"""Test that parameters are not appended to an AWS signature v4 presigned URL."""
signed_url = (
'https://s3.example.com/netbox/static/test.js'
'?X-Amz-Algorithm=AWS4-HMAC-SHA256'
'&X-Amz-Credential=ABC123%2F20260827%2Fus-east-1%2Fs3%2Faws4_request'
'&X-Amz-Date=20260827T141543Z'
'&X-Amz-Expires=3600'
'&X-Amz-SignedHeaders=host'
'&X-Amz-Signature=7a5af16a67d2bc7dc15b77fab733cafdc1344414d884fcf2e0b997b0b78dabca'
)
with patch('utilities.templatetags.builtins.tags.static') as mock_static:
mock_static.return_value = signed_url
result = static_with_params('test.js', v='1.0.0')
# The signed URL must be returned verbatim: appending a parameter would be included in
# the signature calculation performed by the storage backend, invalidating the signature.
self.assertEqual(result, signed_url)
self.assertNotIn('v=1.0.0', result)
@override_settings(STATIC_URL='https://s3.example.com/netbox/static/')
def test_static_with_params_sigv2_presigned_url(self):
"""Test that parameters are not appended to an AWS signature v2 presigned URL."""
signed_url = (
'https://s3.example.com/netbox/static/test.js'
'?AWSAccessKeyId=ABC123&Signature=hR9%2F5pRTOWo%3D&Expires=1748635659'
)
with patch('utilities.templatetags.builtins.tags.static') as mock_static:
mock_static.return_value = signed_url
result = static_with_params('test.js', v='1.0.0')
self.assertEqual(result, signed_url)
self.assertNotIn('v=1.0.0', result)
@override_settings(STATIC_URL='https://storage.example.com/netbox/static/')
def test_static_with_params_gcs_presigned_url(self):
"""Test that parameters are not appended to a Google Cloud Storage v4 signed URL."""
signed_url = (
'https://storage.example.com/netbox/static/test.js'
'?X-Goog-Algorithm=GOOG4-RSA-SHA256'
'&X-Goog-Credential=netbox%40example.iam.gserviceaccount.com%2F20260827%2Fauto%2Fstorage'
'%2Fgoog4_request'
'&X-Goog-Date=20260827T141543Z'
'&X-Goog-Expires=3600'
'&X-Goog-SignedHeaders=host'
'&X-Goog-Signature=4bd3a1f0'
)
with patch('utilities.templatetags.builtins.tags.static') as mock_static:
mock_static.return_value = signed_url
result = static_with_params('test.js', v='1.0.0')
self.assertEqual(result, signed_url)
self.assertNotIn('v=1.0.0', result)
@override_settings(STATIC_URL='https://s3.example.com/netbox/static/')
def test_static_with_params_unsigned_s3_url(self):
"""Test that parameters are appended to an unsigned (public bucket) S3 URL."""
with patch('utilities.templatetags.builtins.tags.static') as mock_static:
mock_static.return_value = 'https://s3.example.com/netbox/static/test.js'
result = static_with_params('test.js', v='1.0.0')
self.assertEqual(result, 'https://s3.example.com/netbox/static/test.js?v=1.0.0')
class BadgeTestCase(TestCase):
"""

View File

@ -26,20 +26,20 @@ Markdown==3.10.3
mkdocs==1.6.1
mkdocs-material==9.7.7
mkdocstrings==1.0.6
mkdocstrings-python==2.0.7
mkdocstrings-python==2.0.8
netaddr==1.3.0
nh3==0.3.7
Pillow==12.3.0
psycopg[c,pool]==3.3.4
psycopg[c,pool]==3.3.5
PyYAML==6.0.3
redis==7.4.1
requests==2.34.2
rq==2.11.0
rq==2.12.0
social-auth-app-django==6.0.1
social-auth-core==5.1.0
sorl-thumbnail==13.1.0
strawberry-graphql==0.324.0
strawberry-graphql-django==0.87.0
strawberry-graphql==0.327.0
strawberry-graphql-django==0.88.0
svgwrite==1.4.3
tablib==3.10.0
tzdata==2026.3