From 471bddea097a6fbd1d2128140919d2ebdeea8234 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Oct 2018 16:58:24 -0400 Subject: [PATCH] WIP: Initial work on the cable connection form --- netbox/dcim/constants.py | 30 ++++- netbox/dcim/forms.py | 93 ++++++++++++++- netbox/dcim/models.py | 4 +- netbox/dcim/urls.py | 12 +- netbox/dcim/views.py | 27 ++++- netbox/project-static/js/forms.js | 5 +- netbox/templates/dcim/cable_connect.html | 106 ++++++++++++++++++ netbox/templates/dcim/inc/consoleport.html | 2 +- .../templates/dcim/inc/consoleserverport.html | 2 +- netbox/templates/dcim/inc/poweroutlet.html | 2 +- netbox/templates/dcim/inc/powerport.html | 2 +- netbox/utilities/forms.py | 9 ++ .../templates/widgets/select_contenttype.html | 1 + 13 files changed, 275 insertions(+), 20 deletions(-) create mode 100644 netbox/templates/dcim/cable_connect.html create mode 100644 netbox/utilities/templates/widgets/select_contenttype.html diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index d63735347..7b11028d4 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -284,12 +284,9 @@ CONNECTION_STATUS_CHOICES = [ ] # Cable endpoint types -CABLE_ENDPOINT_TYPES = ( - 'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', -) -CABLE_CONNECTION_TYPES = CABLE_ENDPOINT_TYPES + ( - 'frontpanelport', 'rearpanelport', -) +CABLE_ENDPOINT_TYPES = [ + 'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontpanelport', 'rearpanelport', +] # Cable types # TODO: Add more types @@ -299,3 +296,24 @@ CABLE_TYPE_CHOICES = ( (CABLE_TYPE_COPPER, 'Copper'), (CABLE_TYPE_FIBER, 'Fiber'), ) + +CABLE_ENDPOINT_TYPE_CHOICES = { + # (API endpoint, human-friendly name) + 'consoleport': ('console-ports', 'Console port'), + 'consoleserverport': ('console-server-ports', 'Console server port'), + 'powerport': ('power-ports', 'Power port'), + 'poweroutlet': ('power-outlets', 'Power outlet'), + 'interface': ('interfaces', 'Interface'), + 'frontpanelport': ('front-panel-ports', 'Front panel port'), + 'rearpanelport': ('rear-panel-ports', 'Rear panel port'), +} + +COMPATIBLE_ENDPOINT_TYPES = { + 'consoleport': ['consoleserverport', 'frontpanelport', 'rearpanelport'], + 'consoleserverport': ['consoleport', 'frontpanelport', 'rearpanelport'], + 'powerport': ['poweroutlet'], + 'poweroutlet': ['powerport'], + 'interface': ['interface', 'frontpanelport', 'rearpanelport'], + 'frontpanelport': ['consoleport', 'consoleserverport', 'interface', 'frontpanelport', 'rearpanelport'], + 'rearpanelport': ['consoleport', 'consoleserverport', 'interface', 'frontpanelport', 'rearpanelport'], +} diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 79913b70c..9565d5ef0 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2,6 +2,7 @@ import re 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.db.models import Count, Q from mptt.forms import TreeNodeChoiceField @@ -19,11 +20,12 @@ from utilities.forms import ( BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, JSONField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField, + ContentTypeSelect ) from virtualization.models import Cluster from .constants import * from .models import ( - DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, + Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site, @@ -2298,6 +2300,95 @@ class RearPanelPortBulkRenameForm(BulkRenameForm): ) +# +# Cables +# + +class CableForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): + endpoint_b_site = forms.ModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False, + widget=forms.Select( + attrs={'filter-for': 'endpoint_b_rack'} + ) + ) + endpoint_b_rack = ChainedModelChoiceField( + queryset=Rack.objects.all(), + chains=( + ('site', 'endpoint_b_site'), + ), + label='Rack', + required=False, + widget=APISelect( + api_url='/api/dcim/racks/?site_id={{endpoint_b_site}}', + attrs={'filter-for': 'endpoint_b_device', 'nullable': 'true'} + ) + ) + endpoint_b_device = ChainedModelChoiceField( + queryset=Device.objects.all(), + chains=( + ('site', 'endpoint_b_site'), + ('rack', 'endpoint_b_rack'), + ), + label='Device', + required=False, + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{endpoint_b_site}}&rack_id={{endpoint_b_rack}}', + display_field='display_name', + attrs={'filter-for': 'endpoint_b_id'} + ) + ) + livesearch = forms.CharField( + required=False, + label='Device', + widget=Livesearch( + query_key='q', + query_url='dcim-api:device-list', + field_to_update='endpoint_b_device' + ) + ) + endpoint_b_type = forms.ModelChoiceField( + queryset=ContentType.objects.all(), + label='Type', + widget=ContentTypeSelect( + attrs={'filter-for': 'endpoint_b_id'} + ) + ) + endpoint_b_id = forms.ChoiceField( + choices=[], + label='Name', + widget=APISelect( + api_url='/api/dcim/{{endpoint_b_type}}s/?device_id={{endpoint_b_device}}', + disabled_indicator='is_connected' + ) + ) + + class Meta: + model = Cable + fields = [ + 'endpoint_b_site', 'endpoint_b_rack', 'endpoint_b_device', 'livesearch', 'endpoint_b_type', + 'endpoint_b_id', 'status', 'label', + ] + + def __init__(self, *args, **kwargs): + super(CableForm, self).__init__(*args, **kwargs) + + # Define available types for endpoint B based on the type of endpoint A + endpoint_a_type = self.instance.endpoint_a._meta.model_name + self.fields['endpoint_b_type'].queryset = ContentType.objects.filter( + model__in=COMPATIBLE_ENDPOINT_TYPES.get(endpoint_a_type) + ) + + def clean(self): + + # Assign endpoint B + cleaned_data = super(CableForm, self).clean() + + + + + # # Device bays # diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 422f723c7..bc3812c29 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -2455,7 +2455,7 @@ class Cable(ChangeLoggedModel): """ endpoint_a_type = models.ForeignKey( to=ContentType, - limit_choices_to={'model__in': CABLE_CONNECTION_TYPES}, + limit_choices_to={'model__in': CABLE_ENDPOINT_TYPES}, on_delete=models.PROTECT, related_name='+' ) @@ -2466,7 +2466,7 @@ class Cable(ChangeLoggedModel): ) endpoint_b_type = models.ForeignKey( to=ContentType, - limit_choices_to={'model__in': CABLE_CONNECTION_TYPES}, + limit_choices_to={'model__in': CABLE_ENDPOINT_TYPES}, on_delete=models.PROTECT, related_name='+' ) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 2d9c8d009..ac283b79f 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -161,7 +161,8 @@ urlpatterns = [ url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), url(r'^devices/(?P\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'), url(r'^devices/(?P\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), - url(r'^console-ports/(?P\d+)/connect/$', views.ConsolePortConnectView.as_view(), name='consoleport_connect'), + # url(r'^console-ports/(?P\d+)/connect/$', views.ConsolePortConnectView.as_view(), name='consoleport_connect'), + url(r'^console-ports/(?P\d+)/connect/$', views.CableConnectView.as_view(), name='consoleport_connect', kwargs={'endpoint_a_type': 'consoleport'}), url(r'^console-ports/(?P\d+)/disconnect/$', views.ConsolePortDisconnectView.as_view(), name='consoleport_disconnect'), url(r'^console-ports/(?P\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'), url(r'^console-ports/(?P\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), @@ -171,7 +172,8 @@ urlpatterns = [ url(r'^devices/(?P\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'), url(r'^devices/(?P\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), url(r'^devices/(?P\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), - url(r'^console-server-ports/(?P\d+)/connect/$', views.ConsoleServerPortConnectView.as_view(), name='consoleserverport_connect'), + # url(r'^console-server-ports/(?P\d+)/connect/$', views.ConsoleServerPortConnectView.as_view(), name='consoleserverport_connect'), + url(r'^console-server-ports/(?P\d+)/connect/$', views.CableConnectView.as_view(), name='consoleserverport_connect', kwargs={'endpoint_a_type': 'consoleserverport'}), url(r'^console-server-ports/(?P\d+)/disconnect/$', views.ConsoleServerPortDisconnectView.as_view(), name='consoleserverport_disconnect'), url(r'^console-server-ports/(?P\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), url(r'^console-server-ports/(?P\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), @@ -181,7 +183,8 @@ urlpatterns = [ url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), url(r'^devices/(?P\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'), url(r'^devices/(?P\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), - url(r'^power-ports/(?P\d+)/connect/$', views.PowerPortConnectView.as_view(), name='powerport_connect'), + # url(r'^power-ports/(?P\d+)/connect/$', views.PowerPortConnectView.as_view(), name='powerport_connect'), + url(r'^power-ports/(?P\d+)/connect/$', views.CableConnectView.as_view(), name='powerport_connect', kwargs={'endpoint_a_type': 'powerport'}), url(r'^power-ports/(?P\d+)/disconnect/$', views.PowerPortDisconnectView.as_view(), name='powerport_disconnect'), url(r'^power-ports/(?P\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'), url(r'^power-ports/(?P\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'), @@ -191,7 +194,8 @@ urlpatterns = [ url(r'^devices/(?P\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'), url(r'^devices/(?P\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), url(r'^devices/(?P\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), - url(r'^power-outlets/(?P\d+)/connect/$', views.PowerOutletConnectView.as_view(), name='poweroutlet_connect'), + # url(r'^power-outlets/(?P\d+)/connect/$', views.PowerOutletConnectView.as_view(), name='poweroutlet_connect'), + url(r'^power-outlets/(?P\d+)/connect/$', views.CableConnectView.as_view(), name='poweroutlet_connect', kwargs={'endpoint_a_type': 'poweroutlet'}), url(r'^power-outlets/(?P\d+)/disconnect/$', views.PowerOutletDisconnectView.as_view(), name='poweroutlet_disconnect'), url(r'^power-outlets/(?P\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), url(r'^power-outlets/(?P\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c7e57b89f..f598d6b51 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,12 +1,14 @@ from operator import attrgetter +from django.apps import apps from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin +from django.core.exceptions import ObjectDoesNotExist from django.core.paginator import EmptyPage, PageNotAnInteger from django.db import transaction from django.db.models import Count, Q from django.forms import modelformset_factory -from django.http import HttpResponseRedirect +from django.http import Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape @@ -30,7 +32,7 @@ from virtualization.models import VirtualMachine from . import filters, forms, tables from .constants import CONNECTION_STATUS_CONNECTED from .models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site, @@ -2152,6 +2154,27 @@ class InterfaceConnectionsListView(ObjectListView): template_name = 'dcim/interface_connections_list.html' +class CableConnectView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_cable' + model = Cable + model_form = forms.CableForm + template_name = 'dcim/cable_connect.html' + + def alter_obj(self, obj, request, url_args, url_kwargs): + # Retrieve endpoint A based on the given type and PK + endpoint_a_type = url_kwargs.get('endpoint_a_type') + endpoint_a_id = url_kwargs.get('endpoint_a_id') + try: + model = apps.get_model( + app_label='dcim', + model_name=endpoint_a_type + ) + obj.endpoint_a = model.objects.get(pk=endpoint_a_id) + except ObjectDoesNotExist: + raise Http404 + return obj + + # # Inventory items # diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 6cb621071..7ea4b152b 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -93,7 +93,10 @@ $(document).ready(function() { var rendered_url = api_url; while (match = filter_regex.exec(api_url)) { var filter_field = $('#id_' + match[1]); - if (filter_field.val()) { + var custom_attr = $('option:selected', filter_field).attr('api-value'); + if (custom_attr) { + rendered_url = rendered_url.replace(match[0], custom_attr); + } else if (filter_field.val()) { rendered_url = rendered_url.replace(match[0], filter_field.val()); } else if (filter_field.attr('nullable') == 'true') { rendered_url = rendered_url.replace(match[0], '0'); diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html new file mode 100644 index 000000000..66327170e --- /dev/null +++ b/netbox/templates/dcim/cable_connect.html @@ -0,0 +1,106 @@ +{% extends '_base.html' %} +{% load static from staticfiles %} +{% load form_helpers %} + +{% block content %} +
+ {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} + {% if form.non_field_errors %} +
+
+
+
Errors
+
+ {{ form.non_field_errors }} +
+
+
+
+ {% endif %} + {% with endpoint_a=form.instance.endpoint_a %} +

{% block title %}Connect {{ endpoint_a.device }} {{ endpoint_a }}{% endblock %}

+
+
+
+
+ A Side +
+
+
+ +
+

{{ endpoint_a.device.site }}

+
+
+
+ +
+

{{ endpoint_a.device.rack|default:"None" }}

+
+
+
+ +
+

{{ endpoint_a.device }}

+
+
+
+ +
+

{{ endpoint_a }}

+
+
+
+
+
+
+ +
+
+
+
+ B Side +
+
+ +
+ +
+ {% render_field form.endpoint_b_site %} + {% render_field form.endpoint_b_rack %} + {% render_field form.endpoint_b_device %} +
+
+ {% render_field form.endpoint_b_type %} + {% render_field form.endpoint_b_id %} +
+
+
+
+
+
+ {% render_field form.status %} + {% render_field form.label %} +
+
+
+
+ + Cancel +
+
+ {% endwith %} +
+{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index 4d75cc65b..91d60f7e3 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -30,7 +30,7 @@ {% else %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html index 673f51388..979b040b1 100644 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ b/netbox/templates/dcim/inc/consoleserverport.html @@ -35,7 +35,7 @@ {% else %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html index 18cfb7f2c..84c018460 100644 --- a/netbox/templates/dcim/inc/poweroutlet.html +++ b/netbox/templates/dcim/inc/poweroutlet.html @@ -35,7 +35,7 @@ {% else %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index 32e7f20fd..7db2003b0 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -30,7 +30,7 @@ {% else %} - + {% endif %} diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 1c2f7dcf0..57e89ac1d 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -207,6 +207,15 @@ class SelectWithPK(forms.Select): option_template_name = 'widgets/select_option_with_pk.html' +class ContentTypeSelect(forms.Select): + """ + Appends an `api-value` attribute equal to the slugified model name for each ContentType. For example: + + This attribute can be used to reference the relevant API endpoint for a particular ContentType. + """ + option_template_name = 'widgets/select_contenttype.html' + + class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple): """ MultiSelect widget for a SimpleArrayField. Choices must be populated on the widget. diff --git a/netbox/utilities/templates/widgets/select_contenttype.html b/netbox/utilities/templates/widgets/select_contenttype.html new file mode 100644 index 000000000..ca7fe326e --- /dev/null +++ b/netbox/utilities/templates/widgets/select_contenttype.html @@ -0,0 +1 @@ +