From 3eddeeadc56a24e4b7574ea918159c0660943e05 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Oct 2018 14:04:16 -0400 Subject: [PATCH] Initial work on #20 - patch panels --- netbox/dcim/api/serializers.py | 104 +++++++- netbox/dcim/api/urls.py | 4 + netbox/dcim/api/views.py | 31 ++- netbox/dcim/constants.py | 30 +++ netbox/dcim/filters.py | 35 ++- netbox/dcim/forms.py | 231 +++++++++++++++++- .../dcim/migrations/0065_patch_panel_ports.py | 114 +++++++++ netbox/dcim/models.py | 201 ++++++++++++++- netbox/dcim/tables.py | 44 +++- netbox/dcim/urls.py | 24 ++ netbox/dcim/views.py | 137 ++++++++++- netbox/templates/dcim/device.html | 105 ++++++++ netbox/templates/dcim/devicetype.html | 19 ++ netbox/templates/dcim/devicetype_edit.html | 1 + netbox/templates/dcim/inc/frontpanelport.html | 25 ++ netbox/templates/dcim/inc/rearpanelport.html | 24 ++ netbox/utilities/forms.py | 3 + netbox/utilities/views.py | 3 +- 18 files changed, 1101 insertions(+), 34 deletions(-) create mode 100644 netbox/dcim/migrations/0065_patch_panel_ports.py create mode 100644 netbox/templates/dcim/inc/frontpanelport.html create mode 100644 netbox/templates/dcim/inc/rearpanelport.html diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 696464662..ed9ee29ea 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -3,15 +3,13 @@ from rest_framework.validators import UniqueTogetherValidator from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField 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 ( 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 ipam.models import IPAddress, VLAN @@ -229,8 +227,8 @@ class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): model = DeviceType fields = [ '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'] +# +# 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 # @@ -634,6 +675,51 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): 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 # diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 6456d53a4..0e6a5d344 100644 --- a/netbox/dcim/api/urls.py +++ b/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-outlet-templates', views.PowerOutletTemplateViewSet) 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) # Devices @@ -50,6 +52,8 @@ router.register(r'console-server-ports', views.ConsoleServerPortViewSet) router.register(r'power-ports', views.PowerPortViewSet) router.register(r'power-outlets', views.PowerOutletViewSet) 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'inventory-items', views.InventoryItemViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 4fb9c3f20..a9f4c6f9f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -14,9 +14,10 @@ from rest_framework.viewsets import GenericViewSet, ViewSet from dcim import filters from dcim.models import ( 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.views import CustomFieldModelViewSet @@ -191,6 +192,18 @@ class InterfaceTemplateViewSet(ModelViewSet): 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): queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.DeviceBayTemplateSerializer @@ -352,6 +365,18 @@ class InterfaceViewSet(ModelViewSet): 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): queryset = DeviceBay.objects.select_related('installed_device').prefetch_related('tags') serializer_class = serializers.DeviceBaySerializer diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index a3226a6b2..d51ec97f3 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -209,6 +209,36 @@ IFACE_MODE_CHOICES = [ [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_STATUS_OFFLINE = 0 DEVICE_STATUS_ACTIVE = 1 diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 60cbbfcc1..3b370654e 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -14,9 +14,10 @@ from .constants import ( ) from .models import ( 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'] +class FrontPanelPortTemplateFilter(DeviceTypeComponentFilterSet): + + class Meta: + model = FrontPanelPortTemplate + fields = ['name', 'type'] + + +class RearPanelPortTemplateFilter(DeviceTypeComponentFilterSet): + + class Meta: + model = RearPanelPortTemplate + fields = ['name', 'type'] + + class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet): class Meta: @@ -667,6 +682,20 @@ class InterfaceFilter(django_filters.FilterSet): 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 Meta: diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 923079634..58f17c0a6 100644 --- a/netbox/dcim/forms.py +++ b/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.db.models import Count, Q from mptt.forms import TreeNodeChoiceField +from natsort import natsorted +from operator import attrgetter from taggit.forms import TagField from timezone_field import TimeZoneFormField @@ -19,17 +21,13 @@ from utilities.forms import ( FlexibleModelChoiceField, JSONField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField, ) 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 ( 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+\}' @@ -532,7 +530,7 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm): model = DeviceType fields = [ '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 = { 'interface_ordering': 'Order interfaces by', @@ -582,6 +580,9 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkE is_network_device = forms.NullBooleanField( 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: nullable_fields = [] @@ -602,6 +603,9 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): is_network_device = forms.BooleanField( 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( required=False, label='Subdevice role', widget=forms.Select(choices=( ('', '---------'), @@ -696,6 +700,97 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): 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 Meta: @@ -2087,6 +2182,122 @@ class InterfaceConnectionCSVForm(forms.ModelForm): 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 # diff --git a/netbox/dcim/migrations/0065_patch_panel_ports.py b/netbox/dcim/migrations/0065_patch_panel_ports.py new file mode 100644 index 000000000..48b77e561 --- /dev/null +++ b/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')}, + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index cd471a834..cdbf78525 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -769,6 +769,11 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): verbose_name='Is a network device', 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( default=None, verbose_name='Parent/child status', @@ -789,7 +794,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): csv_headers = [ '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: @@ -822,6 +827,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): self.is_console_server, self.is_pdu, self.is_network_device, + self.is_patch_panel, self.get_subdevice_role_display() if self.subdevice_role else None, self.get_interface_ordering_display(), self.comments, @@ -861,6 +867,14 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): "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(): raise ValidationError({ 'subdevice_role': "Must delete all device bay templates associated with this device before " @@ -1000,6 +1014,86 @@ class InterfaceTemplate(ComponentTemplateModel): 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): """ 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, 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(device=self, name=template.name) for template in self.device_type.device_bay_templates.all()] @@ -2040,6 +2151,94 @@ class InterfaceConnection(models.Model): ).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 # diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 8c3606713..a1a2c7a4d 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -5,9 +5,10 @@ from tenancy.tables import COL_TENANT from utilities.tables import BaseTable, BooleanColumn, ToggleColumn from .models import ( 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 = """ @@ -348,6 +349,7 @@ class DeviceTypeTable(BaseTable): is_console_server = BooleanColumn(verbose_name='CS') is_pdu = BooleanColumn(verbose_name='PDU') is_network_device = BooleanColumn(verbose_name='Net') + is_patch_panel = BooleanColumn(verbose_name='PP') subdevice_role = tables.TemplateColumn( template_code=SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role' @@ -361,7 +363,7 @@ class DeviceTypeTable(BaseTable): model = DeviceType fields = ( '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" +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): pk = ToggleColumn() @@ -574,6 +594,22 @@ class InterfaceTable(BaseTable): 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 Meta(BaseTable.Meta): diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 4ba91f215..2d9c8d009 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -109,6 +109,14 @@ urlpatterns = [ url(r'^device-types/(?P\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'), url(r'^device-types/(?P\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'), + # Front panel port templates + url(r'^device-types/(?P\d+)/front-panel-ports/add/$', views.FrontPanelPortTemplateCreateView.as_view(), name='devicetype_add_frontpanelport'), + url(r'^device-types/(?P\d+)/front-panel-ports/delete/$', views.FrontPanelPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_frontpanelport'), + + # Front panel port templates + url(r'^device-types/(?P\d+)/rear-panel-ports/add/$', views.RearPanelPortTemplateCreateView.as_view(), name='devicetype_add_rearpanelport'), + url(r'^device-types/(?P\d+)/rear-panel-ports/delete/$', views.RearPanelPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_rearpanelport'), + # Device bay templates url(r'^device-types/(?P\d+)/device-bays/add/$', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'), url(r'^device-types/(?P\d+)/device-bays/delete/$', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'), @@ -204,6 +212,22 @@ urlpatterns = [ url(r'^interfaces/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), 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\d+)/front-panel-ports/add/$', views.FrontPanelPortCreateView.as_view(), name='frontpanelport_add'), + url(r'^devices/(?P\d+)/front-panel-ports/delete/$', views.FrontPanelPortBulkDeleteView.as_view(), name='frontpanelport_bulk_delete'), + url(r'^front-panel-ports/(?P\d+)/edit/$', views.FrontPanelPortEditView.as_view(), name='frontpanelport_edit'), + url(r'^front-panel-ports/(?P\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\d+)/rear-panel-ports/add/$', views.RearPanelPortCreateView.as_view(), name='rearpanelport_add'), + url(r'^devices/(?P\d+)/rear-panel-ports/delete/$', views.RearPanelPortBulkDeleteView.as_view(), name='rearpanelport_bulk_delete'), + url(r'^rear-panel-ports/(?P\d+)/edit/$', views.RearPanelPortEditView.as_view(), name='rearpanelport_edit'), + url(r'^rear-panel-ports/(?P\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 url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), url(r'^devices/(?P\d+)/bays/add/$', views.DeviceBayCreateView.as_view(), name='devicebay_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 62f610ce5..c7e57b89f 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -31,9 +31,10 @@ from . import filters, forms, tables from .constants import CONNECTION_STATUS_CONNECTED from .models import ( 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)), 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( natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), orderable=False @@ -569,6 +578,8 @@ class DeviceTypeView(View): powerport_table.columns.show('pk') poweroutlet_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') return render(request, 'dcim/devicetype.html', { @@ -578,6 +589,8 @@ class DeviceTypeView(View): 'powerport_table': powerport_table, 'poweroutlet_table': poweroutlet_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, }) @@ -721,6 +734,40 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): 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): permission_required = 'dcim.add_devicebaytemplate' parent_model = DeviceType @@ -859,6 +906,12 @@ class DeviceView(View): 'circuit_termination__circuit' ).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 = natsorted( DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'), @@ -891,6 +944,8 @@ class DeviceView(View): 'power_outlets': power_outlets, 'interfaces': interfaces, 'device_bays': device_bays, + 'front_panel_ports': front_panel_ports, + 'rear_panel_ports': rear_panel_ports, 'services': services, 'secrets': secrets, 'vc_members': vc_members, @@ -1701,6 +1756,82 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): 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 # diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 7b56269b1..50f8ebbb5 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -689,6 +689,111 @@ {% endif %} {% endif %} + {% if front_panel_ports or device.device_type.is_patch_panel %} +
+ {% csrf_token %} +
+
+ Front Panel Ports +
+ + + + {% if perms.dcim.change_frontpanelport or perms.dcim.delete_frontpanelport %} + + {% endif %} + + + + + + + + + {% for frontpanelport in front_panel_ports %} + {% include 'dcim/inc/frontpanelport.html' %} + {% empty %} + + + + {% endfor %} + +
NameTypeRear PortPosition
— No front panel ports defined —
+ +
+
+ {% endif %} + {% if rear_panel_ports or device.device_type.is_patch_panel %} +
+ {% csrf_token %} +
+
+ Rear Panel Ports +
+ + + + {% if perms.dcim.change_rearpanelport or perms.dcim.delete_rearpanelport %} + + {% endif %} + + + + + + + + {% for rearpanelport in rear_panel_ports %} + {% include 'dcim/inc/rearpanelport.html' %} + {% empty %} + + + + {% endfor %} + +
NameTypePositions
— No rear panel ports defined —
+ +
+
+ {% endif %} {% include 'inc/graphs_modal.html' %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 652c291e6..9d7f94b24 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -135,6 +135,19 @@ This device {% if devicetype.is_network_device %}has{% else %}does not have{% endif %} network interfaces + + + {% if devicetype.is_patch_panel %} + + {% else %} + + {% endif %} + + + Patch Panel
+ This device {% if devicetype.is_patch_panel %}has{% else %}does not have{% endif %} patch panel ports + + {% if devicetype.subdevice_role == True %} @@ -188,6 +201,12 @@ {% 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' %} {% 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 %} {% endblock %} diff --git a/netbox/templates/dcim/devicetype_edit.html b/netbox/templates/dcim/devicetype_edit.html index d0ed2c204..14b8103e3 100644 --- a/netbox/templates/dcim/devicetype_edit.html +++ b/netbox/templates/dcim/devicetype_edit.html @@ -20,6 +20,7 @@ {% render_field form.is_console_server %} {% render_field form.is_pdu %} {% render_field form.is_network_device %} + {% render_field form.is_patch_panel %} {% render_field form.subdevice_role %} diff --git a/netbox/templates/dcim/inc/frontpanelport.html b/netbox/templates/dcim/inc/frontpanelport.html new file mode 100644 index 000000000..bef4c24c7 --- /dev/null +++ b/netbox/templates/dcim/inc/frontpanelport.html @@ -0,0 +1,25 @@ + + {% if perms.dcim.change_frontpanelport or perms.dcim.delete_frontpanelport %} + + + + {% endif %} + + {{ frontpanelport }} + + {{ frontpanelport.get_type_display }} + {{ frontpanelport.rear_port }} + {{ frontpanelport.rear_port_position }} + + {% if perms.dcim.change_frontpanelport %} + + + + {% endif %} + {% if perms.dcim.delete_frontpanelport %} + + + + {% endif %} + + diff --git a/netbox/templates/dcim/inc/rearpanelport.html b/netbox/templates/dcim/inc/rearpanelport.html new file mode 100644 index 000000000..a781de727 --- /dev/null +++ b/netbox/templates/dcim/inc/rearpanelport.html @@ -0,0 +1,24 @@ + + {% if perms.dcim.change_rearpanelport or perms.dcim.delete_rearpanelport %} + + + + {% endif %} + + {{ rearpanelport }} + + {{ rearpanelport.get_type_display }} + {{ rearpanelport.positions }} + + {% if perms.dcim.change_rearpanelport %} + + + + {% endif %} + {% if perms.dcim.delete_rearpanelport %} + + + + {% endif %} + + diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index ac8597dc5..966a0095e 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -643,6 +643,9 @@ class ComponentForm(BootstrapMixin, forms.Form): self.parent = parent super(ComponentForm, self).__init__(*args, **kwargs) + def get_iterative_data(self, iteration): + return {} + class BulkEditForm(forms.Form): """ diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index df0ca63cf..0e36f3220 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -711,10 +711,11 @@ class ComponentCreateView(View): data = deepcopy(request.POST) 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 data['name'] = name + data.update(form.get_iterative_data(i)) component_form = self.model_form(data) if component_form.is_valid():