Jelajahi Sumber

Initial work on #20 - patch panels

Jeremy Stretch 7 tahun lalu
induk
melakukan
3eddeeadc5

+ 95 - 9
netbox/dcim/api/serializers.py

@@ -3,15 +3,13 @@ from rest_framework.validators import UniqueTogetherValidator
 from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 
 
 from circuits.models import Circuit, CircuitTermination
 from circuits.models import Circuit, CircuitTermination
-from dcim.constants import (
-    CONNECTION_STATUS_CHOICES, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_MODE_CHOICES, IFACE_ORDERING_CHOICES,
-    RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
-)
+from dcim.constants import *
 from dcim.models import (
 from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
-    InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    RackReservation, RackRole, Region, Site, VirtualChassis,
+    DeviceBayTemplate, DeviceType, DeviceRole, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection,
+    InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
+    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site,
+    VirtualChassis,
 )
 )
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from ipam.models import IPAddress, VLAN
 from ipam.models import IPAddress, VLAN
@@ -229,8 +227,8 @@ class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
         model = DeviceType
         model = DeviceType
         fields = [
         fields = [
             'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering',
             'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering',
-            'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated', 'instance_count',
+            'is_console_server', 'is_pdu', 'is_network_device', 'is_patch_panel', 'subdevice_role', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated', 'instance_count',
         ]
         ]
 
 
 
 
@@ -304,6 +302,49 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
         fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
         fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
 
 
 
 
+#
+# Rear panel port templates
+#
+
+class RearPanelPortTemplateSerializer(ValidatedModelSerializer):
+    device_type = NestedDeviceTypeSerializer()
+    type = ChoiceField(choices=PANELPORT_TYPE_CHOICES)
+
+    class Meta:
+        model = RearPanelPortTemplate
+        fields = ['id', 'device_type', 'name', 'type', 'positions']
+
+
+class NestedRearPanelPortTemplateSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearpanelporttemplate-detail')
+
+    class Meta:
+        model = RearPanelPortTemplate
+        fields = ['id', 'url', 'name']
+
+
+#
+# Front panel port templates
+#
+
+class FrontPanelPortTemplateSerializer(ValidatedModelSerializer):
+    device_type = NestedDeviceTypeSerializer()
+    type = ChoiceField(choices=PANELPORT_TYPE_CHOICES)
+    rear_port = NestedRearPanelPortTemplateSerializer()
+
+    class Meta:
+        model = FrontPanelPortTemplate
+        fields = ['id', 'device_type', 'name', 'type', 'rear_port', 'rear_port_position']
+
+
+class NestedFrontPanelPortTemplateSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontpanelporttemplate-detail')
+
+    class Meta:
+        model = FrontPanelPortTemplate
+        fields = ['id', 'url', 'name']
+
+
 #
 #
 # Device bay templates
 # Device bay templates
 #
 #
@@ -634,6 +675,51 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
         return None
         return None
 
 
 
 
+#
+# Rear panel ports
+#
+
+class RearPanelPortSerializer(ValidatedModelSerializer):
+    device = NestedDeviceSerializer()
+    type = ChoiceField(choices=PANELPORT_TYPE_CHOICES)
+    tags = TagListSerializerField(required=False)
+
+    class Meta:
+        model = RearPanelPort
+        fields = ['id', 'device', 'name', 'type', 'positions', 'tags']
+
+
+class NestedRearPanelPortSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearpanelport-detail')
+
+    class Meta:
+        model = RearPanelPort
+        fields = ['id', 'url', 'name']
+
+
+#
+# Front panel ports
+#
+
+class FrontPanelPortSerializer(ValidatedModelSerializer):
+    device = NestedDeviceSerializer()
+    type = ChoiceField(choices=PANELPORT_TYPE_CHOICES)
+    rear_port = NestedRearPanelPortSerializer()
+    tags = TagListSerializerField(required=False)
+
+    class Meta:
+        model = FrontPanelPort
+        fields = ['id', 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'tags']
+
+
+class NestedFrontPanelPortSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontpanelport-detail')
+
+    class Meta:
+        model = FrontPanelPort
+        fields = ['id', 'url', 'name']
+
+
 #
 #
 # Device bays
 # Device bays
 #
 #

+ 4 - 0
netbox/dcim/api/urls.py

@@ -37,6 +37,8 @@ router.register(r'console-server-port-templates', views.ConsoleServerPortTemplat
 router.register(r'power-port-templates', views.PowerPortTemplateViewSet)
 router.register(r'power-port-templates', views.PowerPortTemplateViewSet)
 router.register(r'power-outlet-templates', views.PowerOutletTemplateViewSet)
 router.register(r'power-outlet-templates', views.PowerOutletTemplateViewSet)
 router.register(r'interface-templates', views.InterfaceTemplateViewSet)
 router.register(r'interface-templates', views.InterfaceTemplateViewSet)
+router.register(r'front-panel-port-templates', views.FrontPanelPortTemplateViewSet)
+router.register(r'rear-panel-port-templates', views.RearPanelPortTemplateViewSet)
 router.register(r'device-bay-templates', views.DeviceBayTemplateViewSet)
 router.register(r'device-bay-templates', views.DeviceBayTemplateViewSet)
 
 
 # Devices
 # Devices
@@ -50,6 +52,8 @@ router.register(r'console-server-ports', views.ConsoleServerPortViewSet)
 router.register(r'power-ports', views.PowerPortViewSet)
 router.register(r'power-ports', views.PowerPortViewSet)
 router.register(r'power-outlets', views.PowerOutletViewSet)
 router.register(r'power-outlets', views.PowerOutletViewSet)
 router.register(r'interfaces', views.InterfaceViewSet)
 router.register(r'interfaces', views.InterfaceViewSet)
+router.register(r'front-panel-ports', views.FrontPanelPortViewSet)
+router.register(r'rear-panel-ports', views.RearPanelPortViewSet)
 router.register(r'device-bays', views.DeviceBayViewSet)
 router.register(r'device-bays', views.DeviceBayViewSet)
 router.register(r'inventory-items', views.InventoryItemViewSet)
 router.register(r'inventory-items', views.InventoryItemViewSet)
 
 

+ 28 - 3
netbox/dcim/api/views.py

@@ -14,9 +14,10 @@ from rest_framework.viewsets import GenericViewSet, ViewSet
 from dcim import filters
 from dcim import filters
 from dcim.models import (
 from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
-    InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    RackReservation, RackRole, Region, Site, VirtualChassis,
+    DeviceBayTemplate, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection,
+    InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
+    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site,
+    VirtualChassis,
 )
 )
 from extras.api.serializers import RenderedGraphSerializer
 from extras.api.serializers import RenderedGraphSerializer
 from extras.api.views import CustomFieldModelViewSet
 from extras.api.views import CustomFieldModelViewSet
@@ -191,6 +192,18 @@ class InterfaceTemplateViewSet(ModelViewSet):
     filter_class = filters.InterfaceTemplateFilter
     filter_class = filters.InterfaceTemplateFilter
 
 
 
 
+class FrontPanelPortTemplateViewSet(ModelViewSet):
+    queryset = FrontPanelPortTemplate.objects.select_related('device_type__manufacturer')
+    serializer_class = serializers.FrontPanelPortTemplateSerializer
+    filter_class = filters.FrontPanelPortTemplateFilter
+
+
+class RearPanelPortTemplateViewSet(ModelViewSet):
+    queryset = RearPanelPortTemplate.objects.select_related('device_type__manufacturer')
+    serializer_class = serializers.RearPanelPortTemplateSerializer
+    filter_class = filters.RearPanelPortTemplateFilter
+
+
 class DeviceBayTemplateViewSet(ModelViewSet):
 class DeviceBayTemplateViewSet(ModelViewSet):
     queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer')
     queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer')
     serializer_class = serializers.DeviceBayTemplateSerializer
     serializer_class = serializers.DeviceBayTemplateSerializer
@@ -352,6 +365,18 @@ class InterfaceViewSet(ModelViewSet):
         return Response(serializer.data)
         return Response(serializer.data)
 
 
 
 
+class FrontPanelPortViewSet(ModelViewSet):
+    queryset = FrontPanelPort.objects.select_related('device__device_type__manufacturer', 'rear_port')
+    serializer_class = serializers.FrontPanelPortSerializer
+    filter_class = filters.FrontPanelPortFilter
+
+
+class RearPanelPortViewSet(ModelViewSet):
+    queryset = RearPanelPort.objects.select_related('device__device_type__manufacturer')
+    serializer_class = serializers.RearPanelPortSerializer
+    filter_class = filters.RearPanelPortFilter
+
+
 class DeviceBayViewSet(ModelViewSet):
 class DeviceBayViewSet(ModelViewSet):
     queryset = DeviceBay.objects.select_related('installed_device').prefetch_related('tags')
     queryset = DeviceBay.objects.select_related('installed_device').prefetch_related('tags')
     serializer_class = serializers.DeviceBaySerializer
     serializer_class = serializers.DeviceBaySerializer

+ 30 - 0
netbox/dcim/constants.py

@@ -209,6 +209,36 @@ IFACE_MODE_CHOICES = [
     [IFACE_MODE_TAGGED_ALL, 'Tagged All'],
     [IFACE_MODE_TAGGED_ALL, 'Tagged All'],
 ]
 ]
 
 
+# Patch panel port types
+PANELPORT_TYPE_8P8C = 1000
+PANELPORT_TYPE_ST = 2000
+PANELPORT_TYPE_SC_SIMPLEX = 2100
+PANELPORT_TYPE_SC_DUPLEX = 2110
+PANELPORT_TYPE_FC = 2200
+PANELPORT_TYPE_LC = 2300
+PANELPORT_TYPE_MTRJ = 2400
+PANELPORT_TYPE_MPO = 2500
+PANELPORT_TYPE_CHOICES = [
+    [
+        'Copper',
+        [
+            [PANELPORT_TYPE_8P8C, '8P8C'],
+        ],
+    ],
+    [
+        'Fiber Optic',
+        [
+            [PANELPORT_TYPE_ST, 'ST'],
+            [PANELPORT_TYPE_SC_SIMPLEX, 'SC (Simplex)'],
+            [PANELPORT_TYPE_SC_DUPLEX, 'SC (Duplex)'],
+            [PANELPORT_TYPE_FC, 'FC'],
+            [PANELPORT_TYPE_LC, 'LC'],
+            [PANELPORT_TYPE_MTRJ, 'MTRJ'],
+            [PANELPORT_TYPE_MPO, 'MPO'],
+        ]
+    ]
+]
+
 # Device statuses
 # Device statuses
 DEVICE_STATUS_OFFLINE = 0
 DEVICE_STATUS_OFFLINE = 0
 DEVICE_STATUS_ACTIVE = 1
 DEVICE_STATUS_ACTIVE = 1

+ 32 - 3
netbox/dcim/filters.py

@@ -14,9 +14,10 @@ from .constants import (
 )
 )
 from .models import (
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
-    InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    RackReservation, RackRole, Region, Site, VirtualChassis,
+    DeviceBayTemplate, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection,
+    InterfaceTemplate, InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
+    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site,
+    VirtualChassis,
 )
 )
 
 
 
 
@@ -368,6 +369,20 @@ class InterfaceTemplateFilter(DeviceTypeComponentFilterSet):
         fields = ['name', 'form_factor', 'mgmt_only']
         fields = ['name', 'form_factor', 'mgmt_only']
 
 
 
 
+class FrontPanelPortTemplateFilter(DeviceTypeComponentFilterSet):
+
+    class Meta:
+        model = FrontPanelPortTemplate
+        fields = ['name', 'type']
+
+
+class RearPanelPortTemplateFilter(DeviceTypeComponentFilterSet):
+
+    class Meta:
+        model = RearPanelPortTemplate
+        fields = ['name', 'type']
+
+
 class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet):
 class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
@@ -667,6 +682,20 @@ class InterfaceFilter(django_filters.FilterSet):
             return queryset.none()
             return queryset.none()
 
 
 
 
+class FrontPanelPortFilter(DeviceComponentFilterSet):
+
+    class Meta:
+        model = FrontPanelPort
+        fields = ['name', 'type']
+
+
+class RearPanelPortFilter(DeviceComponentFilterSet):
+
+    class Meta:
+        model = RearPanelPort
+        fields = ['name', 'type']
+
+
 class DeviceBayFilter(DeviceComponentFilterSet):
 class DeviceBayFilter(DeviceComponentFilterSet):
 
 
     class Meta:
     class Meta:

+ 221 - 10
netbox/dcim/forms.py

@@ -5,6 +5,8 @@ from django.contrib.auth.models import User
 from django.contrib.postgres.forms.array import SimpleArrayField
 from django.contrib.postgres.forms.array import SimpleArrayField
 from django.db.models import Count, Q
 from django.db.models import Count, Q
 from mptt.forms import TreeNodeChoiceField
 from mptt.forms import TreeNodeChoiceField
+from natsort import natsorted
+from operator import attrgetter
 from taggit.forms import TagField
 from taggit.forms import TagField
 from timezone_field import TimeZoneFormField
 from timezone_field import TimeZoneFormField
 
 
@@ -19,17 +21,13 @@ from utilities.forms import (
     FlexibleModelChoiceField, JSONField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
     FlexibleModelChoiceField, JSONField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
 )
 )
 from virtualization.models import Cluster
 from virtualization.models import Cluster
-from .constants import (
-    CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_FF_LAG,
-    IFACE_MODE_ACCESS, IFACE_MODE_CHOICES, IFACE_MODE_TAGGED_ALL, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES,
-    RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, RACK_WIDTH_19IN, RACK_WIDTH_23IN, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
-    SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES,
-)
+from .constants import *
 from .models import (
 from .models import (
     DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
     DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
-    Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
-    Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
-    RackRole, Region, Site, VirtualChassis
+    Device, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection,
+    InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
+    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site,
+    VirtualChassis,
 )
 )
 
 
 DEVICE_BY_PK_RE = r'{\d+\}'
 DEVICE_BY_PK_RE = r'{\d+\}'
@@ -532,7 +530,7 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
         model = DeviceType
         model = DeviceType
         fields = [
         fields = [
             'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
             'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
-            'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', 'tags',
+            'is_network_device', 'is_patch_panel', 'subdevice_role', 'interface_ordering', 'comments', 'tags',
         ]
         ]
         labels = {
         labels = {
             'interface_ordering': 'Order interfaces by',
             'interface_ordering': 'Order interfaces by',
@@ -582,6 +580,9 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkE
     is_network_device = forms.NullBooleanField(
     is_network_device = forms.NullBooleanField(
         required=False, widget=BulkEditNullBooleanSelect, label='Is a network device'
         required=False, widget=BulkEditNullBooleanSelect, label='Is a network device'
     )
     )
+    is_patch_panel = forms.NullBooleanField(
+        required=False, widget=BulkEditNullBooleanSelect, label='Is a patch panel'
+    )
 
 
     class Meta:
     class Meta:
         nullable_fields = []
         nullable_fields = []
@@ -602,6 +603,9 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
     is_network_device = forms.BooleanField(
     is_network_device = forms.BooleanField(
         required=False, label='Is a network device', widget=forms.CheckboxInput(attrs={'value': 'True'})
         required=False, label='Is a network device', widget=forms.CheckboxInput(attrs={'value': 'True'})
     )
     )
+    is_patch_panel = forms.BooleanField(
+        required=False, label='Is a patch panel', widget=forms.CheckboxInput(attrs={'value': 'True'})
+    )
     subdevice_role = forms.NullBooleanField(
     subdevice_role = forms.NullBooleanField(
         required=False, label='Subdevice role', widget=forms.Select(choices=(
         required=False, label='Subdevice role', widget=forms.Select(choices=(
             ('', '---------'),
             ('', '---------'),
@@ -696,6 +700,97 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
         nullable_fields = []
         nullable_fields = []
 
 
 
 
+class FrontPanelPortTemplateForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = FrontPanelPortTemplate
+        fields = ['device_type', 'name', 'type', 'rear_port', 'rear_port_position']
+        widgets = {
+            'device_type': forms.HiddenInput(),
+        }
+
+
+class FrontPanelPortTemplateCreateForm(ComponentForm):
+    name_pattern = ExpandableNameField(
+        label='Name'
+    )
+    type = forms.ChoiceField(
+        choices=PANELPORT_TYPE_CHOICES
+    )
+    rear_port_set = forms.MultipleChoiceField(
+        choices=[],
+        label='Rear ports',
+        help_text='Select one rear port assignment for each front port being created.'
+    )
+
+    def __init__(self, *args, **kwargs):
+
+        super(FrontPanelPortTemplateCreateForm, self).__init__(*args, **kwargs)
+
+        # Determine which rear port positions are occupied. These will be excluded from the list of available mappings.
+        occupied_port_positions = [
+            (front_port.rear_port_id, front_port.rear_port_position)
+            for front_port in self.parent.front_panel_port_templates.all()
+        ]
+
+        # Populate rear port choices
+        choices = []
+        rear_ports = natsorted(RearPanelPortTemplate.objects.filter(device_type=self.parent), key=attrgetter('name'))
+        for rear_port in rear_ports:
+            for i in range(1, rear_port.positions + 1):
+                if (rear_port.pk, i) not in occupied_port_positions:
+                    choices.append(
+                        ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
+                    )
+        self.fields['rear_port_set'].choices = choices
+
+    def clean(self):
+
+        # Validate that the number of ports being created equals the number of selected (rear port, position) tuples
+        front_port_count = len(self.cleaned_data['name_pattern'])
+        rear_port_count = len(self.cleaned_data['rear_port_set'])
+        if front_port_count != rear_port_count:
+            raise forms.ValidationError({
+                'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments '
+                                 'were selected. These counts must match.'.format(front_port_count, rear_port_count)
+            })
+
+    def get_iterative_data(self, iteration):
+
+        # Assign rear port and position from selected set
+        rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
+
+        return {
+            'rear_port': int(rear_port),
+            'rear_port_position': int(position),
+        }
+
+
+class RearPanelPortTemplateForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = RearPanelPortTemplate
+        fields = ['device_type', 'name', 'type', 'positions']
+        widgets = {
+            'device_type': forms.HiddenInput(),
+        }
+
+
+class RearPanelPortTemplateCreateForm(ComponentForm):
+    name_pattern = ExpandableNameField(
+        label='Name'
+    )
+    type = forms.ChoiceField(
+        choices=PANELPORT_TYPE_CHOICES
+    )
+    positions = forms.IntegerField(
+        min_value=1,
+        max_value=64,
+        initial=1,
+        help_text='The number of front ports which may be mapped to each rear port'
+    )
+
+
 class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
 class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
@@ -2087,6 +2182,122 @@ class InterfaceConnectionCSVForm(forms.ModelForm):
         return interface
         return interface
 
 
 
 
+#
+# Front panel ports
+#
+
+class FrontPanelPortForm(BootstrapMixin, forms.ModelForm):
+    tags = TagField(required=False)
+
+    class Meta:
+        model = FrontPanelPort
+        fields = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'tags']
+        widgets = {
+            'device': forms.HiddenInput(),
+        }
+
+
+# TODO: Merge with  FrontPanelPortTemplateCreateForm to remove duplicate logic
+class FrontPanelPortCreateForm(ComponentForm):
+    name_pattern = ExpandableNameField(
+        label='Name'
+    )
+    type = forms.ChoiceField(
+        choices=PANELPORT_TYPE_CHOICES
+    )
+    rear_port_set = forms.MultipleChoiceField(
+        choices=[],
+        label='Rear ports',
+        help_text='Select one rear port assignment for each front port being created.'
+    )
+
+    def __init__(self, *args, **kwargs):
+
+        super(FrontPanelPortCreateForm, self).__init__(*args, **kwargs)
+
+        # Determine which rear port positions are occupied. These will be excluded from the list of available mappings.
+        occupied_port_positions = [
+            (front_port.rear_port_id, front_port.rear_port_position)
+            for front_port in self.parent.front_panel_port_templates.all()
+        ]
+
+        # Populate rear port choices
+        choices = []
+        rear_ports = natsorted(RearPanelPort.objects.filter(device=self.parent), key=attrgetter('name'))
+        for rear_port in rear_ports:
+            for i in range(1, rear_port.positions + 1):
+                if (rear_port.pk, i) not in occupied_port_positions:
+                    choices.append(
+                        ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
+                    )
+        self.fields['rear_port_set'].choices = choices
+
+    def clean(self):
+
+        # Validate that the number of ports being created equals the number of selected (rear port, position) tuples
+        front_port_count = len(self.cleaned_data['name_pattern'])
+        rear_port_count = len(self.cleaned_data['rear_port_set'])
+        if front_port_count != rear_port_count:
+            raise forms.ValidationError({
+                'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments '
+                                 'were selected. These counts must match.'.format(front_port_count, rear_port_count)
+            })
+
+    def get_iterative_data(self, iteration):
+
+        # Assign rear port and position from selected set
+        rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
+
+        return {
+            'rear_port': int(rear_port),
+            'rear_port_position': int(position),
+        }
+
+
+class FrontPanelPortBulkRenameForm(BulkRenameForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=FrontPanelPort.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+
+
+#
+# Rear panel ports
+#
+
+class RearPanelPortForm(BootstrapMixin, forms.ModelForm):
+    tags = TagField(required=False)
+
+    class Meta:
+        model = RearPanelPort
+        fields = ['device', 'name', 'type', 'positions', 'tags']
+        widgets = {
+            'device': forms.HiddenInput(),
+        }
+
+
+class RearPanelPortCreateForm(ComponentForm):
+    name_pattern = ExpandableNameField(
+        label='Name'
+    )
+    type = forms.ChoiceField(
+        choices=PANELPORT_TYPE_CHOICES
+    )
+    positions = forms.IntegerField(
+        min_value=1,
+        max_value=64,
+        initial=1,
+        help_text='The number of front ports which may be mapped to each rear port'
+    )
+
+
+class RearPanelPortBulkRenameForm(BulkRenameForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=RearPanelPort.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+
+
 #
 #
 # Device bays
 # Device bays
 #
 #

+ 114 - 0
netbox/dcim/migrations/0065_patch_panel_ports.py

@@ -0,0 +1,114 @@
+# Generated by Django 2.0.8 on 2018-10-03 17:26
+
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('taggit', '0002_auto_20150616_2121'),
+        ('dcim', '0064_remove_platform_rpc_client'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='FrontPanelPort',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=64)),
+                ('type', models.PositiveSmallIntegerField()),
+                ('rear_port_position', models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(64)])),
+                ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='front_panel_ports', to='dcim.Device')),
+            ],
+            options={
+                'ordering': ['device', 'name'],
+            },
+        ),
+        migrations.CreateModel(
+            name='FrontPanelPortTemplate',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=64)),
+                ('type', models.PositiveSmallIntegerField()),
+                ('rear_port_position', models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(64)])),
+            ],
+            options={
+                'ordering': ['device_type', 'name'],
+            },
+        ),
+        migrations.CreateModel(
+            name='RearPanelPort',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=64)),
+                ('type', models.PositiveSmallIntegerField()),
+                ('positions', models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(64)])),
+                ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rear_panel_ports', to='dcim.Device')),
+                ('tags', taggit.managers.TaggableManager(through='taggit.TaggedItem', to='taggit.Tag')),
+            ],
+            options={
+                'ordering': ['device', 'name'],
+            },
+        ),
+        migrations.CreateModel(
+            name='RearPanelPortTemplate',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=64)),
+                ('type', models.PositiveSmallIntegerField()),
+                ('positions', models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(64)])),
+            ],
+            options={
+                'ordering': ['device_type', 'name'],
+            },
+        ),
+        migrations.AddField(
+            model_name='devicetype',
+            name='is_patch_panel',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AddField(
+            model_name='rearpanelporttemplate',
+            name='device_type',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rear_panel_port_templates', to='dcim.DeviceType'),
+        ),
+        migrations.AddField(
+            model_name='frontpanelporttemplate',
+            name='device_type',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='front_panel_port_templates', to='dcim.DeviceType'),
+        ),
+        migrations.AddField(
+            model_name='frontpanelporttemplate',
+            name='rear_port',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='front_panel_port_templates', to='dcim.RearPanelPortTemplate'),
+        ),
+        migrations.AddField(
+            model_name='frontpanelport',
+            name='rear_port',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='front_panel_ports', to='dcim.RearPanelPort'),
+        ),
+        migrations.AddField(
+            model_name='frontpanelport',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='taggit.TaggedItem', to='taggit.Tag'),
+        ),
+        migrations.AlterUniqueTogether(
+            name='rearpanelporttemplate',
+            unique_together={('device_type', 'name')},
+        ),
+        migrations.AlterUniqueTogether(
+            name='rearpanelport',
+            unique_together={('device', 'name')},
+        ),
+        migrations.AlterUniqueTogether(
+            name='frontpanelporttemplate',
+            unique_together={('rear_port', 'rear_port_position'), ('device_type', 'name')},
+        ),
+        migrations.AlterUniqueTogether(
+            name='frontpanelport',
+            unique_together={('device', 'name'), ('rear_port', 'rear_port_position')},
+        ),
+    ]

+ 200 - 1
netbox/dcim/models.py

@@ -769,6 +769,11 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
         verbose_name='Is a network device',
         verbose_name='Is a network device',
         help_text='This type of device has network interfaces'
         help_text='This type of device has network interfaces'
     )
     )
+    is_patch_panel = models.BooleanField(
+        default=False,
+        verbose_name='Is a patch panel',
+        help_text='This type of device has patch panel ports'
+    )
     subdevice_role = models.NullBooleanField(
     subdevice_role = models.NullBooleanField(
         default=None,
         default=None,
         verbose_name='Parent/child status',
         verbose_name='Parent/child status',
@@ -789,7 +794,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
 
 
     csv_headers = [
     csv_headers = [
         'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
         'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
-        'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments',
+        'is_pdu', 'is_network_device', 'is_patch_panel', 'subdevice_role', 'interface_ordering', 'comments',
     ]
     ]
 
 
     class Meta:
     class Meta:
@@ -822,6 +827,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
             self.is_console_server,
             self.is_console_server,
             self.is_pdu,
             self.is_pdu,
             self.is_network_device,
             self.is_network_device,
+            self.is_patch_panel,
             self.get_subdevice_role_display() if self.subdevice_role else None,
             self.get_subdevice_role_display() if self.subdevice_role else None,
             self.get_interface_ordering_display(),
             self.get_interface_ordering_display(),
             self.comments,
             self.comments,
@@ -861,6 +867,14 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
                                      "device before declassifying it as a network device."
                                      "device before declassifying it as a network device."
             })
             })
 
 
+        if not self.is_patch_panel and (
+                self.front_panel_port_templates.exists() or self.rear_panel_port_templates.exists()
+        ):
+            raise ValidationError({
+                'is_patch_panel': "Must delete all patch panel port templates associated with this device before "
+                                  "declassifying it as a network device."
+            })
+
         if self.subdevice_role != SUBDEVICE_ROLE_PARENT and self.device_bay_templates.count():
         if self.subdevice_role != SUBDEVICE_ROLE_PARENT and self.device_bay_templates.count():
             raise ValidationError({
             raise ValidationError({
                 'subdevice_role': "Must delete all device bay templates associated with this device before "
                 'subdevice_role': "Must delete all device bay templates associated with this device before "
@@ -1000,6 +1014,86 @@ class InterfaceTemplate(ComponentTemplateModel):
         return self.name
         return self.name
 
 
 
 
+class FrontPanelPortTemplate(ComponentTemplateModel):
+    """
+    A template for a front patch panel port on a new Device.
+    """
+    device_type = models.ForeignKey(
+        to='dcim.DeviceType',
+        on_delete=models.CASCADE,
+        related_name='front_panel_port_templates'
+    )
+    name = models.CharField(
+        max_length=64
+    )
+    type = models.PositiveSmallIntegerField(
+        choices=PANELPORT_TYPE_CHOICES
+    )
+    rear_port = models.ForeignKey(
+        to='dcim.RearPanelPortTemplate',
+        on_delete=models.CASCADE,
+        related_name='front_panel_port_templates'
+    )
+    rear_port_position = models.PositiveSmallIntegerField(
+        default=1,
+        validators=[MinValueValidator(1), MaxValueValidator(64)]
+    )
+
+    class Meta:
+        ordering = ['device_type', 'name']
+        unique_together = [
+            ['device_type', 'name'],
+            ['rear_port', 'rear_port_position'],
+        ]
+
+    def __str__(self):
+        return self.name
+
+    def clean(self):
+
+        # Validate rear port assignment
+        if self.rear_port.device_type != self.device_type:
+            raise ValidationError(
+                "Rear port ({}) must belong to the same device type".format(self.rear_port)
+            )
+
+        # Validate rear port position assignment
+        if self.rear_port_position > self.rear_port.positions:
+            raise ValidationError(
+                "Invalid rear port position ({}); rear port {} has only {} positions".format(
+                    self.rear_port_position, self.rear_port.name, self.rear_port.positions
+                )
+            )
+
+
+class RearPanelPortTemplate(ComponentTemplateModel):
+    """
+    A template for a rear patch panel port on a new Device.
+    """
+    device_type = models.ForeignKey(
+        to='dcim.DeviceType',
+        on_delete=models.CASCADE,
+        related_name='rear_panel_port_templates'
+    )
+    name = models.CharField(
+        max_length=64
+    )
+    type = models.PositiveSmallIntegerField(
+        choices=PANELPORT_TYPE_CHOICES
+    )
+    positions = models.PositiveSmallIntegerField(
+        default=1,
+        validators=[MinValueValidator(1), MaxValueValidator(64)]
+    )
+
+    class Meta:
+        ordering = ['device_type', 'name']
+        unique_together = ['device_type', 'name']
+
+    def __str__(self):
+        return self.name
+
+
 class DeviceBayTemplate(ComponentTemplateModel):
 class DeviceBayTemplate(ComponentTemplateModel):
     """
     """
     A template for a DeviceBay to be created for a new parent Device.
     A template for a DeviceBay to be created for a new parent Device.
@@ -1417,6 +1511,23 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
                 [Interface(device=self, name=template.name, form_factor=template.form_factor,
                 [Interface(device=self, name=template.name, form_factor=template.form_factor,
                            mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()]
                            mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()]
             )
             )
+            RearPanelPort.objects.bulk_create([
+                RearPanelPort(
+                    device=self,
+                    name=template.name,
+                    type=template.type,
+                    positions=template.positions
+                ) for template in self.device_type.rear_panel_port_templates.all()
+            ])
+            FrontPanelPort.objects.bulk_create([
+                FrontPanelPort(
+                    device=self,
+                    name=template.name,
+                    type=template.type,
+                    rear_port=RearPanelPort.objects.get(device=self, name=template.rear_port.name),
+                    rear_port_position=template.rear_port_position,
+                ) for template in self.device_type.front_panel_port_templates.all()
+            ])
             DeviceBay.objects.bulk_create(
             DeviceBay.objects.bulk_create(
                 [DeviceBay(device=self, name=template.name) for template in
                 [DeviceBay(device=self, name=template.name) for template in
                  self.device_type.device_bay_templates.all()]
                  self.device_type.device_bay_templates.all()]
@@ -2040,6 +2151,94 @@ class InterfaceConnection(models.Model):
             ).save()
             ).save()
 
 
 
 
+#
+# Patch panel ports
+#
+
+class FrontPanelPort(ComponentModel):
+    """
+    A port on the front of a patch panel.
+    """
+    device = models.ForeignKey(
+        to='dcim.Device',
+        on_delete=models.CASCADE,
+        related_name='front_panel_ports'
+    )
+    name = models.CharField(
+        max_length=64
+    )
+    type = models.PositiveSmallIntegerField(
+        choices=PANELPORT_TYPE_CHOICES
+    )
+    rear_port = models.ForeignKey(
+        to='dcim.RearPanelPort',
+        on_delete=models.CASCADE,
+        related_name='front_panel_ports'
+    )
+    rear_port_position = models.PositiveSmallIntegerField(
+        default=1,
+        validators=[MinValueValidator(1), MaxValueValidator(64)]
+    )
+
+    tags = TaggableManager()
+
+    class Meta:
+        ordering = ['device', 'name']
+        unique_together = [
+            ['device', 'name'],
+            ['rear_port', 'rear_port_position'],
+        ]
+
+    def __str__(self):
+        return self.name
+
+    def clean(self):
+
+        # Validate rear port assignment
+        if self.rear_port.device != self.device:
+            raise ValidationError(
+                "Rear port ({}) must belong to the same device".format(self.rear_port)
+            )
+
+        # Validate rear port position assignment
+        if self.rear_port_position > self.rear_port.positions:
+            raise ValidationError(
+                "Invalid rear port position ({}); rear port {} has only {} positions".format(
+                    self.rear_port_position, self.rear_port.name, self.rear_port.positions
+                )
+            )
+
+
+class RearPanelPort(ComponentModel):
+    """
+    A port on the rear of a patch panel.
+    """
+    device = models.ForeignKey(
+        to='dcim.Device',
+        on_delete=models.CASCADE,
+        related_name='rear_panel_ports'
+    )
+    name = models.CharField(
+        max_length=64
+    )
+    type = models.PositiveSmallIntegerField(
+        choices=PANELPORT_TYPE_CHOICES
+    )
+    positions = models.PositiveSmallIntegerField(
+        default=1,
+        validators=[MinValueValidator(1), MaxValueValidator(64)]
+    )
+
+    tags = TaggableManager()
+
+    class Meta:
+        ordering = ['device', 'name']
+        unique_together = ['device', 'name']
+
+    def __str__(self):
+        return self.name
+
+
 #
 #
 # Device bays
 # Device bays
 #
 #

+ 40 - 4
netbox/dcim/tables.py

@@ -5,9 +5,10 @@ from tenancy.tables import COL_TENANT
 from utilities.tables import BaseTable, BooleanColumn, ToggleColumn
 from utilities.tables import BaseTable, BooleanColumn, ToggleColumn
 from .models import (
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, InventoryItem,
-    Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    RackReservation, Region, Site, VirtualChassis,
+    DeviceBayTemplate, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection,
+    InterfaceTemplate, InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
+    PowerPortTemplate, Rack, RackGroup, RackReservation, RearPanelPort, RearPanelPortTemplate, Region, Site,
+    VirtualChassis,
 )
 )
 
 
 REGION_LINK = """
 REGION_LINK = """
@@ -348,6 +349,7 @@ class DeviceTypeTable(BaseTable):
     is_console_server = BooleanColumn(verbose_name='CS')
     is_console_server = BooleanColumn(verbose_name='CS')
     is_pdu = BooleanColumn(verbose_name='PDU')
     is_pdu = BooleanColumn(verbose_name='PDU')
     is_network_device = BooleanColumn(verbose_name='Net')
     is_network_device = BooleanColumn(verbose_name='Net')
+    is_patch_panel = BooleanColumn(verbose_name='PP')
     subdevice_role = tables.TemplateColumn(
     subdevice_role = tables.TemplateColumn(
         template_code=SUBDEVICE_ROLE_TEMPLATE,
         template_code=SUBDEVICE_ROLE_TEMPLATE,
         verbose_name='Subdevice Role'
         verbose_name='Subdevice Role'
@@ -361,7 +363,7 @@ class DeviceTypeTable(BaseTable):
         model = DeviceType
         model = DeviceType
         fields = (
         fields = (
             'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
             'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
-            'is_network_device', 'subdevice_role', 'instance_count',
+            'is_network_device', 'is_patch_panel', 'subdevice_role', 'instance_count',
         )
         )
 
 
 
 
@@ -415,6 +417,24 @@ class InterfaceTemplateTable(BaseTable):
         empty_text = "None"
         empty_text = "None"
 
 
 
 
+class FrontPanelPortTemplateTable(BaseTable):
+    pk = ToggleColumn()
+
+    class Meta(BaseTable.Meta):
+        model = FrontPanelPortTemplate
+        fields = ('pk', 'name', 'type', 'rear_port', 'rear_port_position')
+        empty_text = "None"
+
+
+class RearPanelPortTemplateTable(BaseTable):
+    pk = ToggleColumn()
+
+    class Meta(BaseTable.Meta):
+        model = RearPanelPortTemplate
+        fields = ('pk', 'name', 'type', 'positions')
+        empty_text = "None"
+
+
 class DeviceBayTemplateTable(BaseTable):
 class DeviceBayTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
 
 
@@ -574,6 +594,22 @@ class InterfaceTable(BaseTable):
         fields = ('name', 'form_factor', 'lag', 'enabled', 'mgmt_only', 'description')
         fields = ('name', 'form_factor', 'lag', 'enabled', 'mgmt_only', 'description')
 
 
 
 
+class FrontPanelPortTable(BaseTable):
+
+    class Meta(BaseTable.Meta):
+        model = FrontPanelPort
+        fields = ('name', 'type', 'rear_port', 'rear_port_position')
+        empty_text = "None"
+
+
+class RearPanelPortTable(BaseTable):
+
+    class Meta(BaseTable.Meta):
+        model = RearPanelPort
+        fields = ('name', 'type', 'positions')
+        empty_text = "None"
+
+
 class DeviceBayTable(BaseTable):
 class DeviceBayTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):

+ 24 - 0
netbox/dcim/urls.py

@@ -109,6 +109,14 @@ urlpatterns = [
     url(r'^device-types/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'),
     url(r'^device-types/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'),
     url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
     url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
 
 
+    # Front panel port templates
+    url(r'^device-types/(?P<pk>\d+)/front-panel-ports/add/$', views.FrontPanelPortTemplateCreateView.as_view(), name='devicetype_add_frontpanelport'),
+    url(r'^device-types/(?P<pk>\d+)/front-panel-ports/delete/$', views.FrontPanelPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_frontpanelport'),
+
+    # Front panel port templates
+    url(r'^device-types/(?P<pk>\d+)/rear-panel-ports/add/$', views.RearPanelPortTemplateCreateView.as_view(), name='devicetype_add_rearpanelport'),
+    url(r'^device-types/(?P<pk>\d+)/rear-panel-ports/delete/$', views.RearPanelPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_rearpanelport'),
+
     # Device bay templates
     # Device bay templates
     url(r'^device-types/(?P<pk>\d+)/device-bays/add/$', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'),
     url(r'^device-types/(?P<pk>\d+)/device-bays/add/$', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'),
     url(r'^device-types/(?P<pk>\d+)/device-bays/delete/$', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'),
     url(r'^device-types/(?P<pk>\d+)/device-bays/delete/$', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'),
@@ -204,6 +212,22 @@ urlpatterns = [
     url(r'^interfaces/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
     url(r'^interfaces/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
     url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
     url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
 
 
+    # Front panel ports
+    # url(r'^devices/front-panel-ports/add/$', views.DeviceBulkAddFrontPanelPortView.as_view(), name='device_bulk_add_frontpanelport'),
+    url(r'^devices/(?P<pk>\d+)/front-panel-ports/add/$', views.FrontPanelPortCreateView.as_view(), name='frontpanelport_add'),
+    url(r'^devices/(?P<pk>\d+)/front-panel-ports/delete/$', views.FrontPanelPortBulkDeleteView.as_view(), name='frontpanelport_bulk_delete'),
+    url(r'^front-panel-ports/(?P<pk>\d+)/edit/$', views.FrontPanelPortEditView.as_view(), name='frontpanelport_edit'),
+    url(r'^front-panel-ports/(?P<pk>\d+)/delete/$', views.FrontPanelPortDeleteView.as_view(), name='frontpanelport_delete'),
+    url(r'^front-panel-ports/rename/$', views.FrontPanelPortBulkRenameView.as_view(), name='frontpanelport_bulk_rename'),
+
+    # Rear panel ports
+    # url(r'^devices/rear-panel-ports/add/$', views.DeviceBulkAddRearPanelPortView.as_view(), name='device_bulk_add_rearpanelport'),
+    url(r'^devices/(?P<pk>\d+)/rear-panel-ports/add/$', views.RearPanelPortCreateView.as_view(), name='rearpanelport_add'),
+    url(r'^devices/(?P<pk>\d+)/rear-panel-ports/delete/$', views.RearPanelPortBulkDeleteView.as_view(), name='rearpanelport_bulk_delete'),
+    url(r'^rear-panel-ports/(?P<pk>\d+)/edit/$', views.RearPanelPortEditView.as_view(), name='rearpanelport_edit'),
+    url(r'^rear-panel-ports/(?P<pk>\d+)/delete/$', views.RearPanelPortDeleteView.as_view(), name='rearpanelport_delete'),
+    url(r'^rear-panel-ports/rename/$', views.RearPanelPortBulkRenameView.as_view(), name='rearpanelport_bulk_rename'),
+
     # Device bays
     # Device bays
     url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
     url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
     url(r'^devices/(?P<pk>\d+)/bays/add/$', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
     url(r'^devices/(?P<pk>\d+)/bays/add/$', views.DeviceBayCreateView.as_view(), name='devicebay_add'),

+ 134 - 3
netbox/dcim/views.py

@@ -31,9 +31,10 @@ from . import filters, forms, tables
 from .constants import CONNECTION_STATUS_CONNECTED
 from .constants import CONNECTION_STATUS_CONNECTED
 from .models import (
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
-    InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    RackReservation, RackRole, Region, Site, VirtualChassis,
+    DeviceBayTemplate, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection,
+    InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
+    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site,
+    VirtualChassis,
 )
 )
 
 
 
 
@@ -559,6 +560,14 @@ class DeviceTypeView(View):
             ).filter(device_type=devicetype)),
             ).filter(device_type=devicetype)),
             orderable=False
             orderable=False
         )
         )
+        front_panel_port_table = tables.FrontPanelPortTemplateTable(
+            natsorted(FrontPanelPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')),
+            orderable=False
+        )
+        rear_panel_port_table = tables.RearPanelPortTemplateTable(
+            natsorted(RearPanelPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')),
+            orderable=False
+        )
         devicebay_table = tables.DeviceBayTemplateTable(
         devicebay_table = tables.DeviceBayTemplateTable(
             natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')),
             natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')),
             orderable=False
             orderable=False
@@ -569,6 +578,8 @@ class DeviceTypeView(View):
             powerport_table.columns.show('pk')
             powerport_table.columns.show('pk')
             poweroutlet_table.columns.show('pk')
             poweroutlet_table.columns.show('pk')
             interface_table.columns.show('pk')
             interface_table.columns.show('pk')
+            front_panel_port_table.columns.show('pk')
+            rear_panel_port_table.columns.show('pk')
             devicebay_table.columns.show('pk')
             devicebay_table.columns.show('pk')
 
 
         return render(request, 'dcim/devicetype.html', {
         return render(request, 'dcim/devicetype.html', {
@@ -578,6 +589,8 @@ class DeviceTypeView(View):
             'powerport_table': powerport_table,
             'powerport_table': powerport_table,
             'poweroutlet_table': poweroutlet_table,
             'poweroutlet_table': poweroutlet_table,
             'interface_table': interface_table,
             'interface_table': interface_table,
+            'front_panel_port_table': front_panel_port_table,
+            'rear_panel_port_table': rear_panel_port_table,
             'devicebay_table': devicebay_table,
             'devicebay_table': devicebay_table,
         })
         })
 
 
@@ -721,6 +734,40 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     table = tables.InterfaceTemplateTable
     table = tables.InterfaceTemplateTable
 
 
 
 
+class FrontPanelPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_frontpanelporttemplate'
+    parent_model = DeviceType
+    parent_field = 'device_type'
+    model = FrontPanelPortTemplate
+    form = forms.FrontPanelPortTemplateCreateForm
+    model_form = forms.FrontPanelPortTemplateForm
+    template_name = 'dcim/device_component_add.html'
+
+
+class FrontPanelPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'dcim.delete_frontpanelporttemplate'
+    queryset = FrontPanelPortTemplate.objects.all()
+    parent_model = DeviceType
+    table = tables.FrontPanelPortTemplateTable
+
+
+class RearPanelPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_rearpanelporttemplate'
+    parent_model = DeviceType
+    parent_field = 'device_type'
+    model = RearPanelPortTemplate
+    form = forms.RearPanelPortTemplateCreateForm
+    model_form = forms.RearPanelPortTemplateForm
+    template_name = 'dcim/device_component_add.html'
+
+
+class RearPanelPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'dcim.delete_rearpanelporttemplate'
+    queryset = RearPanelPortTemplate.objects.all()
+    parent_model = DeviceType
+    table = tables.RearPanelPortTemplateTable
+
+
 class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_devicebaytemplate'
     permission_required = 'dcim.add_devicebaytemplate'
     parent_model = DeviceType
     parent_model = DeviceType
@@ -859,6 +906,12 @@ class DeviceView(View):
             'circuit_termination__circuit'
             'circuit_termination__circuit'
         ).prefetch_related('ip_addresses')
         ).prefetch_related('ip_addresses')
 
 
+        # Front panel ports
+        front_panel_ports = device.front_panel_ports.select_related('rear_port')
+
+        # Rear panel ports
+        rear_panel_ports = device.rear_panel_ports.all()
+
         # Device bays
         # Device bays
         device_bays = natsorted(
         device_bays = natsorted(
             DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
             DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
@@ -891,6 +944,8 @@ class DeviceView(View):
             'power_outlets': power_outlets,
             'power_outlets': power_outlets,
             'interfaces': interfaces,
             'interfaces': interfaces,
             'device_bays': device_bays,
             'device_bays': device_bays,
+            'front_panel_ports': front_panel_ports,
+            'rear_panel_ports': rear_panel_ports,
             'services': services,
             'services': services,
             'secrets': secrets,
             'secrets': secrets,
             'vc_members': vc_members,
             'vc_members': vc_members,
@@ -1701,6 +1756,82 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     table = tables.InterfaceTable
     table = tables.InterfaceTable
 
 
 
 
+#
+# Front panel ports
+#
+
+class FrontPanelPortCreateView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_frontpanelport'
+    parent_model = Device
+    parent_field = 'device'
+    model = FrontPanelPort
+    form = forms.FrontPanelPortCreateForm
+    model_form = forms.FrontPanelPortForm
+    template_name = 'dcim/device_component_add.html'
+
+
+class FrontPanelPortEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.change_frontpanelport'
+    model = FrontPanelPort
+    model_form = forms.FrontPanelPortForm
+
+
+class FrontPanelPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_frontpanelport'
+    model = FrontPanelPort
+
+
+class FrontPanelPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
+    permission_required = 'dcim.change_frontpanelport'
+    queryset = FrontPanelPort.objects.all()
+    form = forms.FrontPanelPortBulkRenameForm
+
+
+class FrontPanelPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'dcim.delete_frontpanelport'
+    queryset = FrontPanelPort.objects.all()
+    parent_model = Device
+    table = tables.FrontPanelPortTable
+
+
+#
+# Rear panel ports
+#
+
+class RearPanelPortCreateView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_rearpanelport'
+    parent_model = Device
+    parent_field = 'device'
+    model = RearPanelPort
+    form = forms.RearPanelPortCreateForm
+    model_form = forms.RearPanelPortForm
+    template_name = 'dcim/device_component_add.html'
+
+
+class RearPanelPortEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.change_rearpanelport'
+    model = RearPanelPort
+    model_form = forms.RearPanelPortForm
+
+
+class RearPanelPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_rearpanelport'
+    model = RearPanelPort
+
+
+class RearPanelPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
+    permission_required = 'dcim.change_rearpanelport'
+    queryset = RearPanelPort.objects.all()
+    form = forms.RearPanelPortBulkRenameForm
+
+
+class RearPanelPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'dcim.delete_rearpanelport'
+    queryset = RearPanelPort.objects.all()
+    parent_model = Device
+    table = tables.RearPanelPortTable
+
+
 #
 #
 # Device bays
 # Device bays
 #
 #

+ 105 - 0
netbox/templates/dcim/device.html

@@ -689,6 +689,111 @@
                     </form>
                     </form>
                 {% endif %}
                 {% endif %}
             {% endif %}
             {% endif %}
+            {% if front_panel_ports or device.device_type.is_patch_panel %}
+                <form method="post">
+                {% csrf_token %}
+                    <div class="panel panel-default">
+                        <div class="panel-heading">
+                            <strong>Front Panel Ports</strong>
+                        </div>
+                        <table class="table table-hover table-headings panel-body component-list">
+                            <thead>
+                                <tr>
+                                    {% if perms.dcim.change_frontpanelport or perms.dcim.delete_frontpanelport %}
+                                        <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
+                                    {% endif %}
+                                    <th>Name</th>
+                                    <th>Type</th>
+                                    <th>Rear Port</th>
+                                    <th>Position</th>
+                                    <th></th>
+                                </tr>
+                            </thead>
+                            <tbody>
+                                {% for frontpanelport in front_panel_ports %}
+                                    {% include 'dcim/inc/frontpanelport.html' %}
+                                {% empty %}
+                                    <tr>
+                                        <td colspan="5" class="text-center text-muted">&mdash; No front panel ports defined &mdash;</td>
+                                    </tr>
+                                {% endfor %}
+                            </tbody>
+                        </table>
+                        <div class="panel-footer">
+                            {% if front_panel_ports and perms.dcim.change_frontpanelport %}
+                                <button type="submit" name="_rename" formaction="{% url 'dcim:frontpanelport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                                    <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
+                                </button>
+                            {% endif %}
+                            {% if front_panel_ports and perms.dcim.delete_frontpanelport %}
+                                <button type="submit" formaction="{% url 'dcim:frontpanelport_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                                    <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
+                                </button>
+                            {% endif %}
+                            {% if perms.dcim.add_frontpanelport %}
+                                <div class="pull-right">
+                                    <a href="{% url 'dcim:frontpanelport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add front panel ports
+                                    </a>
+                                </div>
+                                <div class="clearfix"></div>
+                            {% endif %}
+                        </div>
+                    </div>
+                </form>
+            {% endif %}
+            {% if rear_panel_ports or device.device_type.is_patch_panel %}
+                <form method="post">
+                {% csrf_token %}
+                    <div class="panel panel-default">
+                        <div class="panel-heading">
+                            <strong>Rear Panel Ports</strong>
+                        </div>
+                        <table class="table table-hover table-headings panel-body component-list">
+                            <thead>
+                                <tr>
+                                    {% if perms.dcim.change_rearpanelport or perms.dcim.delete_rearpanelport %}
+                                        <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
+                                    {% endif %}
+                                    <th>Name</th>
+                                    <th>Type</th>
+                                    <th>Positions</th>
+                                    <th></th>
+                                </tr>
+                            </thead>
+                            <tbody>
+                                {% for rearpanelport in rear_panel_ports %}
+                                    {% include 'dcim/inc/rearpanelport.html' %}
+                                {% empty %}
+                                    <tr>
+                                        <td colspan="5" class="text-center text-muted">&mdash; No rear panel ports defined &mdash;</td>
+                                    </tr>
+                                {% endfor %}
+                            </tbody>
+                        </table>
+                        <div class="panel-footer">
+                            {% if rear_panel_ports and perms.dcim.change_rearpanelport %}
+                                <button type="submit" name="_rename" formaction="{% url 'dcim:rearpanelport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                                    <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
+                                </button>
+                            {% endif %}
+                            {% if rear_panel_ports and perms.dcim.delete_rearpanelport %}
+                                <button type="submit" formaction="{% url 'dcim:rearpanelport_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                                    <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
+                                </button>
+                            {% endif %}
+                            {% if perms.dcim.add_rearpanelport %}
+                                <div class="pull-right">
+                                    <a href="{% url 'dcim:rearpanelport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add rear panel ports
+                                    </a>
+                                </div>
+                                <div class="clearfix"></div>
+                            {% endif %}
+                        </div>
+                    </div>
+                </form>
+            {% endif %}
         </div>
         </div>
     </div>
     </div>
 {% include 'inc/graphs_modal.html' %}
 {% include 'inc/graphs_modal.html' %}

+ 19 - 0
netbox/templates/dcim/devicetype.html

@@ -135,6 +135,19 @@
                         <small class="text-muted">This device {% if devicetype.is_network_device %}has{% else %}does not have{% endif %} network interfaces</small>
                         <small class="text-muted">This device {% if devicetype.is_network_device %}has{% else %}does not have{% endif %} network interfaces</small>
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td class="text-right">
+                        {% if devicetype.is_patch_panel %}
+                            <i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
+                        {% else %}
+                            <i class="glyphicon glyphicon-remove text-danger" title="No"></i>
+                        {% endif %}
+                    </td>
+                    <td>
+                        <strong>Patch Panel</strong><br />
+                        <small class="text-muted">This device {% if devicetype.is_patch_panel %}has{% else %}does not have{% endif %} patch panel ports</small>
+                    </td>
+                </tr>
                 <tr>
                 <tr>
                     <td class="text-right">
                     <td class="text-right">
                         {% if devicetype.subdevice_role == True %}
                         {% if devicetype.subdevice_role == True %}
@@ -188,6 +201,12 @@
         {% if devicetype.is_pdu or poweroutlet_table.rows %}
         {% if devicetype.is_pdu or poweroutlet_table.rows %}
             {% include 'dcim/inc/devicetype_component_table.html' with table=poweroutlet_table title='Power Outlets' add_url='dcim:devicetype_add_poweroutlet' delete_url='dcim:devicetype_delete_poweroutlet' %}
             {% include 'dcim/inc/devicetype_component_table.html' with table=poweroutlet_table title='Power Outlets' add_url='dcim:devicetype_add_poweroutlet' delete_url='dcim:devicetype_delete_poweroutlet' %}
         {% endif %}
         {% endif %}
+        {% if devicetype.is_patch_panel or front_panel_port_table.rows %}
+            {% include 'dcim/inc/devicetype_component_table.html' with table=front_panel_port_table title='Front Panel Ports' add_url='dcim:devicetype_add_frontpanelport' delete_url='dcim:devicetype_delete_frontpanelport' %}
+        {% endif %}
+        {% if devicetype.is_patch_panel or rear_panel_port_table.rows %}
+            {% include 'dcim/inc/devicetype_component_table.html' with table=rear_panel_port_table title='Rear Panel Ports' add_url='dcim:devicetype_add_rearpanelport' delete_url='dcim:devicetype_delete_rearpanelport' %}
+        {% endif %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

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

@@ -20,6 +20,7 @@
             {% render_field form.is_console_server %}
             {% render_field form.is_console_server %}
             {% render_field form.is_pdu %}
             {% render_field form.is_pdu %}
             {% render_field form.is_network_device %}
             {% render_field form.is_network_device %}
+            {% render_field form.is_patch_panel %}
             {% render_field form.subdevice_role %}
             {% render_field form.subdevice_role %}
         </div>
         </div>
     </div>
     </div>

+ 25 - 0
netbox/templates/dcim/inc/frontpanelport.html

@@ -0,0 +1,25 @@
+<tr class="frontpanelport">
+    {% if perms.dcim.change_frontpanelport or perms.dcim.delete_frontpanelport %}
+        <td class="pk">
+            <input name="pk" type="checkbox" value="{{ frontpanelport.pk }}" />
+        </td>
+    {% endif %}
+    <td>
+        <i class="fa fa-fw fa-square-o"></i> {{ frontpanelport }}
+    </td>
+    <td>{{ frontpanelport.get_type_display }}</td>
+    <td>{{ frontpanelport.rear_port }}</td>
+    <td>{{ frontpanelport.rear_port_position }}</td>
+    <td class="text-right">
+        {% if perms.dcim.change_frontpanelport %}
+            <a href="{% url 'dcim:frontpanelport_edit' pk=frontpanelport.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
+                <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
+            </a>
+        {% endif %}
+        {% if perms.dcim.delete_frontpanelport %}
+            <a href="{% url 'dcim:frontpanelport_delete' pk=frontpanelport.pk %}?return_url={{ device.get_absolute_url }}" title="Delete port" class="btn btn-danger btn-xs">
+                <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
+            </a>
+        {% endif %}
+    </td>
+</tr>

+ 24 - 0
netbox/templates/dcim/inc/rearpanelport.html

@@ -0,0 +1,24 @@
+<tr class="rearpanelport">
+    {% if perms.dcim.change_rearpanelport or perms.dcim.delete_rearpanelport %}
+        <td class="pk">
+            <input name="pk" type="checkbox" value="{{ rearpanelport.pk }}" />
+        </td>
+    {% endif %}
+    <td>
+        <i class="fa fa-fw fa-square-o"></i> {{ rearpanelport }}
+    </td>
+    <td>{{ rearpanelport.get_type_display }}</td>
+    <td>{{ rearpanelport.positions }}</td>
+    <td class="text-right">
+        {% if perms.dcim.change_rearpanelport %}
+            <a href="{% url 'dcim:rearpanelport_edit' pk=rearpanelport.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
+                <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
+            </a>
+        {% endif %}
+        {% if perms.dcim.delete_rearpanelport %}
+            <a href="{% url 'dcim:rearpanelport_delete' pk=rearpanelport.pk %}?return_url={{ device.get_absolute_url }}" title="Delete port" class="btn btn-danger btn-xs">
+                <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
+            </a>
+        {% endif %}
+    </td>
+</tr>

+ 3 - 0
netbox/utilities/forms.py

@@ -643,6 +643,9 @@ class ComponentForm(BootstrapMixin, forms.Form):
         self.parent = parent
         self.parent = parent
         super(ComponentForm, self).__init__(*args, **kwargs)
         super(ComponentForm, self).__init__(*args, **kwargs)
 
 
+    def get_iterative_data(self, iteration):
+        return {}
+
 
 
 class BulkEditForm(forms.Form):
 class BulkEditForm(forms.Form):
     """
     """

+ 2 - 1
netbox/utilities/views.py

@@ -711,10 +711,11 @@ class ComponentCreateView(View):
             data = deepcopy(request.POST)
             data = deepcopy(request.POST)
             data[self.parent_field] = parent.pk
             data[self.parent_field] = parent.pk
 
 
-            for name in form.cleaned_data['name_pattern']:
+            for i, name in enumerate(form.cleaned_data['name_pattern']):
 
 
                 # Initialize the individual component form
                 # Initialize the individual component form
                 data['name'] = name
                 data['name'] = name
+                data.update(form.get_iterative_data(i))
                 component_form = self.model_form(data)
                 component_form = self.model_form(data)
 
 
                 if component_form.is_valid():
                 if component_form.is_valid():