Closes #21575: Implement {vc_position} template variable on component template name/label (#21601)

This commit is contained in:
Étienne Brunel
2026-03-18 18:15:11 +01:00
committed by GitHub
parent 6030fc383a
commit 1f336eee2e
8 changed files with 366 additions and 37 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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',

View File

@@ -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: <code>[ge,xe]-0/0/[0-9]</code>). The token <code>{module}</code>, 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 <code>{vc_position}</code> will be replaced with the device's Virtual Chassis position "
"(use <code>{vc_position:1}</code> to specify a fallback (default is 0))"
)

View File

@@ -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
)

View File

@@ -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

View File

@@ -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

View File

@@ -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')