Просмотр исходного кода

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

Étienne Brunel 1 день назад
Родитель
Сommit
1f336eee2e

+ 12 - 0
docs/models/dcim/devicetype.md

@@ -7,6 +7,18 @@ Device types are instantiated as devices installed within sites and/or equipment
 !!! note
 !!! 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.
     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
 ## Fields
 
 
 ### Manufacturer
 ### Manufacturer

+ 10 - 0
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]`.
 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).
 Automatic renaming is supported for all modular component types (those listed above).
 
 
 ## Fields
 ## Fields

+ 3 - 0
netbox/dcim/constants.py

@@ -1,3 +1,5 @@
+import re
+
 from django.db.models import Q
 from django.db.models import Q
 
 
 from .choices import InterfaceTypeChoices
 from .choices import InterfaceTypeChoices
@@ -79,6 +81,7 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
 #
 #
 
 
 MODULE_TOKEN = '{module}'
 MODULE_TOKEN = '{module}'
+VC_POSITION_RE = re.compile(r'\{vc_position(?::([^}]*))?\}')
 
 
 MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
 MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
     app_label='dcim',
     app_label='dcim',

+ 3 - 1
netbox/dcim/forms/model_forms.py

@@ -1072,7 +1072,9 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
         self.fields['name'].help_text = _(
         self.fields['name'].help_text = _(
             "Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range are not "
             "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 "
             "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))"
         )
         )
 
 
 
 

+ 66 - 33
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.")
                 _("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):
     def _get_module_tree(self, module):
         modules = []
         modules = []
         while module:
         while module:
@@ -177,29 +197,42 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
         modules.reverse()
         modules.reverse()
         return modules
         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
             return self.name
 
 
-        if module:
+        name = self.name
+
+        if has_module and module:
             modules = self._get_module_tree(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
+            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):
-        if MODULE_TOKEN not in self.label:
+    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
             return self.label
 
 
-        if module:
+        label = self.label
+
+        if has_module and module:
             modules = self._get_module_tree(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):
 class ConsolePortTemplate(ModularComponentTemplateModel):
@@ -222,8 +255,8 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         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,
             type=self.type,
             **kwargs
             **kwargs
         )
         )
@@ -257,8 +290,8 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         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,
             type=self.type,
             **kwargs
             **kwargs
         )
         )
@@ -307,8 +340,8 @@ class PowerPortTemplate(ModularComponentTemplateModel):
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         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,
             type=self.type,
             maximum_draw=self.maximum_draw,
             maximum_draw=self.maximum_draw,
             allocated_draw=self.allocated_draw,
             allocated_draw=self.allocated_draw,
@@ -395,13 +428,13 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         if self.power_port:
         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)
             power_port = PowerPort.objects.get(name=power_port_name, **kwargs)
         else:
         else:
             power_port = None
             power_port = None
         return self.component_model(
         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,
             type=self.type,
             color=self.color,
             color=self.color,
             power_port=power_port,
             power_port=power_port,
@@ -501,8 +534,8 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         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,
             type=self.type,
             enabled=self.enabled,
             enabled=self.enabled,
             mgmt_only=self.mgmt_only,
             mgmt_only=self.mgmt_only,
@@ -628,8 +661,8 @@ class FrontPortTemplate(ModularComponentTemplateModel):
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         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,
             type=self.type,
             color=self.color,
             color=self.color,
             positions=self.positions,
             positions=self.positions,
@@ -692,8 +725,8 @@ class RearPortTemplate(ModularComponentTemplateModel):
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         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,
             type=self.type,
             color=self.color,
             color=self.color,
             positions=self.positions,
             positions=self.positions,
@@ -731,8 +764,8 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         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,
             position=self.position,
             **kwargs
             **kwargs
         )
         )

+ 27 - 2
netbox/dcim/models/devices.py

@@ -26,6 +26,7 @@ from netbox.config import ConfigItem
 from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from netbox.models.mixins import WeightMixin
 from netbox.models.mixins import WeightMixin
+from utilities.exceptions import AbortRequest
 from utilities.fields import ColorField, CounterCacheField
 from utilities.fields import ColorField, CounterCacheField
 from utilities.prefetch import get_prefetchable_fields
 from utilities.prefetch import get_prefetchable_fields
 from utilities.tracking import TrackingModelMixin
 from utilities.tracking import TrackingModelMixin
@@ -948,6 +949,20 @@ class Device(
                 ).format(virtual_chassis=self.vc_master_for)
                 ).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):
     def _instantiate_components(self, queryset, bulk_create=True):
         """
         """
         Instantiate components for the device from the specified component templates.
         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]
             components = [obj.instantiate(device=self) for obj in queryset]
             if not components:
             if not components:
                 return
                 return
+
+            # Check for duplicate names after resolution {vc_position}
+            self._check_duplicate_component_names(components)
+
             # Set default values for any applicable custom fields
             # Set default values for any applicable custom fields
             if cf_defaults := CustomField.objects.get_defaults_for_model(model):
             if cf_defaults := CustomField.objects.get_defaults_for_model(model):
                 for component in components:
                 for component in components:
@@ -986,8 +1005,14 @@ class Device(
                     update_fields=None
                     update_fields=None
                 )
                 )
         else:
         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
                 # Set default values for any applicable custom fields
                 if cf_defaults := CustomField.objects.get_defaults_for_model(model):
                 if cf_defaults := CustomField.objects.get_defaults_for_model(model):
                     component.custom_field_data = cf_defaults
                     component.custom_field_data = cf_defaults

+ 83 - 0
netbox/dcim/tests/test_forms.py

@@ -11,6 +11,7 @@ from dcim.choices import (
 from dcim.forms import *
 from dcim.forms import *
 from dcim.models import *
 from dcim.models import *
 from ipam.models import VLAN
 from ipam.models import VLAN
+from utilities.exceptions import AbortRequest
 from utilities.testing import create_test_device
 from utilities.testing import create_test_device
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
@@ -175,6 +176,88 @@ class DeviceTestCase(TestCase):
         self.assertIn('position', form.errors)
         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):
 class FrontPortTestCase(TestCase):
 
 
     @classmethod
     @classmethod

+ 161 - 0
netbox/dcim/tests/test_models.py

@@ -1373,6 +1373,167 @@ class VirtualChassisTestCase(TestCase):
             device2.full_clean()
             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):
 class SiteSignalTestCase(TestCase):
 
 
     @tag('regression')
     @tag('regression')