From 1f336eee2ec66a2548adad9653f2e65311731faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Brunel?= <56799322+Etibru@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:15:11 +0100 Subject: [PATCH] Closes #21575: Implement `{vc_position}` template variable on component template name/label (#21601) --- docs/models/dcim/devicetype.md | 12 ++ docs/models/dcim/moduletype.md | 10 ++ netbox/dcim/constants.py | 3 + netbox/dcim/forms/model_forms.py | 4 +- .../dcim/models/device_component_templates.py | 101 +++++++---- netbox/dcim/models/devices.py | 29 +++- netbox/dcim/tests/test_forms.py | 83 +++++++++ netbox/dcim/tests/test_models.py | 161 ++++++++++++++++++ 8 files changed, 366 insertions(+), 37 deletions(-) diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md index fc177542c..4f75aff09 100644 --- a/docs/models/dcim/devicetype.md +++ b/docs/models/dcim/devicetype.md @@ -7,6 +7,18 @@ Device types are instantiated as devices installed within sites and/or equipment !!! note This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as modules or inventory items within a device. +## Automatic Component Renaming + +When adding component templates to a device type, the string `{vc_position}` can be used in component template names to reference the +`vc_position` field of the device being provisioned, when that device is a member of a Virtual Chassis. + +For example, an interface template named `Gi{vc_position}/0/0` installed on a Virtual Chassis +member with position `2` will be rendered as `Gi2/0/0`. + +If the device is not a member of a Virtual Chassis, `{vc_position}` defaults to `0`. A custom +fallback value can be specified using the syntax `{vc_position:X}`, where `X` is the desired default. +For example, `{vc_position:1}` will render as `1` when no Virtual Chassis position is set. + ## Fields ### Manufacturer diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md index 88f04466a..790e4a8de 100644 --- a/docs/models/dcim/moduletype.md +++ b/docs/models/dcim/moduletype.md @@ -20,6 +20,16 @@ When adding component templates to a module type, the string `{module}` can be u For example, you can create a module type with interface templates named `Gi{module}/0/[1-48]`. When a new module of this type is "installed" to a module bay with a position of "3", NetBox will automatically name these interfaces `Gi3/0/[1-48]`. +Similarly, the string `{vc_position}` can be used in component template names to reference the +`vc_position` field of the device being provisioned, when that device is a member of a Virtual Chassis. + +For example, an interface template named `Gi{vc_position}/{module}/0` installed on a Virtual Chassis +member with position `2` and module bay position `3` will be rendered as `Gi2/3/0`. + +If the device is not a member of a Virtual Chassis, `{vc_position}` defaults to `0`. A custom +fallback value can be specified using the syntax `{vc_position:X}`, where `X` is the desired default. +For example, `{vc_position:1}` will render as `1` when no Virtual Chassis position is set. + Automatic renaming is supported for all modular component types (those listed above). ## Fields diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 669345d7c..87f8844e0 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -1,3 +1,5 @@ +import re + from django.db.models import Q from .choices import InterfaceTypeChoices @@ -79,6 +81,7 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES # MODULE_TOKEN = '{module}' +VC_POSITION_RE = re.compile(r'\{vc_position(?::([^}]*))?\}') MODULAR_COMPONENT_TEMPLATE_MODELS = Q( app_label='dcim', diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index ca06b620b..8c715d369 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1072,7 +1072,9 @@ class ModularComponentTemplateForm(ComponentTemplateForm): self.fields['name'].help_text = _( "Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range are not " "supported (example: [ge,xe]-0/0/[0-9]). The token {module}, if present, will be " - "automatically replaced with the position value when creating a new module." + "automatically replaced with the position value when creating a new module. " + "The token {vc_position} will be replaced with the device's Virtual Chassis position " + "(use {vc_position:1} to specify a fallback (default is 0))" ) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 0d54331d8..475eb5508 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -165,6 +165,26 @@ class ModularComponentTemplateModel(ComponentTemplateModel): _("A component template must be associated with either a device type or a module type.") ) + @staticmethod + def _resolve_vc_position(value: str, device) -> str: + """ + Resolves {vc_position} and {vc_position:X} tokens. + + If the device has a vc_position, replaces the token with that value. + Otherwise uses the explicit fallback X if given, else '0'. + """ + def replacer(match): + explicit_fallback = match.group(1) + if ( + device is not None + and device.virtual_chassis is not None + and device.vc_position is not None + ): + return str(device.vc_position) + return explicit_fallback if explicit_fallback is not None else '0' + + return VC_POSITION_RE.sub(replacer, value) + def _get_module_tree(self, module): modules = [] while module: @@ -177,29 +197,42 @@ class ModularComponentTemplateModel(ComponentTemplateModel): modules.reverse() return modules - def resolve_name(self, module): - if MODULE_TOKEN not in self.name: + def resolve_name(self, module=None, device=None): + has_module = MODULE_TOKEN in self.name + has_vc = VC_POSITION_RE.search(self.name) is not None + if not has_module and not has_vc: return self.name - if module: - modules = self._get_module_tree(module) - name = self.name - for module in modules: - name = name.replace(MODULE_TOKEN, module.module_bay.position, 1) - return name - return self.name + name = self.name - def resolve_label(self, module): - if MODULE_TOKEN not in self.label: + if has_module and module: + modules = self._get_module_tree(module) + for m in modules: + name = name.replace(MODULE_TOKEN, m.module_bay.position, 1) + + if has_vc: + resolved_device = (module.device if module else None) or device + name = self._resolve_vc_position(name, resolved_device) + + return name + + def resolve_label(self, module=None, device=None): + has_module = MODULE_TOKEN in self.label + has_vc = VC_POSITION_RE.search(self.label) is not None + if not has_module and not has_vc: return self.label - if module: + label = self.label + + if has_module and module: modules = self._get_module_tree(module) - label = self.label - for module in modules: - label = label.replace(MODULE_TOKEN, module.module_bay.position, 1) - return label - return self.label + for m in modules: + label = label.replace(MODULE_TOKEN, m.module_bay.position, 1) + if has_vc: + resolved_device = (module.device if module else None) or device + label = self._resolve_vc_position(label, resolved_device) + + return label class ConsolePortTemplate(ModularComponentTemplateModel): @@ -222,8 +255,8 @@ class ConsolePortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return self.component_model( - name=self.resolve_name(kwargs.get('module')), - label=self.resolve_label(kwargs.get('module')), + name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), + label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), type=self.type, **kwargs ) @@ -257,8 +290,8 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return self.component_model( - name=self.resolve_name(kwargs.get('module')), - label=self.resolve_label(kwargs.get('module')), + name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), + label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), type=self.type, **kwargs ) @@ -307,8 +340,8 @@ class PowerPortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return self.component_model( - name=self.resolve_name(kwargs.get('module')), - label=self.resolve_label(kwargs.get('module')), + name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), + label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), type=self.type, maximum_draw=self.maximum_draw, allocated_draw=self.allocated_draw, @@ -395,13 +428,13 @@ class PowerOutletTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): if self.power_port: - power_port_name = self.power_port.resolve_name(kwargs.get('module')) + power_port_name = self.power_port.resolve_name(kwargs.get('module'), kwargs.get('device')) power_port = PowerPort.objects.get(name=power_port_name, **kwargs) else: power_port = None return self.component_model( - name=self.resolve_name(kwargs.get('module')), - label=self.resolve_label(kwargs.get('module')), + name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), + label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), type=self.type, color=self.color, power_port=power_port, @@ -501,8 +534,8 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel) def instantiate(self, **kwargs): return self.component_model( - name=self.resolve_name(kwargs.get('module')), - label=self.resolve_label(kwargs.get('module')), + name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), + label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), type=self.type, enabled=self.enabled, mgmt_only=self.mgmt_only, @@ -628,8 +661,8 @@ class FrontPortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return self.component_model( - name=self.resolve_name(kwargs.get('module')), - label=self.resolve_label(kwargs.get('module')), + name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), + label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), type=self.type, color=self.color, positions=self.positions, @@ -692,8 +725,8 @@ class RearPortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return self.component_model( - name=self.resolve_name(kwargs.get('module')), - label=self.resolve_label(kwargs.get('module')), + name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), + label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), type=self.type, color=self.color, positions=self.positions, @@ -731,8 +764,8 @@ class ModuleBayTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return self.component_model( - name=self.resolve_name(kwargs.get('module')), - label=self.resolve_label(kwargs.get('module')), + name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), + label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), position=self.position, **kwargs ) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 4a5d0b3cd..e205ac0c8 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -26,6 +26,7 @@ from netbox.config import ConfigItem from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from netbox.models.mixins import WeightMixin +from utilities.exceptions import AbortRequest from utilities.fields import ColorField, CounterCacheField from utilities.prefetch import get_prefetchable_fields from utilities.tracking import TrackingModelMixin @@ -948,6 +949,20 @@ class Device( ).format(virtual_chassis=self.vc_master_for) }) + def _check_duplicate_component_names(self, components): + """ + Check for duplicate component names after resolving {vc_position} placeholders. + Raises AbortRequest if duplicates are found. + """ + names = [c.name for c in components] + duplicates = {n for n in names if names.count(n) > 1} + if duplicates: + raise AbortRequest( + _("Component name conflict after resolving {{vc_position}}: {names}").format( + names=', '.join(duplicates) + ) + ) + def _instantiate_components(self, queryset, bulk_create=True): """ Instantiate components for the device from the specified component templates. @@ -962,6 +977,10 @@ class Device( components = [obj.instantiate(device=self) for obj in queryset] if not components: return + + # Check for duplicate names after resolution {vc_position} + self._check_duplicate_component_names(components) + # Set default values for any applicable custom fields if cf_defaults := CustomField.objects.get_defaults_for_model(model): for component in components: @@ -986,8 +1005,14 @@ class Device( update_fields=None ) else: - for obj in queryset: - component = obj.instantiate(device=self) + components = [obj.instantiate(device=self) for obj in queryset] + if not components: + return + + # Check for duplicate names after resolution {vc_position} + self._check_duplicate_component_names(components) + + for component in components: # Set default values for any applicable custom fields if cf_defaults := CustomField.objects.get_defaults_for_model(model): component.custom_field_data = cf_defaults diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 118c347fd..a11481d8a 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -11,6 +11,7 @@ from dcim.choices import ( from dcim.forms import * from dcim.models import * from ipam.models import VLAN +from utilities.exceptions import AbortRequest from utilities.testing import create_test_device from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -175,6 +176,88 @@ class DeviceTestCase(TestCase): self.assertIn('position', form.errors) +class VCPositionTokenFormTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + Site.objects.create(name='Site VC 1', slug='site-vc-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer VC 1', slug='manufacturer-vc-1') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type VC 1', slug='device-type-vc-1' + ) + DeviceRole.objects.create(name='Device Role VC 1', slug='device-role-vc-1', color='ff0000') + InterfaceTemplate.objects.create( + device_type=device_type, + name='ge-{vc_position:0}/0/0', + type='1000base-t', + ) + VirtualChassis.objects.create(name='VC 1') + + def test_device_creation_in_vc_resolves_vc_position(self): + form = DeviceForm(data={ + 'name': 'Device VC Form 1', + 'role': DeviceRole.objects.first().pk, + 'tenant': None, + 'manufacturer': Manufacturer.objects.first().pk, + 'device_type': DeviceType.objects.first().pk, + 'site': Site.objects.first().pk, + 'rack': None, + 'face': None, + 'position': None, + 'platform': None, + 'status': DeviceStatusChoices.STATUS_ACTIVE, + 'virtual_chassis': VirtualChassis.objects.first().pk, + 'vc_position': 2, + }) + self.assertTrue(form.is_valid()) + device = form.save() + self.assertTrue(device.interfaces.filter(name='ge-2/0/0').exists()) + + def test_device_creation_not_in_vc_uses_fallback(self): + form = DeviceForm(data={ + 'name': 'Device VC Form 2', + 'role': DeviceRole.objects.first().pk, + 'tenant': None, + 'manufacturer': Manufacturer.objects.first().pk, + 'device_type': DeviceType.objects.first().pk, + 'site': Site.objects.first().pk, + 'rack': None, + 'face': None, + 'position': None, + 'platform': None, + 'status': DeviceStatusChoices.STATUS_ACTIVE, + }) + self.assertTrue(form.is_valid()) + device = form.save() + self.assertTrue(device.interfaces.filter(name='ge-0/0/0').exists()) + + def test_device_creation_duplicate_name_conflict(self): + # With conflict + device_type = DeviceType.objects.first() + # to generate conflicts create an interface that will exist + InterfaceTemplate.objects.create( + device_type=device_type, + name='ge-0/0/0', + type='1000base-t', + ) + form = DeviceForm(data={ + 'name': 'Device VC Form 3', + 'role': DeviceRole.objects.first().pk, + 'tenant': None, + 'manufacturer': Manufacturer.objects.first().pk, + 'device_type': device_type.pk, + 'site': Site.objects.first().pk, + 'rack': None, + 'face': None, + 'position': None, + 'platform': None, + 'status': DeviceStatusChoices.STATUS_ACTIVE, + }) + self.assertTrue(form.is_valid()) + with self.assertRaises(AbortRequest): + form.save() + + class FrontPortTestCase(TestCase): @classmethod diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 45e07f257..329aa0785 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1373,6 +1373,167 @@ class VirtualChassisTestCase(TestCase): device2.full_clean() +class VCPositionTokenTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + ModuleType.objects.create( + manufacturer=manufacturer, model='Test Module Type 1' + ) + DeviceRole.objects.create(name='Test Role 1', slug='test-role-1') + + def test_vc_position_token_in_vc(self): + site = Site.objects.first() + device_type = DeviceType.objects.first() + module_type = ModuleType.objects.first() + device_role = DeviceRole.objects.first() + + InterfaceTemplate.objects.create( + module_type=module_type, + name='ge-{vc_position}/{module}/0', + type='1000base-t', + ) + vc = VirtualChassis.objects.create(name='Test VC 1') + device = Device.objects.create( + name='Device VC 1', device_type=device_type, role=device_role, + site=site, virtual_chassis=vc, vc_position=8, + ) + module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1') + Module.objects.create(device=device, module_bay=module_bay, module_type=module_type) + + interface = device.interfaces.get(name='ge-8/1/0') + self.assertEqual(interface.name, 'ge-8/1/0') + + def test_vc_position_token_not_in_vc_default_fallback(self): + site = Site.objects.first() + device_type = DeviceType.objects.first() + module_type = ModuleType.objects.first() + device_role = DeviceRole.objects.first() + + InterfaceTemplate.objects.create( + module_type=module_type, + name='ge-{vc_position}/{module}/0', + type='1000base-t', + ) + device = Device.objects.create( + name='Device NoVC 1', device_type=device_type, role=device_role, + site=site, + ) + module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1') + Module.objects.create(device=device, module_bay=module_bay, module_type=module_type) + + interface = device.interfaces.get(name='ge-0/1/0') + self.assertEqual(interface.name, 'ge-0/1/0') + + def test_vc_position_token_explicit_fallback(self): + site = Site.objects.first() + device_type = DeviceType.objects.first() + module_type = ModuleType.objects.first() + device_role = DeviceRole.objects.first() + + InterfaceTemplate.objects.create( + module_type=module_type, + name='ge-{vc_position:18}/{module}/0', + type='1000base-t', + ) + device = Device.objects.create( + name='Device NoVC 2', device_type=device_type, role=device_role, + site=site, + ) + module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1') + Module.objects.create(device=device, module_bay=module_bay, module_type=module_type) + + interface = device.interfaces.get(name='ge-18/1/0') + self.assertEqual(interface.name, 'ge-18/1/0') + + def test_vc_position_token_explicit_fallback_ignored_when_in_vc(self): + site = Site.objects.first() + device_type = DeviceType.objects.first() + module_type = ModuleType.objects.first() + device_role = DeviceRole.objects.first() + + InterfaceTemplate.objects.create( + module_type=module_type, + name='ge-{vc_position:99}/{module}/0', + type='1000base-t', + ) + vc = VirtualChassis.objects.create(name='Test VC 2') + device = Device.objects.create( + name='Device VC 2', device_type=device_type, role=device_role, + site=site, virtual_chassis=vc, vc_position=2, + ) + module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1') + Module.objects.create(device=device, module_bay=module_bay, module_type=module_type) + + interface = device.interfaces.get(name='ge-2/1/0') + self.assertEqual(interface.name, 'ge-2/1/0') + + def test_vc_position_token_device_type_template(self): + site = Site.objects.first() + device_type = DeviceType.objects.first() + device_role = DeviceRole.objects.first() + + InterfaceTemplate.objects.create( + device_type=device_type, + name='ge-{vc_position:0}/0/0', + type='1000base-t', + ) + vc = VirtualChassis.objects.create(name='Test VC 3') + device = Device.objects.create( + name='Device VC 3', device_type=device_type, role=device_role, + site=site, virtual_chassis=vc, vc_position=3, + ) + + interface = device.interfaces.get(name='ge-3/0/0') + self.assertEqual(interface.name, 'ge-3/0/0') + + def test_vc_position_token_device_type_template_not_in_vc(self): + site = Site.objects.first() + device_type = DeviceType.objects.first() + device_role = DeviceRole.objects.first() + + InterfaceTemplate.objects.create( + device_type=device_type, + name='ge-{vc_position:0}/0/0', + type='1000base-t', + ) + device = Device.objects.create( + name='Device NoVC 3', device_type=device_type, role=device_role, + site=site, + ) + + interface = device.interfaces.get(name='ge-0/0/0') + self.assertEqual(interface.name, 'ge-0/0/0') + + def test_vc_position_token_label_resolution(self): + site = Site.objects.first() + device_type = DeviceType.objects.first() + module_type = ModuleType.objects.first() + device_role = DeviceRole.objects.first() + + InterfaceTemplate.objects.create( + module_type=module_type, + name='ge-{vc_position}/{module}/0', + label='Member {vc_position:0} / Slot {module}', + type='1000base-t', + ) + vc = VirtualChassis.objects.create(name='Test VC 4') + device = Device.objects.create( + name='Device VC 4', device_type=device_type, role=device_role, + site=site, virtual_chassis=vc, vc_position=2, + ) + module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1') + Module.objects.create(device=device, module_bay=module_bay, module_type=module_type) + + interface = device.interfaces.get(name='ge-2/1/0') + self.assertEqual(interface.label, 'Member 2 / Slot 1') + + class SiteSignalTestCase(TestCase): @tag('regression')