mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-11 11:47:08 +02:00
* Fix single {module} token rejection at nested depth (#20474)
A module type with a single {module} placeholder in component template
names could not be installed in a nested module bay (depth > 1) because
the form validation required an exact match between the token count and
the tree depth. This resolves the issue by treating a single {module}
token as a reference to the immediate parent bay's position, regardless
of nesting depth. Multi-token behavior is unchanged.
Refactors resolve_name() and resolve_label() into a shared
_resolve_module_placeholder() helper to eliminate duplication.
Fixes: #20474
* Address review feedback for PR #21740 (fixes #20474)
- Rebase on latest main to resolve merge conflicts
- Extract shared module bay traversal and {module} token resolution
into dcim/utils.py (get_module_bay_positions, resolve_module_placeholder)
- Update ModuleCommonForm, ModularComponentTemplateModel, and
ModuleBayTemplate to use shared utility functions
- Add {module} token validation to ModuleSerializer.validate() so the
API enforces the same rules as the UI form
- Remove duplicated _get_module_bay_tree (form) and _get_module_tree
(model) methods in favor of the shared routine
233 lines
11 KiB
Python
233 lines
11 KiB
Python
import decimal
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.utils.translation import gettext as _
|
|
from drf_spectacular.utils import extend_schema_field
|
|
from rest_framework import serializers
|
|
|
|
from dcim.choices import *
|
|
from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS, MODULE_TOKEN
|
|
from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceContext
|
|
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
|
|
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
|
|
from ipam.api.serializers_.ip import IPAddressSerializer
|
|
from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
|
|
from netbox.api.gfk_fields import GFKSerializerField
|
|
from netbox.api.serializers import PrimaryModelSerializer
|
|
from tenancy.api.serializers_.tenants import TenantSerializer
|
|
from virtualization.api.serializers_.clusters import ClusterSerializer
|
|
|
|
from .devicetypes import *
|
|
from .nested import NestedDeviceBaySerializer, NestedDeviceSerializer, NestedModuleBaySerializer
|
|
from .platforms import PlatformSerializer
|
|
from .racks import RackSerializer
|
|
from .roles import DeviceRoleSerializer
|
|
from .sites import LocationSerializer, SiteSerializer
|
|
from .virtualchassis import VirtualChassisSerializer
|
|
|
|
__all__ = (
|
|
'DeviceSerializer',
|
|
'DeviceWithConfigContextSerializer',
|
|
'MACAddressSerializer',
|
|
'ModuleSerializer',
|
|
'VirtualDeviceContextSerializer',
|
|
)
|
|
|
|
|
|
class DeviceSerializer(PrimaryModelSerializer):
|
|
device_type = DeviceTypeSerializer(nested=True)
|
|
role = DeviceRoleSerializer(nested=True)
|
|
tenant = TenantSerializer(
|
|
nested=True,
|
|
required=False,
|
|
allow_null=True,
|
|
default=None
|
|
)
|
|
platform = PlatformSerializer(nested=True, required=False, allow_null=True)
|
|
site = SiteSerializer(nested=True)
|
|
location = LocationSerializer(nested=True, required=False, allow_null=True, default=None)
|
|
rack = RackSerializer(nested=True, required=False, allow_null=True, default=None)
|
|
face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default=lambda: '')
|
|
position = serializers.DecimalField(
|
|
max_digits=4,
|
|
decimal_places=1,
|
|
allow_null=True,
|
|
label=_('Position (U)'),
|
|
min_value=decimal.Decimal(0.5),
|
|
default=None
|
|
)
|
|
status = ChoiceField(choices=DeviceStatusChoices, required=False)
|
|
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
|
|
primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True)
|
|
primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)
|
|
primary_ip6 = IPAddressSerializer(nested=True, required=False, allow_null=True)
|
|
oob_ip = IPAddressSerializer(nested=True, required=False, allow_null=True)
|
|
parent_device = serializers.SerializerMethodField()
|
|
cluster = ClusterSerializer(nested=True, required=False, allow_null=True)
|
|
virtual_chassis = VirtualChassisSerializer(nested=True, required=False, allow_null=True, default=None)
|
|
vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
|
|
config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
|
|
|
|
# Counter fields
|
|
console_port_count = serializers.IntegerField(read_only=True)
|
|
console_server_port_count = serializers.IntegerField(read_only=True)
|
|
power_port_count = serializers.IntegerField(read_only=True)
|
|
power_outlet_count = serializers.IntegerField(read_only=True)
|
|
interface_count = serializers.IntegerField(read_only=True)
|
|
front_port_count = serializers.IntegerField(read_only=True)
|
|
rear_port_count = serializers.IntegerField(read_only=True)
|
|
device_bay_count = serializers.IntegerField(read_only=True)
|
|
module_bay_count = serializers.IntegerField(read_only=True)
|
|
inventory_item_count = serializers.IntegerField(read_only=True)
|
|
|
|
class Meta:
|
|
model = Device
|
|
fields = [
|
|
'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
|
|
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
|
|
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
|
|
'vc_position', 'vc_priority', 'description', 'owner', 'comments', 'config_template', 'local_context_data',
|
|
'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
|
|
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
|
|
'device_bay_count', 'module_bay_count', 'inventory_item_count',
|
|
]
|
|
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
|
|
|
@extend_schema_field(NestedDeviceSerializer(allow_null=True))
|
|
def get_parent_device(self, obj):
|
|
try:
|
|
device_bay = obj.parent_bay
|
|
except DeviceBay.DoesNotExist:
|
|
return None
|
|
context = {'request': self.context['request']}
|
|
data = NestedDeviceSerializer(instance=device_bay.device, context=context).data
|
|
data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
|
|
return data
|
|
|
|
|
|
class DeviceWithConfigContextSerializer(DeviceSerializer):
|
|
config_context = serializers.SerializerMethodField(read_only=True, allow_null=True)
|
|
|
|
class Meta(DeviceSerializer.Meta):
|
|
fields = [
|
|
'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
|
|
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
|
|
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
|
|
'vc_position', 'vc_priority', 'description', 'owner', 'comments', 'config_template', 'config_context',
|
|
'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
|
|
'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
|
|
'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
|
|
]
|
|
|
|
@extend_schema_field(serializers.JSONField(allow_null=True))
|
|
def get_config_context(self, obj):
|
|
return obj.get_config_context()
|
|
|
|
|
|
class VirtualDeviceContextSerializer(PrimaryModelSerializer):
|
|
device = DeviceSerializer(nested=True)
|
|
identifier = serializers.IntegerField(allow_null=True, max_value=32767, min_value=0, required=False, default=None)
|
|
tenant = TenantSerializer(nested=True, required=False, allow_null=True, default=None)
|
|
primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True)
|
|
primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)
|
|
primary_ip6 = IPAddressSerializer(nested=True, required=False, allow_null=True)
|
|
status = ChoiceField(choices=VirtualDeviceContextStatusChoices)
|
|
|
|
# Related object counts
|
|
interface_count = RelatedObjectCountField('interfaces')
|
|
|
|
class Meta:
|
|
model = VirtualDeviceContext
|
|
fields = [
|
|
'id', 'url', 'display_url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip',
|
|
'primary_ip4', 'primary_ip6', 'status', 'description', 'owner', 'comments', 'tags', 'custom_fields',
|
|
'created', 'last_updated', 'interface_count',
|
|
]
|
|
brief_fields = ('id', 'url', 'display', 'name', 'identifier', 'device', 'description')
|
|
|
|
|
|
class ModuleSerializer(PrimaryModelSerializer):
|
|
device = DeviceSerializer(nested=True)
|
|
module_bay = NestedModuleBaySerializer()
|
|
module_type = ModuleTypeSerializer(nested=True)
|
|
status = ChoiceField(choices=ModuleStatusChoices, required=False)
|
|
|
|
class Meta:
|
|
model = Module
|
|
fields = [
|
|
'id', 'url', 'display_url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial',
|
|
'asset_tag', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
|
]
|
|
brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
|
|
|
|
def validate(self, data):
|
|
data = super().validate(data)
|
|
|
|
if self.nested:
|
|
return data
|
|
|
|
# Skip validation for existing modules (updates)
|
|
if self.instance is not None:
|
|
return data
|
|
|
|
module_bay = data.get('module_bay')
|
|
module_type = data.get('module_type')
|
|
device = data.get('device')
|
|
|
|
if not all((module_bay, module_type, device)):
|
|
return data
|
|
|
|
positions = get_module_bay_positions(module_bay)
|
|
|
|
for templates, component_attribute in [
|
|
("consoleporttemplates", "consoleports"),
|
|
("consoleserverporttemplates", "consoleserverports"),
|
|
("interfacetemplates", "interfaces"),
|
|
("powerporttemplates", "powerports"),
|
|
("poweroutlettemplates", "poweroutlets"),
|
|
("rearporttemplates", "rearports"),
|
|
("frontporttemplates", "frontports"),
|
|
]:
|
|
installed_components = {
|
|
component.name: component for component in getattr(device, component_attribute).all()
|
|
}
|
|
|
|
for template in getattr(module_type, templates).all():
|
|
resolved_name = template.name
|
|
if MODULE_TOKEN in template.name:
|
|
if not module_bay.position:
|
|
raise serializers.ValidationError(
|
|
_("Cannot install module with placeholder values in a module bay with no position defined.")
|
|
)
|
|
try:
|
|
resolved_name = resolve_module_placeholder(template.name, positions)
|
|
except ValueError as e:
|
|
raise serializers.ValidationError(str(e))
|
|
|
|
if resolved_name in installed_components:
|
|
raise serializers.ValidationError(
|
|
_("A {model} named {name} already exists").format(
|
|
model=template.component_model.__name__,
|
|
name=resolved_name
|
|
)
|
|
)
|
|
|
|
return data
|
|
|
|
|
|
class MACAddressSerializer(PrimaryModelSerializer):
|
|
assigned_object_type = ContentTypeField(
|
|
queryset=ContentType.objects.filter(MACADDRESS_ASSIGNMENT_MODELS),
|
|
required=False,
|
|
allow_null=True
|
|
)
|
|
assigned_object = GFKSerializerField(read_only=True)
|
|
|
|
class Meta:
|
|
model = MACAddress
|
|
fields = [
|
|
'id', 'url', 'display_url', 'display', 'mac_address', 'assigned_object_type', 'assigned_object_id',
|
|
'assigned_object', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
|
]
|
|
brief_fields = ('id', 'url', 'display', 'mac_address', 'description')
|