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

Merge pull request #4723 from jsenecal/4615_interface_label

Closes: #4615 Physical labels
Jeremy Stretch 5 лет назад
Родитель
Сommit
9b48a26aef

+ 12 - 12
netbox/dcim/api/serializers.py

@@ -249,7 +249,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
 
     class Meta:
         model = ConsolePortTemplate
-        fields = ['id', 'device_type', 'name', 'type']
+        fields = ['id', 'device_type', 'name', 'label', 'type']
 
 
 class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
@@ -262,7 +262,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
 
     class Meta:
         model = ConsoleServerPortTemplate
-        fields = ['id', 'device_type', 'name', 'type']
+        fields = ['id', 'device_type', 'name', 'label', 'type']
 
 
 class PowerPortTemplateSerializer(ValidatedModelSerializer):
@@ -275,7 +275,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
 
     class Meta:
         model = PowerPortTemplate
-        fields = ['id', 'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw']
+        fields = ['id', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw']
 
 
 class PowerOutletTemplateSerializer(ValidatedModelSerializer):
@@ -296,7 +296,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
 
     class Meta:
         model = PowerOutletTemplate
-        fields = ['id', 'device_type', 'name', 'type', 'power_port', 'feed_leg']
+        fields = ['id', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg']
 
 
 class InterfaceTemplateSerializer(ValidatedModelSerializer):
@@ -305,7 +305,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
 
     class Meta:
         model = InterfaceTemplate
-        fields = ['id', 'device_type', 'name', 'type', 'mgmt_only']
+        fields = ['id', 'device_type', 'name', 'label', 'type', 'mgmt_only']
 
 
 class RearPortTemplateSerializer(ValidatedModelSerializer):
@@ -332,7 +332,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
 
     class Meta:
         model = DeviceBayTemplate
-        fields = ['id', 'device_type', 'name']
+        fields = ['id', 'device_type', 'name', 'label']
 
 
 #
@@ -447,7 +447,7 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
     class Meta:
         model = ConsoleServerPort
         fields = [
-            'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
+            'id', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
             'connection_status', 'cable', 'tags',
         ]
 
@@ -465,7 +465,7 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     class Meta:
         model = ConsolePort
         fields = [
-            'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
+            'id', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
             'connection_status', 'cable', 'tags',
         ]
 
@@ -495,7 +495,7 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     class Meta:
         model = PowerOutlet
         fields = [
-            'id', 'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type',
+            'id', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type',
             'connected_endpoint', 'connection_status', 'cable', 'tags',
         ]
 
@@ -513,7 +513,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     class Meta:
         model = PowerPort
         fields = [
-            'id', 'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type',
+            'id', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type',
             'connected_endpoint', 'connection_status', 'cable', 'tags',
         ]
 
@@ -537,7 +537,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     class Meta:
         model = Interface
         fields = [
-            'id', 'device', 'name', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
+            'id', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
             'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan',
             'tagged_vlans', 'tags', 'count_ipaddresses',
         ]
@@ -604,7 +604,7 @@ class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer):
 
     class Meta:
         model = DeviceBay
-        fields = ['id', 'device', 'name', 'description', 'installed_device', 'tags']
+        fields = ['id', 'device', 'name', 'label', 'description', 'installed_device', 'tags']
 
 
 #

+ 27 - 60
netbox/dcim/forms.py

@@ -22,10 +22,10 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
     APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
-    BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField,
-    CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model,
-    JSONField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
-    BOOLEAN_WITH_BLANK_CHOICES,
+    BOOLEAN_WITH_BLANK_CHOICES, BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField,
+    CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
+    form_from_model, JSONField, LabeledComponentForm, SelectWithPK, SmallTextarea, SlugField, StaticSelect2,
+    StaticSelect2Multiple, TagFilterField,
 )
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine
 from .choices import *
@@ -1037,20 +1037,17 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = ConsolePortTemplate
         fields = [
-            'device_type', 'name', 'type',
+            'device_type', 'name', 'label', 'type',
         ]
         widgets = {
             'device_type': forms.HiddenInput(),
         }
 
 
-class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form):
+class ConsolePortTemplateCreateForm(LabeledComponentForm):
     device_type = DynamicModelChoiceField(
         queryset=DeviceType.objects.all()
     )
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
     type = forms.ChoiceField(
         choices=add_blank_choice(ConsolePortTypeChoices),
         widget=StaticSelect2()
@@ -1077,20 +1074,17 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = ConsoleServerPortTemplate
         fields = [
-            'device_type', 'name', 'type',
+            'device_type', 'name', 'label', 'type',
         ]
         widgets = {
             'device_type': forms.HiddenInput(),
         }
 
 
-class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form):
+class ConsoleServerPortTemplateCreateForm(LabeledComponentForm):
     device_type = DynamicModelChoiceField(
         queryset=DeviceType.objects.all()
     )
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
     type = forms.ChoiceField(
         choices=add_blank_choice(ConsolePortTypeChoices),
         widget=StaticSelect2()
@@ -1117,20 +1111,17 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = PowerPortTemplate
         fields = [
-            'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw',
+            'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
         ]
         widgets = {
             'device_type': forms.HiddenInput(),
         }
 
 
-class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form):
+class PowerPortTemplateCreateForm(LabeledComponentForm):
     device_type = DynamicModelChoiceField(
         queryset=DeviceType.objects.all()
     )
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
     type = forms.ChoiceField(
         choices=add_blank_choice(PowerPortTypeChoices),
         required=False
@@ -1177,7 +1168,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = PowerOutletTemplate
         fields = [
-            'device_type', 'name', 'type', 'power_port', 'feed_leg',
+            'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
         ]
         widgets = {
             'device_type': forms.HiddenInput(),
@@ -1194,13 +1185,10 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
             )
 
 
-class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form):
+class PowerOutletTemplateCreateForm(LabeledComponentForm):
     device_type = DynamicModelChoiceField(
         queryset=DeviceType.objects.all()
     )
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
     type = forms.ChoiceField(
         choices=add_blank_choice(PowerOutletTypeChoices),
         required=False
@@ -1252,7 +1240,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = InterfaceTemplate
         fields = [
-            'device_type', 'name', 'type', 'mgmt_only',
+            'device_type', 'name', 'label', 'type', 'mgmt_only',
         ]
         widgets = {
             'device_type': forms.HiddenInput(),
@@ -1260,13 +1248,10 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form):
+class InterfaceTemplateCreateForm(LabeledComponentForm):
     device_type = DynamicModelChoiceField(
         queryset=DeviceType.objects.all()
     )
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
     type = forms.ChoiceField(
         choices=InterfaceTypeChoices,
         widget=StaticSelect2()
@@ -1509,7 +1494,7 @@ class ConsolePortTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
         model = ConsolePortTemplate
         fields = [
-            'device_type', 'name', 'type',
+            'device_type', 'name', 'label', 'type',
         ]
 
 
@@ -1518,7 +1503,7 @@ class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
         model = ConsoleServerPortTemplate
         fields = [
-            'device_type', 'name', 'type',
+            'device_type', 'name', 'label', 'type',
         ]
 
 
@@ -1527,7 +1512,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
         model = PowerPortTemplate
         fields = [
-            'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw',
+            'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
         ]
 
 
@@ -1541,7 +1526,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
         model = PowerOutletTemplate
         fields = [
-            'device_type', 'name', 'type', 'power_port', 'feed_leg',
+            'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
         ]
 
 
@@ -1553,7 +1538,7 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
         model = InterfaceTemplate
         fields = [
-            'device_type', 'name', 'type', 'mgmt_only',
+            'device_type', 'name', 'label', 'type', 'mgmt_only',
         ]
 
 
@@ -2196,14 +2181,11 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
 # Bulk device component creation
 #
 
-class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
+class DeviceBulkAddComponentForm(LabeledComponentForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Device.objects.all(),
         widget=forms.MultipleHiddenInput()
     )
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
 
     def clean_tags(self):
         # Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we
@@ -2234,20 +2216,17 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = ConsolePort
         fields = [
-            'device', 'name', 'type', 'description', 'tags',
+            'device', 'name', 'label', 'type', 'description', 'tags',
         ]
         widgets = {
             'device': forms.HiddenInput(),
         }
 
 
-class ConsolePortCreateForm(BootstrapMixin, forms.Form):
+class ConsolePortCreateForm(LabeledComponentForm):
     device = DynamicModelChoiceField(
         queryset=Device.objects.prefetch_related('device_type__manufacturer')
     )
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
     type = forms.ChoiceField(
         choices=add_blank_choice(ConsolePortTypeChoices),
         required=False,
@@ -2327,13 +2306,10 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form):
+class ConsoleServerPortCreateForm(LabeledComponentForm):
     device = DynamicModelChoiceField(
         queryset=Device.objects.prefetch_related('device_type__manufacturer')
     )
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
     type = forms.ChoiceField(
         choices=add_blank_choice(ConsolePortTypeChoices),
         required=False,
@@ -2427,13 +2403,10 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class PowerPortCreateForm(BootstrapMixin, forms.Form):
+class PowerPortCreateForm(LabeledComponentForm):
     device = DynamicModelChoiceField(
         queryset=Device.objects.prefetch_related('device_type__manufacturer')
     )
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
     type = forms.ChoiceField(
         choices=add_blank_choice(PowerPortTypeChoices),
         required=False,
@@ -2536,13 +2509,10 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
             )
 
 
-class PowerOutletCreateForm(BootstrapMixin, forms.Form):
+class PowerOutletCreateForm(LabeledComponentForm):
     device = DynamicModelChoiceField(
         queryset=Device.objects.prefetch_related('device_type__manufacturer')
     )
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
     type = forms.ChoiceField(
         choices=add_blank_choice(PowerOutletTypeChoices),
         required=False,
@@ -2726,7 +2696,7 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
     class Meta:
         model = Interface
         fields = [
-            'device', 'name', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
+            'device', 'name', 'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
             'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
         ]
         widgets = {
@@ -2761,13 +2731,10 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
         self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
 
 
-class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
+class InterfaceCreateForm(InterfaceCommonForm, LabeledComponentForm):
     device = DynamicModelChoiceField(
         queryset=Device.objects.prefetch_related('device_type__manufacturer')
     )
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
     type = forms.ChoiceField(
         choices=InterfaceTypeChoices,
         widget=StaticSelect2(),

+ 73 - 0
netbox/dcim/migrations/0107_component_labels.py

@@ -0,0 +1,73 @@
+# Generated by Django 3.0.7 on 2020-06-04 20:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0106_role_default_color'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interface',
+            name='label',
+            field=models.CharField(blank=True, max_length=64),
+        ),
+        migrations.AddField(
+            model_name='interfacetemplate',
+            name='label',
+            field=models.CharField(blank=True, max_length=64),
+        ),
+        migrations.AddField(
+            model_name='consoleport',
+            name='label',
+            field=models.CharField(blank=True, max_length=64),
+        ),
+        migrations.AddField(
+            model_name='consoleporttemplate',
+            name='label',
+            field=models.CharField(blank=True, max_length=64),
+        ),
+        migrations.AddField(
+            model_name='consoleserverport',
+            name='label',
+            field=models.CharField(blank=True, max_length=64),
+        ),
+        migrations.AddField(
+            model_name='consoleserverporttemplate',
+            name='label',
+            field=models.CharField(blank=True, max_length=64),
+        ),
+        migrations.AddField(
+            model_name='poweroutlet',
+            name='label',
+            field=models.CharField(blank=True, max_length=64),
+        ),
+        migrations.AddField(
+            model_name='poweroutlettemplate',
+            name='label',
+            field=models.CharField(blank=True, max_length=64),
+        ),
+        migrations.AddField(
+            model_name='powerport',
+            name='label',
+            field=models.CharField(blank=True, max_length=64),
+        ),
+        migrations.AddField(
+            model_name='powerporttemplate',
+            name='label',
+            field=models.CharField(blank=True, max_length=64),
+        ),
+        migrations.AddField(
+            model_name='devicebay',
+            name='label',
+            field=models.CharField(blank=True, max_length=64),
+        ),
+        migrations.AddField(
+            model_name='devicebaytemplate',
+            name='label',
+            field=models.CharField(blank=True, max_length=64),
+        ),
+    ]

+ 35 - 18
netbox/dcim/models/device_component_templates.py

@@ -32,6 +32,11 @@ class ComponentTemplateModel(models.Model):
     class Meta:
         abstract = True
 
+    def __str__(self):
+        if self.label:
+            return f"{self.name} ({self.label})"
+        return self.name
+
     def instantiate(self, device):
         """
         Instantiate a new component on the specified Device.
@@ -71,6 +76,11 @@ class ConsolePortTemplate(ComponentTemplateModel):
         max_length=100,
         blank=True
     )
+    label = models.CharField(
+        max_length=64,
+        blank=True,
+        help_text="Physical label"
+    )
     type = models.CharField(
         max_length=50,
         choices=ConsolePortTypeChoices,
@@ -81,9 +91,6 @@ class ConsolePortTemplate(ComponentTemplateModel):
         ordering = ('device_type', '_name')
         unique_together = ('device_type', 'name')
 
-    def __str__(self):
-        return self.name
-
     def instantiate(self, device):
         return ConsolePort(
             device=device,
@@ -109,6 +116,11 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
         max_length=100,
         blank=True
     )
+    label = models.CharField(
+        max_length=64,
+        blank=True,
+        help_text="Physical label"
+    )
     type = models.CharField(
         max_length=50,
         choices=ConsolePortTypeChoices,
@@ -119,9 +131,6 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
         ordering = ('device_type', '_name')
         unique_together = ('device_type', 'name')
 
-    def __str__(self):
-        return self.name
-
     def instantiate(self, device):
         return ConsoleServerPort(
             device=device,
@@ -147,6 +156,11 @@ class PowerPortTemplate(ComponentTemplateModel):
         max_length=100,
         blank=True
     )
+    label = models.CharField(
+        max_length=64,
+        blank=True,
+        help_text="Physical label"
+    )
     type = models.CharField(
         max_length=50,
         choices=PowerPortTypeChoices,
@@ -169,9 +183,6 @@ class PowerPortTemplate(ComponentTemplateModel):
         ordering = ('device_type', '_name')
         unique_together = ('device_type', 'name')
 
-    def __str__(self):
-        return self.name
-
     def instantiate(self, device):
         return PowerPort(
             device=device,
@@ -199,6 +210,11 @@ class PowerOutletTemplate(ComponentTemplateModel):
         max_length=100,
         blank=True
     )
+    label = models.CharField(
+        max_length=64,
+        blank=True,
+        help_text="Physical label"
+    )
     type = models.CharField(
         max_length=50,
         choices=PowerOutletTypeChoices,
@@ -222,9 +238,6 @@ class PowerOutletTemplate(ComponentTemplateModel):
         ordering = ('device_type', '_name')
         unique_together = ('device_type', 'name')
 
-    def __str__(self):
-        return self.name
-
     def clean(self):
 
         # Validate power port assignment
@@ -265,6 +278,11 @@ class InterfaceTemplate(ComponentTemplateModel):
         max_length=100,
         blank=True
     )
+    label = models.CharField(
+        max_length=64,
+        blank=True,
+        help_text="Physical label"
+    )
     type = models.CharField(
         max_length=50,
         choices=InterfaceTypeChoices
@@ -278,9 +296,6 @@ class InterfaceTemplate(ComponentTemplateModel):
         ordering = ('device_type', '_name')
         unique_together = ('device_type', 'name')
 
-    def __str__(self):
-        return self.name
-
     def instantiate(self, device):
         return Interface(
             device=device,
@@ -420,14 +435,16 @@ class DeviceBayTemplate(ComponentTemplateModel):
         max_length=100,
         blank=True
     )
+    label = models.CharField(
+        max_length=64,
+        blank=True,
+        help_text="Physical label"
+    )
 
     class Meta:
         ordering = ('device_type', '_name')
         unique_together = ('device_type', 'name')
 
-    def __str__(self):
-        return self.name
-
     def instantiate(self, device):
         return DeviceBay(
             device=device,

+ 37 - 15
netbox/dcim/models/device_components.py

@@ -47,6 +47,11 @@ class ComponentModel(models.Model):
     class Meta:
         abstract = True
 
+    def __str__(self):
+        if self.label:
+            return f"{self.name} ({self.label})"
+        return self.name
+
     def to_objectchange(self, action):
         # Annotate the parent Device/VM
         try:
@@ -234,6 +239,11 @@ class ConsolePort(CableTermination, ComponentModel):
     name = models.CharField(
         max_length=50
     )
+    label = models.CharField(
+        max_length=64,
+        blank=True,
+        help_text="Physical label"
+    )
     _name = NaturalOrderingField(
         target_field='name',
         max_length=100,
@@ -264,9 +274,6 @@ class ConsolePort(CableTermination, ComponentModel):
         ordering = ('device', '_name')
         unique_together = ('device', 'name')
 
-    def __str__(self):
-        return self.name
-
     def get_absolute_url(self):
         return self.device.get_absolute_url()
 
@@ -301,6 +308,11 @@ class ConsoleServerPort(CableTermination, ComponentModel):
         max_length=100,
         blank=True
     )
+    label = models.CharField(
+        max_length=64,
+        blank=True,
+        help_text="Physical label"
+    )
     type = models.CharField(
         max_length=50,
         choices=ConsolePortTypeChoices,
@@ -319,9 +331,6 @@ class ConsoleServerPort(CableTermination, ComponentModel):
         ordering = ('device', '_name')
         unique_together = ('device', 'name')
 
-    def __str__(self):
-        return self.name
-
     def get_absolute_url(self):
         return self.device.get_absolute_url()
 
@@ -356,6 +365,11 @@ class PowerPort(CableTermination, ComponentModel):
         max_length=100,
         blank=True
     )
+    label = models.CharField(
+        max_length=64,
+        blank=True,
+        help_text="Physical label"
+    )
     type = models.CharField(
         max_length=50,
         choices=PowerPortTypeChoices,
@@ -400,9 +414,6 @@ class PowerPort(CableTermination, ComponentModel):
         ordering = ('device', '_name')
         unique_together = ('device', 'name')
 
-    def __str__(self):
-        return self.name
-
     def get_absolute_url(self):
         return self.device.get_absolute_url()
 
@@ -519,6 +530,11 @@ class PowerOutlet(CableTermination, ComponentModel):
         max_length=100,
         blank=True
     )
+    label = models.CharField(
+        max_length=64,
+        blank=True,
+        help_text="Physical label"
+    )
     type = models.CharField(
         max_length=50,
         choices=PowerOutletTypeChoices,
@@ -550,9 +566,6 @@ class PowerOutlet(CableTermination, ComponentModel):
         ordering = ('device', '_name')
         unique_together = ('device', 'name')
 
-    def __str__(self):
-        return self.name
-
     def get_absolute_url(self):
         return self.device.get_absolute_url()
 
@@ -608,6 +621,11 @@ class Interface(CableTermination, ComponentModel):
         max_length=100,
         blank=True
     )
+    label = models.CharField(
+        max_length=64,
+        blank=True,
+        help_text="Physical label"
+    )
     _connected_interface = models.OneToOneField(
         to='self',
         on_delete=models.SET_NULL,
@@ -688,9 +706,6 @@ class Interface(CableTermination, ComponentModel):
         ordering = ('device', CollateAsChar('_name'))
         unique_together = ('device', 'name')
 
-    def __str__(self):
-        return self.name
-
     def get_absolute_url(self):
         return reverse('dcim:interface', kwargs={'pk': self.pk})
 
@@ -997,6 +1012,11 @@ class DeviceBay(ComponentModel):
         max_length=100,
         blank=True
     )
+    label = models.CharField(
+        max_length=64,
+        blank=True,
+        help_text="Physical label"
+    )
     installed_device = models.OneToOneField(
         to='dcim.Device',
         on_delete=models.SET_NULL,
@@ -1013,6 +1033,8 @@ class DeviceBay(ComponentModel):
         unique_together = ('device', 'name')
 
     def __str__(self):
+        if self.label:
+            return '{} - {} ({})'.format(self.device.name, self.name, self.label)
         return '{} - {}'.format(self.device.name, self.name)
 
     def get_absolute_url(self):

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

@@ -116,3 +116,45 @@ class DeviceTestCase(TestCase):
 
         # Check that the initial value for the cluster group is set automatically when assigning the cluster
         self.assertEqual(test.initial['cluster_group'], cluster.group.pk)
+
+
+class LabelTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        site = Site.objects.create(name='Site 2', slug='site-2')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 2', slug='manufacturer-2')
+        cls.device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=1
+        )
+        device_role = DeviceRole.objects.create(
+            name='Device Role 2', slug='device-role-2', color='ffff00'
+        )
+        cls.device = Device.objects.create(
+            name='Device 2', device_type=cls.device_type, device_role=device_role, site=site
+        )
+
+    def test_interface_label_count_valid(self):
+        """Test that a `label` can be generated for each generated `name` from `name_pattern` on InterfaceCreateForm"""
+        interface_data = {
+            'device': self.device.pk,
+            'name_pattern': 'eth[0-9]',
+            'label_pattern': 'Interface[0-9]',
+            'type': InterfaceTypeChoices.TYPE_100ME_FIXED,
+        }
+        form = InterfaceCreateForm(interface_data)
+
+        self.assertTrue(form.is_valid())
+
+    def test_interface_label_count_mismatch(self):
+        """Test that a `label` cannot be generated for each generated `name` from `name_pattern` due to invalid `label_pattern` on InterfaceCreateForm"""
+        bad_interface_data = {
+            'device': self.device.pk,
+            'name_pattern': 'eth[0-9]',
+            'label_pattern': 'Interface[0-1]',
+            'type': InterfaceTypeChoices.TYPE_100ME_FIXED,
+        }
+        form = InterfaceCreateForm(bad_interface_data)
+
+        self.assertFalse(form.is_valid())
+        self.assertIn('label_pattern', form.errors)

+ 4 - 0
netbox/dcim/tests/test_views.py

@@ -710,6 +710,8 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
         cls.bulk_create_data = {
             'device_type': devicetypes[1].pk,
             'name_pattern': 'Interface Template [4-6]',
+            # Test that a label can be applied to each generated interface templates
+            'label_pattern': 'Interface Template Label [3-5]',
             'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
             'mgmt_only': True,
         }
@@ -1010,6 +1012,8 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
         cls.bulk_create_data = {
             'device': device.pk,
             'name_pattern': 'Console Port [4-6]',
+            # Test that a label can be applied to each generated console ports
+            'label_pattern': 'Serial[3-5]',
             'type': ConsolePortTypeChoices.TYPE_RJ45,
             'description': 'A console port',
             'tags': 'Alpha,Bravo,Charlie',

+ 4 - 0
netbox/templates/dcim/interface.html

@@ -58,6 +58,10 @@
                     <td>Name</td>
                     <td>{{ interface.name }}</td>
                 </tr>
+                <tr>
+                    <td>Label</td>
+                    <td>{{ interface.label|placeholder }}</td>
+                </tr>
                 <tr>
                     <td>Type</td>
                     <td>{{ interface.get_type_display }}</td>

+ 1 - 0
netbox/templates/dcim/interface_edit.html

@@ -6,6 +6,7 @@
         <div class="panel-heading"><strong>Interface</strong></div>
         <div class="panel-body">
             {% render_field form.name %}
+            {% render_field form.label %}
             {% render_field form.type %}
             {% render_field form.enabled %}
             {% render_field form.lag %}

+ 27 - 0
netbox/utilities/forms.py

@@ -530,6 +530,8 @@ class ExpandableNameField(forms.CharField):
                 """
 
     def to_python(self, value):
+        if value is None:
+            return list()
         if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value):
             return list(expand_alphanumeric_pattern(value))
         return [value]
@@ -802,6 +804,31 @@ class ImportForm(BootstrapMixin, forms.Form):
                 })
 
 
+class LabeledComponentForm(BootstrapMixin, forms.Form):
+    """
+    Base form for adding label pattern validation to `Create` forms
+    """
+    name_pattern = ExpandableNameField(
+        label='Name'
+    )
+    label_pattern = ExpandableNameField(
+        label='Label',
+        required=False
+    )
+
+    def clean(self):
+
+        # Validate that the number of components being created from both the name_pattern and label_pattern are equal
+        name_pattern_count = len(self.cleaned_data['name_pattern'])
+        label_pattern_count = len(self.cleaned_data['label_pattern'])
+        if label_pattern_count and name_pattern_count != label_pattern_count:
+            raise forms.ValidationError({
+                'label_pattern': 'The provided name pattern will create {} components, however {} labels will '
+                'be generated. These counts must match.'.format(
+                    name_pattern_count, label_pattern_count)
+            }, code='label_pattern_mismatch')
+
+
 class TableConfigForm(BootstrapMixin, forms.Form):
     """
     Form for configuring user's table preferences.

+ 13 - 4
netbox/utilities/views.py

@@ -1088,10 +1088,13 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
             new_components = []
             data = deepcopy(request.POST)
 
-            for i, name in enumerate(form.cleaned_data['name_pattern']):
-
+            names = form.cleaned_data['name_pattern']
+            labels = form.cleaned_data.get('label_pattern')
+            for i, name in enumerate(names):
+                label = labels[i] if labels else None
                 # Initialize the individual component form
                 data['name'] = name
+                data['label'] = label
                 if hasattr(form, 'get_iterative_data'):
                     data.update(form.get_iterative_data(i))
                 component_form = self.model_form(data)
@@ -1100,9 +1103,11 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
                     new_components.append(component_form)
                 else:
                     for field, errors in component_form.errors.as_data().items():
-                        # Assign errors on the child form's name field to name_pattern on the parent form
+                        # Assign errors on the child form's name/label field to name_pattern/label_pattern on the parent form
                         if field == 'name':
                             field = 'name_pattern'
+                        elif field == 'label':
+                            field = 'label_pattern'
                         for e in errors:
                             form.add_error(field, '{}: {}'.format(name, ', '.join(e)))
 
@@ -1187,10 +1192,14 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
                         for obj in data['pk']:
 
                             names = data['name_pattern']
-                            for name in names:
+                            labels = data['label_pattern']
+                            for i, name in enumerate(names):
+                                label = labels[i] if labels else None
+
                                 component_data = {
                                     self.parent_field: obj.pk,
                                     'name': name,
+                                    'label': label
                                 }
                                 component_data.update(data)
                                 component_form = self.model_form(component_data)