mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-10 22:02:17 -06:00
Compare commits
21 Commits
20068-impo
...
1598ca9108
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1598ca9108 | ||
|
|
21f4036782 | ||
|
|
ce3738572c | ||
|
|
cbb979934e | ||
|
|
642d83a4c6 | ||
|
|
a06c12c6b8 | ||
|
|
59afa0b41d | ||
|
|
14b246cb8a | ||
|
|
f0507d00bf | ||
|
|
77b389f105 | ||
|
|
970f2bd4ed | ||
|
|
a4ee323cb6 | ||
|
|
17e5184a11 | ||
|
|
e1548bb290 | ||
|
|
269112a565 | ||
|
|
c6672538ac | ||
|
|
9ae53fc232 | ||
|
|
6efb258b9f | ||
|
|
da1e0f4b53 | ||
|
|
7f39f75d3d | ||
|
|
c111c08315 |
@@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.4.7
|
||||
placeholder: v4.4.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@@ -27,7 +27,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.4.7
|
||||
placeholder: v4.4.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "NetBox REST API",
|
||||
"version": "4.4.7",
|
||||
"version": "4.4.8",
|
||||
"license": {
|
||||
"name": "Apache v2 License"
|
||||
}
|
||||
@@ -27326,6 +27326,58 @@
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "type",
|
||||
@@ -30798,6 +30850,58 @@
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "type",
|
||||
@@ -34158,6 +34262,58 @@
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "updated_by_request",
|
||||
@@ -46373,6 +46529,58 @@
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "type",
|
||||
@@ -52303,6 +52511,58 @@
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tx_power",
|
||||
@@ -58814,6 +59074,58 @@
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "updated_by_request",
|
||||
@@ -66953,6 +67265,58 @@
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "updated_by_request",
|
||||
@@ -78840,6 +79204,58 @@
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "type",
|
||||
@@ -83976,6 +84392,58 @@
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "type",
|
||||
@@ -96759,6 +97227,58 @@
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (slug)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "tenant_id__n",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"description": "Tenant (ID)",
|
||||
"explode": true,
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "type",
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# NetBox v4.4
|
||||
|
||||
## v4.4.8 (2025-12-09)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#20068](https://github.com/netbox-community/netbox/issues/20068) - Support the assignment of module type profile attributes via bulk import
|
||||
* [#20914](https://github.com/netbox-community/netbox/issues/20914) - Enable filtering device components by tenant assigned to device
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#19918](https://github.com/netbox-community/netbox/issues/19918) - Fix support for `{module}` resolution of components of child modules
|
||||
* [#20759](https://github.com/netbox-community/netbox/issues/20759) - Improve legibility of object types in permissions form
|
||||
* [#20860](https://github.com/netbox-community/netbox/issues/20860) - Ensure user-provided changelog message is recorded when creating device components via the UI
|
||||
* [#20878](https://github.com/netbox-community/netbox/issues/20878) - Use the active database connection when executing custom scripts
|
||||
* [#20888](https://github.com/netbox-community/netbox/issues/20888) - Resolve warnings about non-decimal values for min/max latitude & longitude fields
|
||||
|
||||
---
|
||||
|
||||
## v4.4.7 (2025-11-25)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -20,4 +20,4 @@ class ManufacturerSerializer(NetBoxModelSerializer):
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'devicetype_count', 'inventoryitem_count', 'platform_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
|
||||
|
||||
@@ -1626,6 +1626,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
choices=DeviceStatusChoices,
|
||||
field_name='device__status',
|
||||
)
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label=_('Tenant (ID)'),
|
||||
)
|
||||
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__tenant__slug',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label=_('Tenant (slug)'),
|
||||
)
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from circuits.models import Circuit
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
@@ -1414,19 +1415,52 @@ class MACAddressImportForm(NetBoxModelImportForm):
|
||||
#
|
||||
|
||||
class CableImportForm(NetBoxModelImportForm):
|
||||
"""
|
||||
CSV bulk import form for cables.
|
||||
|
||||
Supports dynamic parent model resolution - terminations are identified by their parent
|
||||
object (device, circuit, or power panel) and termination name.
|
||||
|
||||
The parent field resolves to different models based on the termination type
|
||||
See CABLE_PARENT_MAPPING for supported termination types.
|
||||
"""
|
||||
|
||||
# Map cable termination content types to their parent model and lookup field.
|
||||
#
|
||||
# This mapping enables dynamic parent model resolution during cable CSV imports.
|
||||
# Each entry maps a termination type to a tuple of (parent_content_type, accessor):
|
||||
#
|
||||
# Format: 'app.model': ('parent_app.ParentModel', 'accessor')
|
||||
#
|
||||
CABLE_PARENT_MAPPING = {
|
||||
'dcim.interface': ('dcim.Device', 'name'),
|
||||
'dcim.consoleport': ('dcim.Device', 'name'),
|
||||
'dcim.consoleserverport': ('dcim.Device', 'name'),
|
||||
'dcim.powerport': ('dcim.Device', 'name'),
|
||||
'dcim.poweroutlet': ('dcim.Device', 'name'),
|
||||
'dcim.frontport': ('dcim.Device', 'name'),
|
||||
'dcim.rearport': ('dcim.Device', 'name'),
|
||||
'circuits.circuittermination': ('circuits.Circuit', 'cid'),
|
||||
'dcim.powerfeed': ('dcim.PowerPanel', 'name'),
|
||||
}
|
||||
# Map parent model name to (parent_field_name, termination_name_field, value_transform)
|
||||
TERMINATION_FIELDS = {
|
||||
'Circuit': ('circuit', 'term_side', str.upper),
|
||||
'Device': ('device', 'name', None),
|
||||
'PowerPanel': ('power_panel', 'name', None),
|
||||
}
|
||||
|
||||
# Termination A
|
||||
side_a_site = CSVModelChoiceField(
|
||||
label=_('Side A site'),
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Site of parent device A (if any)'),
|
||||
help_text=_('Site of parent A (if any)')
|
||||
)
|
||||
side_a_device = CSVModelChoiceField(
|
||||
label=_('Side A device'),
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text=_('Device name')
|
||||
side_a_parent = forms.CharField(
|
||||
label=_('Side A parent'),
|
||||
help_text=_('Device name, Circuit CID, or Power Panel name')
|
||||
)
|
||||
side_a_type = CSVContentTypeField(
|
||||
label=_('Side A type'),
|
||||
@@ -1445,13 +1479,11 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Site of parent device B (if any)'),
|
||||
help_text=_('Site of parent B (if any)')
|
||||
)
|
||||
side_b_device = CSVModelChoiceField(
|
||||
label=_('Side B device'),
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text=_('Device name')
|
||||
side_b_parent = forms.CharField(
|
||||
label=_('Side B parent'),
|
||||
help_text=_('Device name, Circuit CID, or Power Panel name')
|
||||
)
|
||||
side_b_type = CSVContentTypeField(
|
||||
label=_('Side B type'),
|
||||
@@ -1500,7 +1532,7 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type',
|
||||
'side_a_site', 'side_a_parent', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_parent', 'side_b_type',
|
||||
'side_b_name', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
|
||||
'comments', 'tags',
|
||||
]
|
||||
@@ -1508,21 +1540,6 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
if data:
|
||||
# Limit choices for side_a_device to the assigned side_a_site
|
||||
if side_a_site := data.get('side_a_site'):
|
||||
side_a_device_params = {f'site__{self.fields["side_a_site"].to_field_name}': side_a_site}
|
||||
self.fields['side_a_device'].queryset = self.fields['side_a_device'].queryset.filter(
|
||||
**side_a_device_params
|
||||
)
|
||||
|
||||
# Limit choices for side_b_device to the assigned side_b_site
|
||||
if side_b_site := data.get('side_b_site'):
|
||||
side_b_device_params = {f'site__{self.fields["side_b_site"].to_field_name}': side_b_site}
|
||||
self.fields['side_b_device'].queryset = self.fields['side_b_device'].queryset.filter(
|
||||
**side_b_device_params
|
||||
)
|
||||
|
||||
def _clean_side(self, side):
|
||||
"""
|
||||
Derive a Cable's A/B termination objects.
|
||||
@@ -1531,31 +1548,118 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
"""
|
||||
assert side in 'ab', f"Invalid side designation: {side}"
|
||||
|
||||
device = self.cleaned_data.get(f'side_{side}_device')
|
||||
content_type = self.cleaned_data.get(f'side_{side}_type')
|
||||
site = self.cleaned_data.get(f'side_{side}_site')
|
||||
parent_value = self.cleaned_data.get(f'side_{side}_parent')
|
||||
name = self.cleaned_data.get(f'side_{side}_name')
|
||||
if not device or not content_type or not name:
|
||||
|
||||
if not parent_value or not content_type or not name: # pragma: no cover
|
||||
return None
|
||||
|
||||
model = content_type.model_class()
|
||||
# Get the parent model mapping from the submitted content_type
|
||||
parent_map = self.CABLE_PARENT_MAPPING.get(f'{content_type.app_label}.{content_type.model}')
|
||||
# This should never happen
|
||||
assert parent_map, (
|
||||
'Unknown cable termination content type parent mapping: '
|
||||
f'{content_type.app_label}.{content_type.model}'
|
||||
)
|
||||
|
||||
parent_content_type, parent_accessor = parent_map
|
||||
parent_app_label, parent_model_name = parent_content_type.split('.')
|
||||
|
||||
# Get the parent model class
|
||||
try:
|
||||
if device.virtual_chassis and device.virtual_chassis.master == device and \
|
||||
model.objects.filter(device=device, name=name).count() == 0:
|
||||
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
|
||||
else:
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
if termination_object.cable is not None and termination_object.cable != self.instance:
|
||||
raise forms.ValidationError(
|
||||
_("Side {side_upper}: {device} {termination_object} is already connected").format(
|
||||
side_upper=side.upper(), device=device, termination_object=termination_object
|
||||
)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
parent_ct = ContentType.objects.get(app_label=parent_app_label.lower(), model=parent_model_name.lower())
|
||||
parent_model: Device | PowerPanel | Circuit = parent_ct.model_class()
|
||||
except ContentType.DoesNotExist: # pragma: no cover
|
||||
# This should never happen
|
||||
raise AssertionError(f'Unknown cable termination parent content type: {parent_content_type}')
|
||||
|
||||
# Build query for parent lookup
|
||||
parent_query = {parent_accessor: parent_value}
|
||||
# Add site to query if provided
|
||||
if site:
|
||||
parent_query['site'] = site
|
||||
|
||||
# Look up the parent object
|
||||
try:
|
||||
parent_object = parent_model.objects.get(**parent_query)
|
||||
except parent_model.DoesNotExist:
|
||||
raise forms.ValidationError(
|
||||
_("{side_upper} side termination not found: {device} {name}").format(
|
||||
side_upper=side.upper(), device=device, name=name
|
||||
_('Side {side_upper}: {model_name} not found: {value}').format(
|
||||
side_upper=side.upper(), model_name=parent_model.__name__, value=parent_value
|
||||
)
|
||||
)
|
||||
except parent_model.MultipleObjectsReturned:
|
||||
raise forms.ValidationError(
|
||||
_('Side {side_upper}: Multiple {model_name} objects found: {value}').format(
|
||||
side_upper=side.upper(), model_name=parent_model.__name__, value=parent_value
|
||||
)
|
||||
)
|
||||
|
||||
# Get the termination model class
|
||||
termination_model = content_type.model_class()
|
||||
|
||||
# Build the query to find the termination object
|
||||
field_mapping = self.TERMINATION_FIELDS.get(parent_model.__name__)
|
||||
if not field_mapping: # pragma: no cover
|
||||
return None
|
||||
|
||||
parent_field, name_field, value_transform = field_mapping
|
||||
query = {parent_field: parent_object}
|
||||
|
||||
if value_transform:
|
||||
name = value_transform(name)
|
||||
|
||||
if name:
|
||||
query[name_field] = name
|
||||
|
||||
# Add site to query if provided (for site-scoped parents)
|
||||
if site and parent_field in ('device', 'power_panel'):
|
||||
query[f'{parent_field}__site'] = site
|
||||
|
||||
# Look up the termination object
|
||||
try:
|
||||
# Handle virtual chassis for device-based terminations
|
||||
if (parent_field == 'device' and
|
||||
parent_object.virtual_chassis and
|
||||
parent_object.virtual_chassis.master == parent_object and
|
||||
termination_model.objects.filter(**query).count() == 0):
|
||||
query[f'{parent_field}__in'] = parent_object.virtual_chassis.members.all()
|
||||
query.pop(parent_field, None)
|
||||
termination_object = termination_model.objects.get(**query)
|
||||
else:
|
||||
termination_object = termination_model.objects.get(**query)
|
||||
|
||||
# Check if already connected to a cable
|
||||
if termination_object.cable is not None and termination_object.cable != self.instance:
|
||||
raise forms.ValidationError(
|
||||
_('Side {side_upper}: {parent} {termination} is already connected').format(
|
||||
side_upper=side.upper(), parent=parent_object, termination=termination_object
|
||||
)
|
||||
)
|
||||
|
||||
# Circuit terminations can also be connected to provider networks
|
||||
if (name_field == 'term_side' and
|
||||
hasattr(termination_object, '_provider_network') and
|
||||
termination_object._provider_network is not None):
|
||||
raise forms.ValidationError(
|
||||
_('Side {side_upper}: {parent} {termination} is already connected to a provider network').format(
|
||||
side_upper=side.upper(), parent=parent_object, termination=termination_object
|
||||
)
|
||||
)
|
||||
except termination_model.DoesNotExist:
|
||||
raise forms.ValidationError(
|
||||
_('Side {side_upper}: {model_name} not found: {parent} {name}').format(
|
||||
side_upper=side.upper(),
|
||||
model_name=termination_model.__name__,
|
||||
parent=parent_object, name=name or '',
|
||||
),
|
||||
)
|
||||
except termination_model.MultipleObjectsReturned: # pragma: no cover
|
||||
# This should never happen
|
||||
raise AssertionError('Multiple termination objects returned for query: {query}'.format(query=query))
|
||||
|
||||
setattr(self.instance, f'{side}_terminations', [termination_object])
|
||||
return termination_object
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from ipam.models import ASN, VRF, VLANTranslationPolicy
|
||||
from netbox.choices import *
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
from tenancy.models import Tenant
|
||||
from users.models import User
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
||||
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
@@ -120,6 +121,11 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Device role')
|
||||
)
|
||||
tenant_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False,
|
||||
label=_('Tenant')
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
@@ -128,7 +134,8 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
'location_id': '$location_id',
|
||||
'virtual_chassis_id': '$virtual_chassis_id',
|
||||
'device_type_id': '$device_type_id',
|
||||
'role_id': '$role_id'
|
||||
'role_id': '$role_id',
|
||||
'tenant_id': '$tenant_id'
|
||||
},
|
||||
label=_('Device')
|
||||
)
|
||||
@@ -1317,7 +1324,8 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
)
|
||||
@@ -1341,7 +1349,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
|
||||
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
@@ -1366,7 +1374,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
)
|
||||
@@ -1385,7 +1394,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
@@ -1418,7 +1427,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'vdc_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'vdc_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
@@ -1539,7 +1549,8 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'occupied', name=_('Cable')),
|
||||
)
|
||||
@@ -1563,7 +1574,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'occupied', name=_('Cable')),
|
||||
@@ -1587,7 +1598,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
FieldSet('name', 'label', 'position', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
)
|
||||
@@ -1605,7 +1616,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
FieldSet('name', 'label', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
)
|
||||
@@ -1622,7 +1633,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
)
|
||||
|
||||
@@ -681,8 +681,8 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.name,
|
||||
label=self.label,
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
label=self.resolve_label(kwargs.get('module')),
|
||||
position=self.position,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
from jsonschema.exceptions import ValidationError as JSONValidationError
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import MODULE_TOKEN
|
||||
from dcim.utils import update_interface_bridges
|
||||
from extras.models import ConfigContextModel, CustomField
|
||||
from netbox.models import PrimaryModel
|
||||
@@ -331,7 +330,6 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
else:
|
||||
# ModuleBays must be saved individually for MPTT
|
||||
for instance in create_instances:
|
||||
instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position))
|
||||
instance.save()
|
||||
|
||||
update_fields = ['module']
|
||||
|
||||
@@ -531,7 +531,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class ManufacturerTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Manufacturer
|
||||
brief_fields = ['description', 'devicetype_count', 'display', 'id', 'name', 'slug', 'url']
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Manufacturer 4',
|
||||
|
||||
@@ -43,6 +43,13 @@ class DeviceComponentFilterSetTests:
|
||||
params = {'device_status': ['active', 'planned']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tenant(self):
|
||||
tenants = Tenant.objects.all()[:2]
|
||||
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class DeviceComponentTemplateFilterSetTests:
|
||||
|
||||
@@ -3377,9 +3384,17 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -3389,6 +3404,7 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -3398,6 +3414,7 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -3617,9 +3634,17 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -3629,6 +3654,7 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -3638,6 +3664,7 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -3857,9 +3884,17 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -3869,6 +3904,7 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -3878,6 +3914,7 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -4111,9 +4148,17 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -4123,6 +4168,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -4132,6 +4178,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -4390,9 +4437,17 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
virtual_chassis = VirtualChassis(name='Virtual Chassis')
|
||||
virtual_chassis.save()
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1A',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -4405,6 +4460,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 1B',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -4417,6 +4473,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -4426,6 +4483,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -5011,9 +5069,17 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -5023,6 +5089,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -5032,6 +5099,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -5302,9 +5370,17 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -5314,6 +5390,7 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -5323,6 +5400,7 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -5579,9 +5657,17 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -5591,6 +5677,7 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -5600,6 +5687,7 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
@@ -5752,9 +5840,17 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
devices = (
|
||||
Device(
|
||||
name='Device 1',
|
||||
tenant=tenants[0],
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
@@ -5764,6 +5860,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
tenant=tenants[1],
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
@@ -5773,6 +5870,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
tenant=tenants[2],
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, ProviderNetwork
|
||||
from dcim.choices import (
|
||||
DeviceFaceChoices, DeviceStatusChoices, InterfaceModeChoices, InterfaceTypeChoices, PortTypeChoices,
|
||||
PowerOutletStatusChoices,
|
||||
CableTypeChoices, DeviceFaceChoices, DeviceStatusChoices, InterfaceModeChoices, InterfaceTypeChoices,
|
||||
PortTypeChoices, PowerOutletStatusChoices,
|
||||
)
|
||||
from dcim.forms import *
|
||||
from dcim.models import *
|
||||
@@ -411,3 +412,204 @@ class InterfaceTestCase(TestCase):
|
||||
self.assertNotIn('untagged_vlan', form.cleaned_data.keys())
|
||||
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
|
||||
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
|
||||
|
||||
|
||||
class CableImportFormTestCase(TestCase):
|
||||
"""
|
||||
Test cases for CableImportForm error handling and edge cases.
|
||||
|
||||
Note: Happy path scenarios (successful cable creation) are covered by
|
||||
dcim.tests.test_views.CableTestCase which tests the bulk import view.
|
||||
These tests focus on validation errors and edge cases not covered by the view tests.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
# Create sites
|
||||
cls.site_a = Site.objects.create(name='Site A', slug='site-a')
|
||||
cls.site_b = Site.objects.create(name='Site B', slug='site-b')
|
||||
|
||||
# Create manufacturer and device type
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Device Type 1',
|
||||
slug='device-type-1',
|
||||
)
|
||||
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
|
||||
|
||||
# Create devices
|
||||
cls.device_a1 = Device.objects.create(
|
||||
name='Device-A1',
|
||||
device_type=device_type,
|
||||
role=role,
|
||||
site=cls.site_a,
|
||||
)
|
||||
cls.device_a2 = Device.objects.create(
|
||||
name='Device-A2',
|
||||
device_type=device_type,
|
||||
role=role,
|
||||
site=cls.site_a,
|
||||
)
|
||||
# Device with same name in different site
|
||||
cls.device_b_duplicate = Device.objects.create(
|
||||
name='Device-A1', # Same name as device_a1
|
||||
device_type=device_type,
|
||||
role=role,
|
||||
site=cls.site_b,
|
||||
)
|
||||
|
||||
# Create interfaces
|
||||
cls.interface_a1_eth0 = Interface.objects.create(
|
||||
device=cls.device_a1,
|
||||
name='eth0',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||
)
|
||||
cls.interface_a2_eth0 = Interface.objects.create(
|
||||
device=cls.device_a2,
|
||||
name='eth0',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||
)
|
||||
|
||||
# Create circuit for testing circuit not found error
|
||||
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
|
||||
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
|
||||
cls.circuit = Circuit.objects.create(
|
||||
provider=provider,
|
||||
type=circuit_type,
|
||||
cid='CIRCUIT-001',
|
||||
)
|
||||
cls.circuit_term_a = CircuitTermination.objects.create(
|
||||
circuit=cls.circuit,
|
||||
term_side='A',
|
||||
)
|
||||
|
||||
# Create provider network for testing provider network validation
|
||||
cls.provider_network = ProviderNetwork.objects.create(
|
||||
provider=provider,
|
||||
name='Provider Network 1',
|
||||
)
|
||||
|
||||
def test_device_not_found(self):
|
||||
"""Test error when parent device is not found."""
|
||||
form = CableImportForm(data={
|
||||
'side_a_site': 'Site A',
|
||||
'side_a_parent': 'NonexistentDevice',
|
||||
'side_a_type': 'dcim.interface',
|
||||
'side_a_name': 'eth0',
|
||||
'side_b_site': 'Site A',
|
||||
'side_b_parent': 'Device-A2',
|
||||
'side_b_type': 'dcim.interface',
|
||||
'side_b_name': 'eth0',
|
||||
'type': CableTypeChoices.TYPE_CAT6,
|
||||
'status': 'connected',
|
||||
})
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('Side A: Device not found: NonexistentDevice', str(form.errors))
|
||||
|
||||
def test_circuit_not_found(self):
|
||||
"""Test error when circuit is not found."""
|
||||
form = CableImportForm(data={
|
||||
'side_a_site': None,
|
||||
'side_a_parent': 'NONEXISTENT-CID',
|
||||
'side_a_type': 'circuits.circuittermination',
|
||||
'side_a_name': 'A',
|
||||
'side_b_site': 'Site A',
|
||||
'side_b_parent': 'Device-A1',
|
||||
'side_b_type': 'dcim.interface',
|
||||
'side_b_name': 'eth0',
|
||||
'type': CableTypeChoices.TYPE_MMF_OM4,
|
||||
'status': 'connected',
|
||||
})
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('Side A: Circuit not found: NONEXISTENT-CID', str(form.errors))
|
||||
|
||||
def test_termination_not_found(self):
|
||||
"""Test error when termination is not found on parent."""
|
||||
form = CableImportForm(data={
|
||||
'side_a_site': 'Site A',
|
||||
'side_a_parent': 'Device-A1',
|
||||
'side_a_type': 'dcim.interface',
|
||||
'side_a_name': 'eth999', # Nonexistent interface
|
||||
'side_b_site': 'Site A',
|
||||
'side_b_parent': 'Device-A2',
|
||||
'side_b_type': 'dcim.interface',
|
||||
'side_b_name': 'eth0',
|
||||
'type': CableTypeChoices.TYPE_CAT6,
|
||||
'status': 'connected',
|
||||
})
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('Side A: Interface not found', str(form.errors))
|
||||
|
||||
def test_termination_already_cabled(self):
|
||||
"""Test error when termination is already connected to a cable."""
|
||||
# Create an existing cable
|
||||
existing_cable = Cable.objects.create(type=CableTypeChoices.TYPE_CAT6, status='connected')
|
||||
self.interface_a1_eth0.cable = existing_cable
|
||||
self.interface_a1_eth0.save()
|
||||
|
||||
form = CableImportForm(data={
|
||||
'side_a_site': 'Site A',
|
||||
'side_a_parent': 'Device-A1',
|
||||
'side_a_type': 'dcim.interface',
|
||||
'side_a_name': 'eth0',
|
||||
'side_b_site': 'Site A',
|
||||
'side_b_parent': 'Device-A2',
|
||||
'side_b_type': 'dcim.interface',
|
||||
'side_b_name': 'eth0',
|
||||
'type': CableTypeChoices.TYPE_CAT6,
|
||||
'status': 'connected',
|
||||
})
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('already connected', str(form.errors))
|
||||
|
||||
def test_circuit_termination_with_provider_network(self):
|
||||
"""Test error when circuit termination is already connected to a provider network."""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
# Connect circuit termination to provider network
|
||||
circuit_term = CircuitTermination.objects.get(pk=self.circuit_term_a.pk)
|
||||
pn_ct = ContentType.objects.get_for_model(ProviderNetwork)
|
||||
circuit_term.termination_type = pn_ct
|
||||
circuit_term.termination_id = self.provider_network.pk
|
||||
circuit_term.save()
|
||||
|
||||
try:
|
||||
form = CableImportForm(data={
|
||||
'side_a_site': None,
|
||||
'side_a_parent': 'CIRCUIT-001',
|
||||
'side_a_type': 'circuits.circuittermination',
|
||||
'side_a_name': 'A',
|
||||
'side_b_site': 'Site A',
|
||||
'side_b_parent': 'Device-A1',
|
||||
'side_b_type': 'dcim.interface',
|
||||
'side_b_name': 'eth0',
|
||||
'type': CableTypeChoices.TYPE_MMF_OM4,
|
||||
'status': 'connected',
|
||||
})
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('already connected to a provider network', str(form.errors))
|
||||
finally:
|
||||
# Clean up: remove provider network connection
|
||||
circuit_term.termination_type = None
|
||||
circuit_term.termination_id = None
|
||||
circuit_term.save()
|
||||
|
||||
def test_multiple_parents_without_site(self):
|
||||
"""Test error when multiple parent objects are found without site scoping."""
|
||||
# Device-A1 exists in both site_a and site_b
|
||||
# Try to find device without specifying site
|
||||
form = CableImportForm(data={
|
||||
'side_a_site': '', # Empty site - should cause multiple matches
|
||||
'side_a_parent': 'Device-A1',
|
||||
'side_a_type': 'dcim.interface',
|
||||
'side_a_name': 'eth0',
|
||||
'side_b_site': 'Site A',
|
||||
'side_b_parent': 'Device-A2',
|
||||
'side_b_type': 'dcim.interface',
|
||||
'side_b_name': 'eth0',
|
||||
'type': CableTypeChoices.TYPE_CAT6,
|
||||
'status': 'connected',
|
||||
})
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('Multiple Device objects found', str(form.errors))
|
||||
|
||||
@@ -792,8 +792,54 @@ class ModuleBayTestCase(TestCase):
|
||||
)
|
||||
device.consoleports.first()
|
||||
|
||||
def test_nested_module_token(self):
|
||||
pass
|
||||
@tag('regression') # #19918
|
||||
def test_nested_module_bay_label_resolution(self):
|
||||
"""Test that nested module bay labels properly resolve {module} placeholders"""
|
||||
manufacturer = Manufacturer.objects.first()
|
||||
site = Site.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
# Create device type with module bay template (position='A')
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Device with Bays',
|
||||
slug='device-with-bays'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
device_type=device_type,
|
||||
name='Bay A',
|
||||
position='A'
|
||||
)
|
||||
|
||||
# Create module type with nested bay template using {module} placeholder
|
||||
module_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Module with Nested Bays'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
module_type=module_type,
|
||||
name='SFP {module}-21',
|
||||
label='{module}-21',
|
||||
position='21'
|
||||
)
|
||||
|
||||
# Create device and install module
|
||||
device = Device.objects.create(
|
||||
name='Test Device',
|
||||
device_type=device_type,
|
||||
role=device_role,
|
||||
site=site
|
||||
)
|
||||
module_bay = device.modulebays.get(name='Bay A')
|
||||
module = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=module_bay,
|
||||
module_type=module_type
|
||||
)
|
||||
|
||||
# Verify nested bay label resolves {module} to parent position
|
||||
nested_bay = module.modulebays.get(name='SFP A-21')
|
||||
self.assertEqual(nested_bay.label, 'A-21')
|
||||
|
||||
|
||||
class CableTestCase(TestCase):
|
||||
|
||||
@@ -11,6 +11,7 @@ from core.models import ObjectType
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
from ipam.models import ASN, RIR, VLAN, VRF
|
||||
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, WeightUnitChoices
|
||||
from tenancy.models import Tenant
|
||||
@@ -3495,7 +3496,7 @@ class CableTestCase(
|
||||
Interface(device=devices[4], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[4], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
|
||||
# Device 1, Site 2
|
||||
# Device 5, Site 2
|
||||
Interface(device=devices[5], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[5], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[5], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
@@ -3507,6 +3508,22 @@ class CableTestCase(
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
ConsolePort.objects.create(device=devices[0], name='Console 1')
|
||||
ConsoleServerPort.objects.create(device=devices[1], name='Console Server 1')
|
||||
|
||||
power_panel = PowerPanel.objects.create(site=sites[0], name='Power Panel 1')
|
||||
PowerFeed.objects.create(power_panel=power_panel, name='Feed 1')
|
||||
PowerPort.objects.create(device=devices[0], name='PSU1')
|
||||
|
||||
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
|
||||
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
|
||||
circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='CIRCUIT-001')
|
||||
circuit_terminations = (
|
||||
CircuitTermination(circuit=circuit, term_side='A'),
|
||||
CircuitTermination(circuit=circuit, term_side='Z'),
|
||||
)
|
||||
CircuitTermination.objects.bulk_create(circuit_terminations)
|
||||
|
||||
cable1 = Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]], type=CableTypeChoices.TYPE_CAT6)
|
||||
cable1.save()
|
||||
cable2 = Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]], type=CableTypeChoices.TYPE_CAT6)
|
||||
@@ -3532,7 +3549,7 @@ class CableTestCase(
|
||||
|
||||
cls.csv_data = {
|
||||
'default': (
|
||||
"side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name",
|
||||
"side_a_parent,side_a_type,side_a_name,side_b_parent,side_b_type,side_b_name",
|
||||
"Device 4,dcim.interface,Interface 1,Device 5,dcim.interface,Interface 1",
|
||||
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
|
||||
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
|
||||
@@ -3545,12 +3562,28 @@ class CableTestCase(
|
||||
'site-filtering': (
|
||||
# Ensure that CSV bulk import supports assigning terminations from parent devices
|
||||
# that share the same device name, provided those devices belong to different sites.
|
||||
"side_a_site,side_a_device,side_a_type,side_a_name,side_b_site,side_b_device,side_b_type,side_b_name",
|
||||
"side_a_site,side_a_parent,side_a_type,side_a_name,side_b_site,side_b_parent,side_b_type,side_b_name",
|
||||
"Site 1,Device 3,dcim.interface,Interface 1,Site 2,Device 1,dcim.interface,Interface 1",
|
||||
"Site 1,Device 3,dcim.interface,Interface 2,Site 2,Device 1,dcim.interface,Interface 2",
|
||||
"Site 1,Device 3,dcim.interface,Interface 3,Site 2,Device 1,dcim.interface,Interface 3",
|
||||
"Site 1,Device 1,dcim.interface,Device 2 Interface,Site 2,Device 1,dcim.interface,Interface 4",
|
||||
"Site 1,Device 1,dcim.interface,Device 3 Interface,Site 2,Device 1,dcim.interface,Interface 5",
|
||||
),
|
||||
'circuits': (
|
||||
# Test circuit termination to interface cables
|
||||
"side_a_parent,side_a_type,side_a_name,side_b_site,side_b_parent,side_b_type,side_b_name",
|
||||
"CIRCUIT-001,circuits.circuittermination,A,Site 1,Device 4,dcim.interface,Interface 2",
|
||||
"CIRCUIT-001,circuits.circuittermination,z,Site 2,Device 5,dcim.interface,Interface 2",
|
||||
),
|
||||
'power': (
|
||||
# Test power feed to power port cables
|
||||
"side_a_site,side_a_parent,side_a_type,side_a_name,side_b_site,side_b_parent,side_b_type,side_b_name",
|
||||
"Site 1,Power Panel 1,dcim.powerfeed,Feed 1,Site 1,Device 1,dcim.powerport,PSU1",
|
||||
),
|
||||
'console': (
|
||||
# Test console port to console server port cables
|
||||
"side_a_site,side_a_parent,side_a_type,side_a_name,side_b_site,side_b_parent,side_b_type,side_b_name",
|
||||
"Site 1,Device 1,dcim.consoleport,Console 1,Site 1,Device 2,dcim.consoleserverport,Console Server 1",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,9 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
|
||||
if snapshots:
|
||||
params["snapshots"] = snapshots
|
||||
if request:
|
||||
params["request"] = copy_safe_request(request)
|
||||
# Exclude FILES - webhooks don't need uploaded files,
|
||||
# which can cause pickle errors with Pillow.
|
||||
params["request"] = copy_safe_request(request, include_files=False)
|
||||
|
||||
# Enqueue the task
|
||||
rq_queue.enqueue(
|
||||
|
||||
@@ -2,11 +2,14 @@ import logging
|
||||
import traceback
|
||||
from contextlib import ExitStack
|
||||
|
||||
from django.db import transaction
|
||||
from django.db import router, transaction
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.signals import clear_events
|
||||
from dcim.models import Device
|
||||
from extras.models import Script as ScriptModel
|
||||
from netbox.context_managers import event_tracking
|
||||
from netbox.jobs import JobRunner
|
||||
from netbox.registry import registry
|
||||
from utilities.exceptions import AbortScript, AbortTransaction
|
||||
@@ -42,10 +45,21 @@ class ScriptJob(JobRunner):
|
||||
# A script can modify multiple models so need to do an atomic lock on
|
||||
# both the default database (for non ChangeLogged models) and potentially
|
||||
# any other database (for ChangeLogged models)
|
||||
with transaction.atomic():
|
||||
script.output = script.run(data, commit)
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
changeloged_db = router.db_for_write(Device)
|
||||
with transaction.atomic(using=DEFAULT_DB_ALIAS):
|
||||
# If branch database is different from default, wrap in a second atomic transaction
|
||||
# Note: Don't add any extra code between the two atomic transactions,
|
||||
# otherwise the changes might get committed to the default database
|
||||
# if there are any raised exceptions.
|
||||
if changeloged_db != DEFAULT_DB_ALIAS:
|
||||
with transaction.atomic(using=changeloged_db):
|
||||
script.output = script.run(data, commit)
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
else:
|
||||
script.output = script.run(data, commit)
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
except AbortTransaction:
|
||||
script.log_info(message=_("Database changes have been reverted automatically."))
|
||||
if script.failed:
|
||||
@@ -108,14 +122,14 @@ class ScriptJob(JobRunner):
|
||||
script.request = request
|
||||
self.logger.debug(f"Request ID: {request.id if request else None}")
|
||||
|
||||
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
|
||||
# change logging, event rules, etc.
|
||||
if commit:
|
||||
self.logger.info("Executing script (commit enabled)")
|
||||
with ExitStack() as stack:
|
||||
for request_processor in registry['request_processors']:
|
||||
stack.enter_context(request_processor(request))
|
||||
self.run_script(script, request, data, commit)
|
||||
else:
|
||||
self.logger.warning("Executing script (commit disabled)")
|
||||
|
||||
with ExitStack() as stack:
|
||||
for request_processor in registry['request_processors']:
|
||||
if not commit and request_processor is event_tracking:
|
||||
continue
|
||||
stack.enter_context(request_processor(request))
|
||||
self.run_script(script, request, data, commit)
|
||||
|
||||
@@ -230,10 +230,6 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
|
||||
query |= Q(**{
|
||||
f"site__{self.fields['vlan_site'].to_field_name}": vlan_site
|
||||
})
|
||||
# Don't Forget to include VLANs without a site in the filter
|
||||
query |= Q(**{
|
||||
f"site__{self.fields['vlan_site'].to_field_name}__isnull": True
|
||||
})
|
||||
|
||||
if vlan_group:
|
||||
query &= Q(**{
|
||||
|
||||
@@ -564,6 +564,82 @@ vlan: 102
|
||||
self.assertEqual(prefix.vlan.vid, 102)
|
||||
self.assertEqual(prefix.scope, site)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_import_with_vlan_site_multiple_vlans_same_vid(self):
|
||||
"""
|
||||
Test import when multiple VLANs exist with the same vid but different sites.
|
||||
Ref: #20560
|
||||
"""
|
||||
site1 = Site.objects.get(name='Site 1')
|
||||
site2 = Site.objects.get(name='Site 2')
|
||||
|
||||
# Create VLANs with the same vid but different sites
|
||||
vlan1 = VLAN.objects.create(vid=1, name='VLAN1-Site1', site=site1)
|
||||
VLAN.objects.create(vid=1, name='VLAN1-Site2', site=site2) # Create ambiguity
|
||||
|
||||
# Import prefix with vlan_site specified
|
||||
IMPORT_DATA = f"""
|
||||
prefix: 10.11.0.0/22
|
||||
status: active
|
||||
scope_type: dcim.site
|
||||
scope_id: {site1.pk}
|
||||
vlan_site: {site1.name}
|
||||
vlan: 1
|
||||
description: LOC02-MGMT
|
||||
"""
|
||||
|
||||
# Add all required permissions to the test user
|
||||
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
|
||||
|
||||
form_data = {
|
||||
'data': IMPORT_DATA,
|
||||
'format': 'yaml'
|
||||
}
|
||||
response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True)
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
# Verify the prefix was created with the correct VLAN
|
||||
prefix = Prefix.objects.get(prefix='10.11.0.0/22')
|
||||
self.assertEqual(prefix.vlan, vlan1)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_import_with_vlan_site_and_global_vlan(self):
|
||||
"""
|
||||
Test import when a global VLAN (no site) and site-specific VLAN exist with same vid.
|
||||
When vlan_site is specified, should prefer the site-specific VLAN.
|
||||
Ref: #20560
|
||||
"""
|
||||
site1 = Site.objects.get(name='Site 1')
|
||||
|
||||
# Create a global VLAN (no site) and a site-specific VLAN with the same vid
|
||||
VLAN.objects.create(vid=10, name='VLAN10-Global', site=None) # Create ambiguity
|
||||
vlan_site = VLAN.objects.create(vid=10, name='VLAN10-Site1', site=site1)
|
||||
|
||||
# Import prefix with vlan_site specified
|
||||
IMPORT_DATA = f"""
|
||||
prefix: 10.12.0.0/22
|
||||
status: active
|
||||
scope_type: dcim.site
|
||||
scope_id: {site1.pk}
|
||||
vlan_site: {site1.name}
|
||||
vlan: 10
|
||||
description: Test Site-Specific VLAN
|
||||
"""
|
||||
|
||||
# Add all required permissions to the test user
|
||||
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
|
||||
|
||||
form_data = {
|
||||
'data': IMPORT_DATA,
|
||||
'format': 'yaml'
|
||||
}
|
||||
response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True)
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
# Verify the prefix was created with the site-specific VLAN (not the global one)
|
||||
prefix = Prefix.objects.get(prefix='10.12.0.0/22')
|
||||
self.assertEqual(prefix.vlan, vlan_site)
|
||||
|
||||
|
||||
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = IPRange
|
||||
|
||||
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
8
netbox/project-static/dist/netbox.js
vendored
8
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
6
netbox/project-static/dist/netbox.js.map
vendored
6
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -30,7 +30,7 @@
|
||||
"gridstack": "12.3.3",
|
||||
"htmx.org": "2.0.8",
|
||||
"query-string": "9.3.1",
|
||||
"sass": "1.94.2",
|
||||
"sass": "1.95.0",
|
||||
"tom-select": "2.4.3",
|
||||
"typeface-inter": "3.18.1",
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getElements } from '../util';
|
||||
|
||||
/**
|
||||
* Move selected options from one select element to another.
|
||||
* Move selected options from one select element to another, preserving optgroup structure.
|
||||
*
|
||||
* @param source Select Element
|
||||
* @param target Select Element
|
||||
@@ -9,14 +9,42 @@ import { getElements } from '../util';
|
||||
function moveOption(source: HTMLSelectElement, target: HTMLSelectElement): void {
|
||||
for (const option of Array.from(source.options)) {
|
||||
if (option.selected) {
|
||||
target.appendChild(option.cloneNode(true));
|
||||
// Check if option is inside an optgroup
|
||||
const parentOptgroup = option.parentElement as HTMLElement;
|
||||
|
||||
if (parentOptgroup.tagName === 'OPTGROUP') {
|
||||
// Find or create matching optgroup in target
|
||||
const groupLabel = parentOptgroup.getAttribute('label');
|
||||
let targetOptgroup = Array.from(target.children).find(
|
||||
child => child.tagName === 'OPTGROUP' && child.getAttribute('label') === groupLabel,
|
||||
) as HTMLOptGroupElement;
|
||||
|
||||
if (!targetOptgroup) {
|
||||
// Create new optgroup in target
|
||||
targetOptgroup = document.createElement('optgroup');
|
||||
targetOptgroup.setAttribute('label', groupLabel!);
|
||||
target.appendChild(targetOptgroup);
|
||||
}
|
||||
|
||||
// Move option to target optgroup
|
||||
targetOptgroup.appendChild(option.cloneNode(true));
|
||||
} else {
|
||||
// Option is not in an optgroup, append directly
|
||||
target.appendChild(option.cloneNode(true));
|
||||
}
|
||||
|
||||
option.remove();
|
||||
|
||||
// Clean up empty optgroups in source
|
||||
if (parentOptgroup.tagName === 'OPTGROUP' && parentOptgroup.children.length === 0) {
|
||||
parentOptgroup.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move selected options of a select element up in order.
|
||||
* Move selected options of a select element up in order, respecting optgroup boundaries.
|
||||
*
|
||||
* Adapted from:
|
||||
* @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
|
||||
@@ -27,14 +55,21 @@ function moveOptionUp(element: HTMLSelectElement): void {
|
||||
for (let i = 1; i < options.length; i++) {
|
||||
const option = options[i];
|
||||
if (option.selected) {
|
||||
element.removeChild(option);
|
||||
element.insertBefore(option, element.options[i - 1]);
|
||||
const parent = option.parentElement as HTMLElement;
|
||||
const previousOption = element.options[i - 1];
|
||||
const previousParent = previousOption.parentElement as HTMLElement;
|
||||
|
||||
// Only move if previous option is in the same parent (optgroup or select)
|
||||
if (parent === previousParent) {
|
||||
parent.removeChild(option);
|
||||
parent.insertBefore(option, previousOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move selected options of a select element down in order.
|
||||
* Move selected options of a select element down in order, respecting optgroup boundaries.
|
||||
*
|
||||
* Adapted from:
|
||||
* @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
|
||||
@@ -43,12 +78,18 @@ function moveOptionUp(element: HTMLSelectElement): void {
|
||||
function moveOptionDown(element: HTMLSelectElement): void {
|
||||
const options = Array.from(element.options);
|
||||
for (let i = options.length - 2; i >= 0; i--) {
|
||||
let option = options[i];
|
||||
const option = options[i];
|
||||
if (option.selected) {
|
||||
let next = element.options[i + 1];
|
||||
option = element.removeChild(option);
|
||||
next = element.replaceChild(option, next);
|
||||
element.insertBefore(next, option);
|
||||
const parent = option.parentElement as HTMLElement;
|
||||
const nextOption = element.options[i + 1];
|
||||
const nextParent = nextOption.parentElement as HTMLElement;
|
||||
|
||||
// Only move if next option is in the same parent (optgroup or select)
|
||||
if (parent === nextParent) {
|
||||
const optionClone = parent.removeChild(option);
|
||||
const nextClone = parent.replaceChild(optionClone, nextOption);
|
||||
parent.insertBefore(nextClone, optionClone);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,3 +32,16 @@ form.object-edit {
|
||||
border: 1px solid $red;
|
||||
}
|
||||
}
|
||||
|
||||
// Make optgroup labels sticky when scrolling through select elements
|
||||
select[multiple] {
|
||||
optgroup {
|
||||
top: 0;
|
||||
background-color: var(--bs-body-bg);
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
}
|
||||
option {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3190,10 +3190,10 @@ safe-regex-test@^1.1.0:
|
||||
es-errors "^1.3.0"
|
||||
is-regex "^1.2.1"
|
||||
|
||||
sass@1.94.2:
|
||||
version "1.94.2"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.94.2.tgz#198511fc6fdd2fc0a71b8d1261735c12608d4ef3"
|
||||
integrity sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==
|
||||
sass@1.95.0:
|
||||
version "1.95.0"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.95.0.tgz#3a3a4d4d954313ab50eaf16f6e2548a2f6ec0811"
|
||||
integrity sha512-9QMjhLq+UkOg/4bb8Lt8A+hJZvY3t+9xeZMKSBtBEgxrXA3ed5Ts4NDreUkYgJP1BTmrscQE/xYhf7iShow6lw==
|
||||
dependencies:
|
||||
chokidar "^4.0.0"
|
||||
immutable "^5.0.2"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version: "4.4.7"
|
||||
version: "4.4.8"
|
||||
edition: "Community"
|
||||
published: "2025-11-25"
|
||||
published: "2025-12-09"
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
from django import forms
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import password_validation
|
||||
from django.contrib.postgres.forms import SimpleArrayField
|
||||
@@ -21,6 +23,7 @@ from utilities.forms.fields import (
|
||||
DynamicModelMultipleChoiceField,
|
||||
JSONField,
|
||||
)
|
||||
from utilities.string import title
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
|
||||
from utilities.permissions import qs_filter_from_constraints
|
||||
@@ -283,10 +286,24 @@ class GroupForm(forms.ModelForm):
|
||||
|
||||
|
||||
def get_object_types_choices():
|
||||
return [
|
||||
(ot.pk, str(ot))
|
||||
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model')
|
||||
]
|
||||
"""
|
||||
Generate choices for object types grouped by app label using optgroups.
|
||||
Returns nested structure: [(app_label, [(id, model_name), ...]), ...]
|
||||
"""
|
||||
app_label_map = {
|
||||
app_config.label: app_config.verbose_name
|
||||
for app_config in apps.get_app_configs()
|
||||
}
|
||||
choices_by_app = defaultdict(list)
|
||||
|
||||
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model'):
|
||||
app_label = app_label_map.get(ot.app_label, ot.app_label)
|
||||
|
||||
model_class = ot.model_class()
|
||||
model_name = model_class._meta.verbose_name if model_class else ot.model
|
||||
choices_by_app[app_label].append((ot.pk, title(model_name)))
|
||||
|
||||
return list(choices_by_app.items())
|
||||
|
||||
|
||||
class ObjectPermissionForm(forms.ModelForm):
|
||||
|
||||
@@ -66,17 +66,45 @@ class SelectWithPK(forms.Select):
|
||||
option_template_name = 'widgets/select_option_with_pk.html'
|
||||
|
||||
|
||||
class AvailableOptions(forms.SelectMultiple):
|
||||
class SelectMultipleBase(forms.SelectMultiple):
|
||||
"""
|
||||
Base class for select widgets that filter choices based on selected values.
|
||||
Subclasses should set `include_selected` to control filtering behavior.
|
||||
"""
|
||||
include_selected = False
|
||||
|
||||
def optgroups(self, name, value, attrs=None):
|
||||
filtered_choices = []
|
||||
include_selected = self.include_selected
|
||||
|
||||
for choice in self.choices:
|
||||
if isinstance(choice[1], (list, tuple)): # optgroup
|
||||
group_label, group_choices = choice
|
||||
filtered_group = [
|
||||
c for c in group_choices if (str(c[0]) in value) == include_selected
|
||||
]
|
||||
|
||||
if filtered_group: # Only include optgroup if it has choices left
|
||||
filtered_choices.append((group_label, filtered_group))
|
||||
else: # option, e.g. flat choice
|
||||
if (str(choice[0]) in value) == include_selected:
|
||||
filtered_choices.append(choice)
|
||||
|
||||
self.choices = filtered_choices
|
||||
value = [] # Clear selected choices
|
||||
return super().optgroups(name, value, attrs)
|
||||
|
||||
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
|
||||
option = super().create_option(name, value, label, selected, index, subindex, attrs)
|
||||
option['attrs']['title'] = label # Add title attribute to show full text on hover
|
||||
return option
|
||||
|
||||
|
||||
class AvailableOptions(SelectMultipleBase):
|
||||
"""
|
||||
Renders a <select multiple=true> including only choices that have been selected. (For unbound fields, this list
|
||||
will be empty.) Employed by SplitMultiSelectWidget.
|
||||
"""
|
||||
def optgroups(self, name, value, attrs=None):
|
||||
self.choices = [
|
||||
choice for choice in self.choices if str(choice[0]) not in value
|
||||
]
|
||||
value = [] # Clear selected choices
|
||||
return super().optgroups(name, value, attrs)
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
@@ -87,17 +115,12 @@ class AvailableOptions(forms.SelectMultiple):
|
||||
return context
|
||||
|
||||
|
||||
class SelectedOptions(forms.SelectMultiple):
|
||||
class SelectedOptions(SelectMultipleBase):
|
||||
"""
|
||||
Renders a <select multiple=true> including only choices that have _not_ been selected. (For unbound fields, this
|
||||
will include _all_ choices.) Employed by SplitMultiSelectWidget.
|
||||
"""
|
||||
def optgroups(self, name, value, attrs=None):
|
||||
self.choices = [
|
||||
choice for choice in self.choices if str(choice[0]) in value
|
||||
]
|
||||
value = [] # Clear selected choices
|
||||
return super().optgroups(name, value, attrs)
|
||||
include_selected = True
|
||||
|
||||
|
||||
class SplitMultiSelectWidget(forms.MultiWidget):
|
||||
|
||||
@@ -35,27 +35,34 @@ class NetBoxFakeRequest:
|
||||
# Utility functions
|
||||
#
|
||||
|
||||
def copy_safe_request(request):
|
||||
def copy_safe_request(request, include_files=True):
|
||||
"""
|
||||
Copy selected attributes from a request object into a new fake request object. This is needed in places where
|
||||
thread safe pickling of the useful request data is needed.
|
||||
|
||||
Args:
|
||||
request: The original request object
|
||||
include_files: Whether to include request.FILES.
|
||||
"""
|
||||
meta = {
|
||||
k: request.META[k]
|
||||
for k in HTTP_REQUEST_META_SAFE_COPY
|
||||
if k in request.META and isinstance(request.META[k], str)
|
||||
}
|
||||
return NetBoxFakeRequest({
|
||||
data = {
|
||||
'META': meta,
|
||||
'COOKIES': request.COOKIES,
|
||||
'POST': request.POST,
|
||||
'GET': request.GET,
|
||||
'FILES': request.FILES,
|
||||
'user': request.user,
|
||||
'method': request.method,
|
||||
'path': request.path,
|
||||
'id': getattr(request, 'id', None), # UUID assigned by middleware
|
||||
})
|
||||
}
|
||||
if include_files:
|
||||
data['FILES'] = request.FILES
|
||||
|
||||
return NetBoxFakeRequest(data)
|
||||
|
||||
|
||||
def get_client_ip(request, additional_headers=()):
|
||||
|
||||
@@ -7,6 +7,7 @@ from utilities.forms.bulk_import import BulkImportForm
|
||||
from utilities.forms.fields.csv import CSVSelectWidget
|
||||
from utilities.forms.forms import BulkRenameForm
|
||||
from utilities.forms.utils import get_field_value, expand_alphanumeric_pattern, expand_ipaddress_pattern
|
||||
from utilities.forms.widgets.select import AvailableOptions, SelectedOptions
|
||||
|
||||
|
||||
class ExpandIPAddress(TestCase):
|
||||
@@ -481,3 +482,71 @@ class CSVSelectWidgetTest(TestCase):
|
||||
widget = CSVSelectWidget()
|
||||
data = {'test_field': 'valid_value'}
|
||||
self.assertFalse(widget.value_omitted_from_data(data, {}, 'test_field'))
|
||||
|
||||
|
||||
class SelectMultipleWidgetTest(TestCase):
|
||||
"""
|
||||
Validate filtering behavior of AvailableOptions and SelectedOptions widgets.
|
||||
"""
|
||||
|
||||
def test_available_options_flat_choices(self):
|
||||
"""AvailableOptions should exclude selected values from flat choices"""
|
||||
widget = AvailableOptions(choices=[
|
||||
(1, 'Option 1'),
|
||||
(2, 'Option 2'),
|
||||
(3, 'Option 3'),
|
||||
])
|
||||
widget.optgroups('test', ['2'], None)
|
||||
|
||||
self.assertEqual(len(widget.choices), 2)
|
||||
self.assertEqual(widget.choices[0], (1, 'Option 1'))
|
||||
self.assertEqual(widget.choices[1], (3, 'Option 3'))
|
||||
|
||||
def test_available_options_optgroups(self):
|
||||
"""AvailableOptions should exclude selected values from optgroups"""
|
||||
widget = AvailableOptions(choices=[
|
||||
('Group A', [(1, 'Option 1'), (2, 'Option 2')]),
|
||||
('Group B', [(3, 'Option 3'), (4, 'Option 4')]),
|
||||
])
|
||||
|
||||
# Select options 2 and 3
|
||||
widget.optgroups('test', ['2', '3'], None)
|
||||
|
||||
# Should have 2 groups with filtered choices
|
||||
self.assertEqual(len(widget.choices), 2)
|
||||
self.assertEqual(widget.choices[0][0], 'Group A')
|
||||
self.assertEqual(widget.choices[0][1], [(1, 'Option 1')])
|
||||
self.assertEqual(widget.choices[1][0], 'Group B')
|
||||
self.assertEqual(widget.choices[1][1], [(4, 'Option 4')])
|
||||
|
||||
def test_selected_options_flat_choices(self):
|
||||
"""SelectedOptions should include only selected values from flat choices"""
|
||||
widget = SelectedOptions(choices=[
|
||||
(1, 'Option 1'),
|
||||
(2, 'Option 2'),
|
||||
(3, 'Option 3'),
|
||||
])
|
||||
|
||||
# Select option 2
|
||||
widget.optgroups('test', ['2'], None)
|
||||
|
||||
# Should only have option 2
|
||||
self.assertEqual(len(widget.choices), 1)
|
||||
self.assertEqual(widget.choices[0], (2, 'Option 2'))
|
||||
|
||||
def test_selected_options_optgroups(self):
|
||||
"""SelectedOptions should include only selected values from optgroups"""
|
||||
widget = SelectedOptions(choices=[
|
||||
('Group A', [(1, 'Option 1'), (2, 'Option 2')]),
|
||||
('Group B', [(3, 'Option 3'), (4, 'Option 4')]),
|
||||
])
|
||||
|
||||
# Select options 2 and 3
|
||||
widget.optgroups('test', ['2', '3'], None)
|
||||
|
||||
# Should have 2 groups with only selected choices
|
||||
self.assertEqual(len(widget.choices), 2)
|
||||
self.assertEqual(widget.choices[0][0], 'Group A')
|
||||
self.assertEqual(widget.choices[0][1], [(2, 'Option 2')])
|
||||
self.assertEqual(widget.choices[1][0], 'Group B')
|
||||
self.assertEqual(widget.choices[1][1], [(3, 'Option 3')])
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
colorama==0.4.6
|
||||
Django==5.2.8
|
||||
Django==5.2.9
|
||||
django-cors-headers==4.9.0
|
||||
django-debug-toolbar==6.1.0
|
||||
django-filter==25.2
|
||||
django-graphiql-debug-toolbar==0.2.0
|
||||
django-htmx==1.26.0
|
||||
django-htmx==1.27.0
|
||||
django-mptt==0.17.0
|
||||
django-pglocks==1.0.4
|
||||
django-prometheus==2.4.1
|
||||
@@ -14,30 +14,30 @@ django-rq==3.2.1
|
||||
django-storages==1.14.6
|
||||
django-tables2==2.8.0
|
||||
django-taggit==6.1.0
|
||||
django-timezone-field==7.1
|
||||
django-timezone-field==7.2.1
|
||||
djangorestframework==3.16.1
|
||||
drf-spectacular==0.29.0
|
||||
drf-spectacular-sidecar==2025.10.1
|
||||
drf-spectacular-sidecar==2025.12.1
|
||||
feedparser==6.0.12
|
||||
gunicorn==23.0.0
|
||||
Jinja2==3.1.6
|
||||
jsonschema==4.25.1
|
||||
Markdown==3.10
|
||||
mkdocs-material==9.7.0
|
||||
mkdocstrings==0.30.1
|
||||
mkdocstrings-python==1.19.0
|
||||
mkdocstrings==1.0.0
|
||||
mkdocstrings-python==2.0.1
|
||||
netaddr==1.3.0
|
||||
nh3==0.3.2
|
||||
Pillow==12.0.0
|
||||
psycopg[c,pool]==3.2.13
|
||||
psycopg[c,pool]==3.3.2
|
||||
PyYAML==6.0.3
|
||||
requests==2.32.5
|
||||
rq==2.6.1
|
||||
social-auth-app-django==5.6.0
|
||||
social-auth-core==4.8.1
|
||||
sorl-thumbnail==12.11.0
|
||||
strawberry-graphql==0.287.0
|
||||
strawberry-graphql-django==0.67.2
|
||||
strawberry-graphql==0.287.2
|
||||
strawberry-graphql-django==0.70.1
|
||||
svgwrite==1.4.3
|
||||
tablib==3.9.0
|
||||
tzdata==2025.2
|
||||
|
||||
Reference in New Issue
Block a user