Compare commits

...

3 Commits

Author SHA1 Message Date
Jason Novinger
f97f14bbc7 Merge 9ae53fc232 into 269112a565 2025-12-08 17:06:37 +01:00
Jason Novinger
269112a565 Fixes #19918: Resolve {module} placeholders in nested module bay labels
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
ModuleBayTemplate.instantiate() now calls resolve_name() and resolve_label()
to properly resolve {module} placeholders, making it consistent with other
modular components like InterfaceTemplate.

When a module with nested module bays is installed (e.g., a module with SFP
bays in position "A"), the nested bay labels now correctly show "A-21" instead
of "{module}-21".

This also removes the inconsistent fix from #17436 which only handled name
resolution post-instantiation. The proper resolution now happens during
instantiation using the existing resolve methods.
2025-12-08 10:06:46 -05:00
Jason Novinger
9ae53fc232 Fixes #20560: Fix VLAN disambiguation in prefix bulk import 2025-12-05 16:39:28 -06:00
5 changed files with 126 additions and 10 deletions

View File

@@ -681,8 +681,8 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.name, name=self.resolve_name(kwargs.get('module')),
label=self.label, label=self.resolve_label(kwargs.get('module')),
position=self.position, position=self.position,
**kwargs **kwargs
) )

View File

@@ -7,7 +7,6 @@ from django.utils.translation import gettext_lazy as _
from jsonschema.exceptions import ValidationError as JSONValidationError from jsonschema.exceptions import ValidationError as JSONValidationError
from dcim.choices import * from dcim.choices import *
from dcim.constants import MODULE_TOKEN
from dcim.utils import update_interface_bridges from dcim.utils import update_interface_bridges
from extras.models import ConfigContextModel, CustomField from extras.models import ConfigContextModel, CustomField
from netbox.models import PrimaryModel from netbox.models import PrimaryModel
@@ -331,7 +330,6 @@ class Module(PrimaryModel, ConfigContextModel):
else: else:
# ModuleBays must be saved individually for MPTT # ModuleBays must be saved individually for MPTT
for instance in create_instances: for instance in create_instances:
instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position))
instance.save() instance.save()
update_fields = ['module'] update_fields = ['module']

View File

@@ -792,8 +792,54 @@ class ModuleBayTestCase(TestCase):
) )
device.consoleports.first() device.consoleports.first()
def test_nested_module_token(self): @tag('regression') # #19918
pass 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): class CableTestCase(TestCase):

View File

@@ -230,10 +230,6 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
query |= Q(**{ query |= Q(**{
f"site__{self.fields['vlan_site'].to_field_name}": vlan_site 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: if vlan_group:
query &= Q(**{ query &= Q(**{

View File

@@ -564,6 +564,82 @@ vlan: 102
self.assertEqual(prefix.vlan.vid, 102) self.assertEqual(prefix.vlan.vid, 102)
self.assertEqual(prefix.scope, site) 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): class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPRange model = IPRange