mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-17 06:29:53 +02:00
Implement {module} position inheritance for nested module bays (#21753)
* Implement {module} position inheritance for nested module bays (#19796)
Enables a single ModuleType to produce correctly named components at any
nesting depth by resolving {module} in module bay position fields during
tree traversal. The user controls the separator through the position
field template itself (e.g. {module}/1 vs {module}-1 vs {module}.1).
Model layer:
- Add _get_inherited_positions() to resolve {module} in positions as
the module tree is walked from root to leaf
- Update _resolve_module_placeholder() with single-token logic: one
{module} resolves to the leaf bay's inherited position; multi-token
continues level-by-level replacement for backwards compatibility
Form layer:
- Update _get_module_bay_tree() to resolve {module} in positions during
traversal, propagating parent positions through the tree
- Extract validation into _validate_module_tokens() private method
Tests:
- Position inheritance at depth 2 and 3
- Custom separator (dot notation)
- Multi-token backwards compatibility
- Documentation for position inheritance
Fixes: #19796
* Consolidate {module} placeholder logic into shared utilities and add API validation
Extract get_module_bay_positions() and resolve_module_placeholder() into
dcim/utils.py as shared routines used by the model, form, and API serializer.
This eliminates duplicated traversal and resolution logic across three layers.
Key changes:
- Add position inheritance: {module} tokens in bay position fields resolve
using the parent bay's position during hierarchy traversal
- Single {module} token now resolves to the leaf bay's inherited position
- Mismatched token count vs tree depth now raises ValueError instead of
silently producing partial strings
- API serializer validation uses shared utilities for parity with the form
- Fix error message wording ("levels deep" instead of "in tree")
This commit is contained in:
committed by
GitHub
parent
b62c5e1ac4
commit
a06a300913
@@ -999,6 +999,273 @@ class ModuleBayTestCase(TestCase):
|
||||
nested_bay = module.modulebays.get(name='Sub-bay 1-1')
|
||||
self.assertEqual(nested_bay.position, '1-1')
|
||||
|
||||
#
|
||||
# Position inheritance tests (#19796)
|
||||
#
|
||||
|
||||
def test_position_inheritance_depth_2(self):
|
||||
"""
|
||||
A module bay with position '{module}/2' under a parent bay with position '1'
|
||||
should resolve to position '1/2'. A single {module} in the interface template
|
||||
should then resolve to '1/2'.
|
||||
"""
|
||||
manufacturer = Manufacturer.objects.first()
|
||||
site = Site.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Chassis for Inheritance',
|
||||
slug='chassis-for-inheritance'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
device_type=device_type,
|
||||
name='Line card slot 1',
|
||||
position='1'
|
||||
)
|
||||
|
||||
line_card_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Line Card with Inherited Bays'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
module_type=line_card_type,
|
||||
name='SFP bay {module}/1',
|
||||
position='{module}/1'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
module_type=line_card_type,
|
||||
name='SFP bay {module}/2',
|
||||
position='{module}/2'
|
||||
)
|
||||
|
||||
sfp_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='SFP with Inherited Path'
|
||||
)
|
||||
InterfaceTemplate.objects.create(
|
||||
module_type=sfp_type,
|
||||
name='SFP {module}',
|
||||
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
|
||||
)
|
||||
|
||||
device = Device.objects.create(
|
||||
name='Inheritance Chassis',
|
||||
device_type=device_type,
|
||||
role=device_role,
|
||||
site=site
|
||||
)
|
||||
|
||||
lc_bay = device.modulebays.get(name='Line card slot 1')
|
||||
line_card = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=lc_bay,
|
||||
module_type=line_card_type
|
||||
)
|
||||
|
||||
sfp_bay = line_card.modulebays.get(name='SFP bay 1/2')
|
||||
sfp_module = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=sfp_bay,
|
||||
module_type=sfp_type
|
||||
)
|
||||
|
||||
interface = sfp_module.interfaces.first()
|
||||
self.assertEqual(interface.name, 'SFP 1/2')
|
||||
|
||||
def test_position_inheritance_depth_3(self):
|
||||
"""
|
||||
Position inheritance at depth 3: positions should chain through the tree.
|
||||
"""
|
||||
manufacturer = Manufacturer.objects.first()
|
||||
site = Site.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Deep Chassis',
|
||||
slug='deep-chassis'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
device_type=device_type,
|
||||
name='Slot A',
|
||||
position='A'
|
||||
)
|
||||
|
||||
mid_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Mid Module'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
module_type=mid_type,
|
||||
name='Sub {module}-1',
|
||||
position='{module}-1'
|
||||
)
|
||||
|
||||
leaf_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Leaf Module'
|
||||
)
|
||||
InterfaceTemplate.objects.create(
|
||||
module_type=leaf_type,
|
||||
name='Port {module}',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_FIXED
|
||||
)
|
||||
|
||||
device = Device.objects.create(
|
||||
name='Deep Device',
|
||||
device_type=device_type,
|
||||
role=device_role,
|
||||
site=site
|
||||
)
|
||||
|
||||
slot_a = device.modulebays.get(name='Slot A')
|
||||
mid_module = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=slot_a,
|
||||
module_type=mid_type
|
||||
)
|
||||
|
||||
sub_bay = mid_module.modulebays.get(name='Sub A-1')
|
||||
self.assertEqual(sub_bay.position, 'A-1')
|
||||
|
||||
leaf_module = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=sub_bay,
|
||||
module_type=leaf_type
|
||||
)
|
||||
|
||||
interface = leaf_module.interfaces.first()
|
||||
self.assertEqual(interface.name, 'Port A-1')
|
||||
|
||||
def test_position_inheritance_custom_separator(self):
|
||||
"""
|
||||
Users control the separator through the position field template.
|
||||
Using '.' instead of '/' should work correctly.
|
||||
"""
|
||||
manufacturer = Manufacturer.objects.first()
|
||||
site = Site.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Dot Separator Chassis',
|
||||
slug='dot-separator-chassis'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
device_type=device_type,
|
||||
name='Bay 1',
|
||||
position='1'
|
||||
)
|
||||
|
||||
card_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Card with Dot Separator'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
module_type=card_type,
|
||||
name='Port {module}.1',
|
||||
position='{module}.1'
|
||||
)
|
||||
|
||||
sfp_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='SFP Dot'
|
||||
)
|
||||
InterfaceTemplate.objects.create(
|
||||
module_type=sfp_type,
|
||||
name='eth{module}',
|
||||
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
|
||||
)
|
||||
|
||||
device = Device.objects.create(
|
||||
name='Dot Device',
|
||||
device_type=device_type,
|
||||
role=device_role,
|
||||
site=site
|
||||
)
|
||||
|
||||
bay = device.modulebays.get(name='Bay 1')
|
||||
card = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=bay,
|
||||
module_type=card_type
|
||||
)
|
||||
|
||||
port_bay = card.modulebays.get(name='Port 1.1')
|
||||
sfp = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=port_bay,
|
||||
module_type=sfp_type
|
||||
)
|
||||
|
||||
interface = sfp.interfaces.first()
|
||||
self.assertEqual(interface.name, 'eth1.1')
|
||||
|
||||
def test_multi_token_backwards_compat(self):
|
||||
"""
|
||||
Multi-token {module}/{module} at matching depth should still resolve
|
||||
level-by-level (backwards compatibility).
|
||||
"""
|
||||
manufacturer = Manufacturer.objects.first()
|
||||
site = Site.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Multi Token Chassis',
|
||||
slug='multi-token-chassis'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
device_type=device_type,
|
||||
name='Slot 1',
|
||||
position='1'
|
||||
)
|
||||
|
||||
card_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Card for Multi Token'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
module_type=card_type,
|
||||
name='Port 1',
|
||||
position='2'
|
||||
)
|
||||
|
||||
iface_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Interface Module Multi Token'
|
||||
)
|
||||
InterfaceTemplate.objects.create(
|
||||
module_type=iface_type,
|
||||
name='Gi{module}/{module}',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_FIXED
|
||||
)
|
||||
|
||||
device = Device.objects.create(
|
||||
name='Multi Token Device',
|
||||
device_type=device_type,
|
||||
role=device_role,
|
||||
site=site
|
||||
)
|
||||
|
||||
slot = device.modulebays.get(name='Slot 1')
|
||||
card = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=slot,
|
||||
module_type=card_type
|
||||
)
|
||||
|
||||
port = card.modulebays.get(name='Port 1')
|
||||
iface_module = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=port,
|
||||
module_type=iface_type
|
||||
)
|
||||
|
||||
interface = iface_module.interfaces.first()
|
||||
self.assertEqual(interface.name, 'Gi1/2')
|
||||
|
||||
@tag('regression') # #20912
|
||||
def test_module_bay_parent_cleared_when_module_removed(self):
|
||||
"""Test that the parent field is properly cleared when a module bay's module assignment is removed"""
|
||||
|
||||
Reference in New Issue
Block a user