Fix single {module} token rejection at nested module bay depth (#21740)

* 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
This commit is contained in:
Mark Robert Coleman
2026-04-02 01:19:43 +02:00
committed by GitHub
parent e98e5e11a7
commit c7bbfb24c5
5 changed files with 191 additions and 50 deletions

View File

@@ -6,8 +6,9 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from dcim.choices import *
from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS
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
@@ -159,6 +160,60 @@ class ModuleSerializer(PrimaryModelSerializer):
]
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(