diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index d6d5be7b4..78d7973b2 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -4,6 +4,7 @@ from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms.array import SimpleArrayField +from django.core.exceptions import ObjectDoesNotExist from django.db.models import Count, Q from mptt.forms import TreeNodeChoiceField from natsort import natsorted @@ -1271,157 +1272,6 @@ class ConsolePortCreateForm(ComponentForm): tags = TagField(required=False) -class ConsoleConnectionCSVForm(forms.ModelForm): - console_server = FlexibleModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text='Console server name or ID', - error_messages={ - 'invalid_choice': 'Console server not found', - } - ) - connected_endpoint = forms.CharField( - help_text='Console server port' - ) - device = FlexibleModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text='Device name or ID', - error_messages={ - 'invalid_choice': 'Device not found', - } - ) - console_port = forms.CharField( - help_text='Console port name' - ) - connection_status = CSVChoiceField( - choices=CONNECTION_STATUS_CHOICES, - help_text='Connection status' - ) - - class Meta: - model = ConsolePort - fields = ['console_server', 'connected_endpoint', 'device', 'console_port', 'connection_status'] - - def clean_console_port(self): - - console_port_name = self.cleaned_data.get('console_port') - if not self.cleaned_data.get('device') or not console_port_name: - return None - - try: - # Retrieve console port by name - consoleport = ConsolePort.objects.get( - device=self.cleaned_data['device'], name=console_port_name - ) - # Check if the console port is already connected - if consoleport.connected_endpoint is not None: - raise forms.ValidationError("{} {} is already connected".format( - self.cleaned_data['device'], console_port_name - )) - except ConsolePort.DoesNotExist: - raise forms.ValidationError("Invalid console port ({} {})".format( - self.cleaned_data['device'], console_port_name - )) - - self.instance = consoleport - return consoleport - - def clean_connected_endpoint(self): - - consoleserverport_name = self.cleaned_data.get('connected_endpoint') - if not self.cleaned_data.get('console_server') or not consoleserverport_name: - return None - - try: - # Retrieve console server port by name - consoleserverport = ConsoleServerPort.objects.get( - device=self.cleaned_data['console_server'], name=consoleserverport_name - ) - # Check if the console server port is already connected - if ConsolePort.objects.filter(connected_endpoint=consoleserverport).count(): - raise forms.ValidationError("{} {} is already connected".format( - self.cleaned_data['console_server'], consoleserverport_name - )) - except ConsoleServerPort.DoesNotExist: - raise forms.ValidationError("Invalid console server port ({} {})".format( - self.cleaned_data['console_server'], consoleserverport_name - )) - - return consoleserverport - - -class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - widget=forms.Select( - attrs={'filter-for': 'rack'} - ) - ) - rack = ChainedModelChoiceField( - queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - label='Rack', - required=False, - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'console_server', 'nullable': 'true'} - ) - ) - console_server = ChainedModelChoiceField( - queryset=Device.objects.all(), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - label='Console Server', - required=False, - widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', - display_field='display_name', - attrs={'filter-for': 'connected_endpoint'} - ) - ) - livesearch = forms.CharField( - required=False, - label='Console Server', - widget=Livesearch( - query_key='q', - query_url='dcim-api:device-list', - field_to_update='console_server', - ) - ) - connected_endpoint = ChainedModelChoiceField( - queryset=ConsoleServerPort.objects.all(), - chains=( - ('device', 'console_server'), - ), - label='Port', - widget=APISelect( - api_url='/api/dcim/console-server-ports/?device_id={{console_server}}', - disabled_indicator='cable', - ) - ) - - class Meta: - model = ConsolePort - fields = ['site', 'rack', 'console_server', 'livesearch', 'connected_endpoint', 'connection_status'] - labels = { - 'connected_endpoint': 'Port', - 'connection_status': 'Status', - } - - def __init__(self, *args, **kwargs): - - super(ConsolePortConnectionForm, self).__init__(*args, **kwargs) - - if not self.instance.pk: - raise RuntimeError("ConsolePortConnectionForm must be initialized with an existing ConsolePort instance.") - - # # Console server ports # @@ -1442,76 +1292,6 @@ class ConsoleServerPortCreateForm(ComponentForm): tags = TagField(required=False) -class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - widget=forms.Select( - attrs={'filter-for': 'rack'} - ) - ) - rack = ChainedModelChoiceField( - queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - label='Rack', - required=False, - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'device', 'nullable': 'true'} - ) - ) - device = ChainedModelChoiceField( - queryset=Device.objects.all(), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - label='Device', - required=False, - widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', - display_field='display_name', - attrs={'filter-for': 'port'} - ) - ) - livesearch = forms.CharField( - required=False, - label='Device', - widget=Livesearch( - query_key='q', - query_url='dcim-api:device-list', - field_to_update='device' - ) - ) - port = ChainedModelChoiceField( - queryset=ConsolePort.objects.all(), - chains=( - ('device', 'device'), - ), - label='Port', - widget=APISelect( - api_url='/api/dcim/console-ports/?device_id={{device}}', - disabled_indicator='cable' - ) - ) - connection_status = forms.BooleanField( - required=False, - initial=CONNECTION_STATUS_CONNECTED, - label='Status', - widget=forms.Select( - choices=CONNECTION_STATUS_CHOICES - ) - ) - - class Meta: - fields = ['site', 'rack', 'device', 'livesearch', 'port', 'connection_status'] - labels = { - 'connection_status': 'Status', - } - - class ConsoleServerPortBulkRenameForm(BulkRenameForm): pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput) @@ -1540,157 +1320,6 @@ class PowerPortCreateForm(ComponentForm): tags = TagField(required=False) -class PowerConnectionCSVForm(forms.ModelForm): - pdu = FlexibleModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text='PDU name or ID', - error_messages={ - 'invalid_choice': 'PDU not found.', - } - ) - connected_endpoint = forms.CharField( - help_text='Power outlet name' - ) - device = FlexibleModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text='Device name or ID', - error_messages={ - 'invalid_choice': 'Device not found', - } - ) - power_port = forms.CharField( - help_text='Power port name' - ) - connection_status = CSVChoiceField( - choices=CONNECTION_STATUS_CHOICES, - help_text='Connection status' - ) - - class Meta: - model = PowerPort - fields = ['pdu', 'connected_endpoint', 'device', 'power_port', 'connection_status'] - - def clean_power_port(self): - - power_port_name = self.cleaned_data.get('power_port') - if not self.cleaned_data.get('device') or not power_port_name: - return None - - try: - # Retrieve power port by name - powerport = PowerPort.objects.get( - device=self.cleaned_data['device'], name=power_port_name - ) - # Check if the power port is already connected - if powerport.connected_endpoint is not None: - raise forms.ValidationError("{} {} is already connected".format( - self.cleaned_data['device'], power_port_name - )) - except PowerPort.DoesNotExist: - raise forms.ValidationError("Invalid power port ({} {})".format( - self.cleaned_data['device'], power_port_name - )) - - self.instance = powerport - return powerport - - def clean_connected_endpoint(self): - - poweroutlet_name = self.cleaned_data.get('connected_endpoint') - if not self.cleaned_data.get('pdu') or not poweroutlet_name: - return None - - try: - # Retrieve power outlet by name - poweroutlet = PowerOutlet.objects.get( - device=self.cleaned_data['pdu'], name=poweroutlet_name - ) - # Check if the power outlet is already connected - if PowerPort.objects.filter(connected_endpoint=poweroutlet).count(): - raise forms.ValidationError("{} {} is already connected".format( - self.cleaned_data['pdu'], poweroutlet_name - )) - except PowerOutlet.DoesNotExist: - raise forms.ValidationError("Invalid power outlet ({} {})".format( - self.cleaned_data['pdu'], poweroutlet_name - )) - - return poweroutlet - - -class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - widget=forms.Select( - attrs={'filter-for': 'rack'} - ) - ) - rack = ChainedModelChoiceField( - queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - label='Rack', - required=False, - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'pdu', 'nullable': 'true'} - ) - ) - pdu = ChainedModelChoiceField( - queryset=Device.objects.all(), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - label='PDU', - required=False, - widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', - display_field='display_name', - attrs={'filter-for': 'connected_endpoint'} - ) - ) - livesearch = forms.CharField( - required=False, - label='PDU', - widget=Livesearch( - query_key='q', - query_url='dcim-api:device-list', - field_to_update='pdu' - ) - ) - connected_endpoint = ChainedModelChoiceField( - queryset=PowerOutlet.objects.all(), - chains=( - ('device', 'pdu'), - ), - label='Outlet', - widget=APISelect( - api_url='/api/dcim/power-outlets/?device_id={{pdu}}', - disabled_indicator='cable' - ) - ) - - class Meta: - model = PowerPort - fields = ['site', 'rack', 'pdu', 'livesearch', 'connected_endpoint', 'connection_status'] - labels = { - 'connected_endpoint': 'Outlet', - 'connection_status': 'Status', - } - - def __init__(self, *args, **kwargs): - - super(PowerPortConnectionForm, self).__init__(*args, **kwargs) - - if not self.instance.pk: - raise RuntimeError("PowerPortConnectionForm must be initialized with an existing PowerPort instance.") - - # # Power outlets # @@ -1711,76 +1340,6 @@ class PowerOutletCreateForm(ComponentForm): tags = TagField(required=False) -class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - widget=forms.Select( - attrs={'filter-for': 'rack'} - ) - ) - rack = ChainedModelChoiceField( - queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - label='Rack', - required=False, - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'device', 'nullable': 'true'} - ) - ) - device = ChainedModelChoiceField( - queryset=Device.objects.all(), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - label='Device', - required=False, - widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', - display_field='display_name', - attrs={'filter-for': 'port'} - ) - ) - livesearch = forms.CharField( - required=False, - label='Device', - widget=Livesearch( - query_key='q', - query_url='dcim-api:device-list', - field_to_update='device' - ) - ) - port = ChainedModelChoiceField( - queryset=PowerPort.objects.all(), - chains=( - ('device', 'device'), - ), - label='Port', - widget=APISelect( - api_url='/api/dcim/power-ports/?device_id={{device}}', - disabled_indicator='cable' - ) - ) - connection_status = forms.BooleanField( - required=False, - initial=CONNECTION_STATUS_CONNECTED, - label='Status', - widget=forms.Select( - choices=CONNECTION_STATUS_CHOICES - ) - ) - - class Meta: - fields = ['site', 'rack', 'device', 'livesearch', 'port', 'connection_status'] - labels = { - 'connection_status': 'Status', - } - - class PowerOutletBulkRenameForm(BulkRenameForm): pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput) @@ -2199,6 +1758,108 @@ class CableForm(BootstrapMixin, forms.ModelForm): fields = ('type', 'status', 'label', 'color', 'length', 'length_unit') +class CableCSVForm(forms.ModelForm): + + # Termination A + side_a_device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Console server name or ID', + error_messages={ + 'invalid_choice': 'Side A device not found', + } + ) + side_a_type = forms.ModelChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to={'model__in': CABLE_TERMINATION_TYPES}, + to_field_name='model' + ) + side_a_name = forms.CharField() + + # Termination B + side_b_device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Console server name or ID', + error_messages={ + 'invalid_choice': 'Side B device not found', + } + ) + side_b_type = forms.ModelChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to={'model__in': CABLE_TERMINATION_TYPES}, + to_field_name='model' + ) + side_b_name = forms.CharField() + + # Cable attributes + status = CSVChoiceField( + choices=CONNECTION_STATUS_CHOICES, + required=False, + help_text='Connection status' + ) + + class Meta: + model = Cable + fields = [ + 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'status', + 'label', + ] + + # TODO: Merge the clean() methods for either end + def clean_side_a_name(self): + + device = self.cleaned_data.get('side_a_device') + content_type = self.cleaned_data.get('side_a_type') + name = self.cleaned_data.get('side_a_name') + if not device or not content_type or not name: + return None + + model = content_type.model_class() + try: + termination_object = model.objects.get( + device=device, + name=name + ) + if termination_object.cable is not None: + raise forms.ValidationError( + "Side A: {} {} is already connected".format(device, termination_object) + ) + except ObjectDoesNotExist: + raise forms.ValidationError( + "A side termination not found: {} {}".format(device, name) + ) + + self.instance.termination_a = termination_object + return termination_object + + def clean_side_b_name(self): + + device = self.cleaned_data.get('side_b_device') + content_type = self.cleaned_data.get('side_b_type') + name = self.cleaned_data.get('side_b_name') + if not device or not content_type or not name: + return None + + model = content_type.model_class() + try: + termination_object = model.objects.get( + device=device, + name=name + ) + if termination_object.cable is not None: + raise forms.ValidationError( + "Side B: {} {} is already connected".format(device, termination_object) + ) + except ObjectDoesNotExist: + raise forms.ValidationError( + "B side termination not found: {} {}".format(device, name) + ) + + self.instance.termination_b = termination_object + return termination_object + + class CableFilterForm(BootstrapMixin, forms.Form): model = Cable q = forms.CharField(required=False, label='Search') diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index c06bc4a30..8a49da199 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -251,17 +251,15 @@ urlpatterns = [ # Cables url(r'^cables/$', views.CableListView.as_view(), name='cable_list'), + url(r'^cables/import/$', views.CableBulkImportView.as_view(), name='cable_import'), url(r'^cables/(?P\d+)/$', views.CableView.as_view(), name='cable'), url(r'^cables/(?P\d+)/edit/$', views.CableEditView.as_view(), name='cable_edit'), url(r'^cables/(?P\d+)/delete/$', views.CableDeleteView.as_view(), name='cable_delete'), - # Console/power/interface connections + # Console/power/interface connections (read-only) url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), - # url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'), url(r'^power-connections/$', views.PowerConnectionsListView.as_view(), name='power_connections_list'), - # url(r'^power-connections/import/$', views.PowerConnectionsBulkImportView.as_view(), name='power_connections_import'), url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'), - # url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'), # Virtual chassis url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 6c419f66c..6f1b3aea5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1121,13 +1121,6 @@ class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.ConsolePortTable -class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.change_consoleport' - model_form = forms.ConsoleConnectionCSVForm - table = tables.ConsoleConnectionTable - default_return_url = 'dcim:console_connections_list' - - # # Console server ports # @@ -1212,13 +1205,6 @@ class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.PowerPortTable -class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.change_powerport' - model_form = forms.PowerConnectionCSVForm - table = tables.PowerConnectionTable - default_return_url = 'dcim:power_connections_list' - - # # Power outlets # @@ -1645,6 +1631,21 @@ class CableView(View): }) +class CableTraceView(View): + """ + Trace a cable path beginning from the given termination. + """ + + def get(self, request, model, pk): + + obj = get_object_or_404(model, pk=pk) + + return render(request, 'dcim/cable_trace.html', { + 'obj': obj, + 'trace': obj.trace(), + }) + + class CableCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_cable' model = Cable @@ -1674,19 +1675,11 @@ class CableDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'dcim:cable_list' -class CableTraceView(View): - """ - Trace a cable path beginning from the given termination. - """ - - def get(self, request, model, pk): - - obj = get_object_or_404(model, pk=pk) - - return render(request, 'dcim/cable_trace.html', { - 'obj': obj, - 'trace': obj.trace(), - }) +class CableBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_cable' + model_form = forms.CableCSVForm + table = tables.CableTable + default_return_url = 'dcim:cable_list' # diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index ef6329564..64f68e7e2 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -180,6 +180,11 @@
  • + {% if perms.dcim.add_cable %} +
    + +
    + {% endif %} Cables