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

WIP: Initial work on the cable connection form

Jeremy Stretch 7 лет назад
Родитель
Сommit
471bddea09

+ 24 - 6
netbox/dcim/constants.py

@@ -284,12 +284,9 @@ CONNECTION_STATUS_CHOICES = [
 ]
 ]
 
 
 # Cable endpoint types
 # 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
 # Cable types
 # TODO: Add more types
 # TODO: Add more types
@@ -299,3 +296,24 @@ CABLE_TYPE_CHOICES = (
     (CABLE_TYPE_COPPER, 'Copper'),
     (CABLE_TYPE_COPPER, 'Copper'),
     (CABLE_TYPE_FIBER, 'Fiber'),
     (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'],
+}

+ 92 - 1
netbox/dcim/forms.py

@@ -2,6 +2,7 @@ import re
 
 
 from django import forms
 from django import forms
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
+from django.contrib.contenttypes.models import ContentType
 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
@@ -19,11 +20,12 @@ from utilities.forms import (
     BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm,
     BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm,
     ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
     ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
     FlexibleModelChoiceField, JSONField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
     FlexibleModelChoiceField, JSONField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
+    ContentTypeSelect
 )
 )
 from virtualization.models import Cluster
 from virtualization.models import Cluster
 from .constants import *
 from .constants import *
 from .models import (
 from .models import (
-    DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
+    Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
     Device, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection,
     Device, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection,
     InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
     InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
     PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site,
     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
 # Device bays
 #
 #

+ 2 - 2
netbox/dcim/models.py

@@ -2455,7 +2455,7 @@ class Cable(ChangeLoggedModel):
     """
     """
     endpoint_a_type = models.ForeignKey(
     endpoint_a_type = models.ForeignKey(
         to=ContentType,
         to=ContentType,
-        limit_choices_to={'model__in': CABLE_CONNECTION_TYPES},
+        limit_choices_to={'model__in': CABLE_ENDPOINT_TYPES},
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='+'
         related_name='+'
     )
     )
@@ -2466,7 +2466,7 @@ class Cable(ChangeLoggedModel):
     )
     )
     endpoint_b_type = models.ForeignKey(
     endpoint_b_type = models.ForeignKey(
         to=ContentType,
         to=ContentType,
-        limit_choices_to={'model__in': CABLE_CONNECTION_TYPES},
+        limit_choices_to={'model__in': CABLE_ENDPOINT_TYPES},
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='+'
         related_name='+'
     )
     )

+ 8 - 4
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/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
     url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
     url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
     url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
     url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
-    url(r'^console-ports/(?P<pk>\d+)/connect/$', views.ConsolePortConnectView.as_view(), name='consoleport_connect'),
+    # url(r'^console-ports/(?P<pk>\d+)/connect/$', views.ConsolePortConnectView.as_view(), name='consoleport_connect'),
+    url(r'^console-ports/(?P<endpoint_a_id>\d+)/connect/$', views.CableConnectView.as_view(), name='consoleport_connect', kwargs={'endpoint_a_type': 'consoleport'}),
     url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.ConsolePortDisconnectView.as_view(), name='consoleport_disconnect'),
     url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.ConsolePortDisconnectView.as_view(), name='consoleport_disconnect'),
     url(r'^console-ports/(?P<pk>\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
     url(r'^console-ports/(?P<pk>\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
     url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
     url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
@@ -171,7 +172,8 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
     url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
     url(r'^devices/(?P<pk>\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
     url(r'^devices/(?P<pk>\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
     url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
     url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
-    url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.ConsoleServerPortConnectView.as_view(), name='consoleserverport_connect'),
+    # url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.ConsoleServerPortConnectView.as_view(), name='consoleserverport_connect'),
+    url(r'^console-server-ports/(?P<endpoint_a_id>\d+)/connect/$', views.CableConnectView.as_view(), name='consoleserverport_connect', kwargs={'endpoint_a_type': 'consoleserverport'}),
     url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.ConsoleServerPortDisconnectView.as_view(), name='consoleserverport_disconnect'),
     url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.ConsoleServerPortDisconnectView.as_view(), name='consoleserverport_disconnect'),
     url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
     url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
     url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
     url(r'^console-server-ports/(?P<pk>\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/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
     url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'),
     url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'),
     url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
     url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
-    url(r'^power-ports/(?P<pk>\d+)/connect/$', views.PowerPortConnectView.as_view(), name='powerport_connect'),
+    # url(r'^power-ports/(?P<pk>\d+)/connect/$', views.PowerPortConnectView.as_view(), name='powerport_connect'),
+    url(r'^power-ports/(?P<endpoint_a_id>\d+)/connect/$', views.CableConnectView.as_view(), name='powerport_connect', kwargs={'endpoint_a_type': 'powerport'}),
     url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.PowerPortDisconnectView.as_view(), name='powerport_disconnect'),
     url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.PowerPortDisconnectView.as_view(), name='powerport_disconnect'),
     url(r'^power-ports/(?P<pk>\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
     url(r'^power-ports/(?P<pk>\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
     url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
     url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
@@ -191,7 +194,8 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
     url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
     url(r'^devices/(?P<pk>\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
     url(r'^devices/(?P<pk>\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
     url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
     url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
-    url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.PowerOutletConnectView.as_view(), name='poweroutlet_connect'),
+    # url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.PowerOutletConnectView.as_view(), name='poweroutlet_connect'),
+    url(r'^power-outlets/(?P<endpoint_a_id>\d+)/connect/$', views.CableConnectView.as_view(), name='poweroutlet_connect', kwargs={'endpoint_a_type': 'poweroutlet'}),
     url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.PowerOutletDisconnectView.as_view(), name='poweroutlet_disconnect'),
     url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.PowerOutletDisconnectView.as_view(), name='poweroutlet_disconnect'),
     url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
     url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
     url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
     url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),

+ 25 - 2
netbox/dcim/views.py

@@ -1,12 +1,14 @@
 from operator import attrgetter
 from operator import attrgetter
 
 
+from django.apps import apps
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.core.exceptions import ObjectDoesNotExist
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.db import transaction
 from django.db import transaction
 from django.db.models import Count, Q
 from django.db.models import Count, Q
 from django.forms import modelformset_factory
 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.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.html import escape
 from django.utils.html import escape
@@ -30,7 +32,7 @@ from virtualization.models import VirtualMachine
 from . import filters, forms, tables
 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,
+    Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection,
     InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
     InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
     PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site,
     PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site,
@@ -2152,6 +2154,27 @@ class InterfaceConnectionsListView(ObjectListView):
     template_name = 'dcim/interface_connections_list.html'
     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
 # Inventory items
 #
 #

+ 4 - 1
netbox/project-static/js/forms.js

@@ -93,7 +93,10 @@ $(document).ready(function() {
                 var rendered_url = api_url;
                 var rendered_url = api_url;
                 while (match = filter_regex.exec(api_url)) {
                 while (match = filter_regex.exec(api_url)) {
                     var filter_field = $('#id_' + match[1]);
                     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());
                         rendered_url = rendered_url.replace(match[0], filter_field.val());
                     } else if (filter_field.attr('nullable') == 'true') {
                     } else if (filter_field.attr('nullable') == 'true') {
                         rendered_url = rendered_url.replace(match[0], '0');
                         rendered_url = rendered_url.replace(match[0], '0');

+ 106 - 0
netbox/templates/dcim/cable_connect.html

@@ -0,0 +1,106 @@
+{% extends '_base.html' %}
+{% load static from staticfiles %}
+{% load form_helpers %}
+
+{% block content %}
+<form action="." method="post" class="form form-horizontal">
+    {% csrf_token %}
+    {% for field in form.hidden_fields %}
+        {{ field }}
+    {% endfor %}
+    {% if form.non_field_errors %}
+        <div class="row">
+            <div class="col-md-6 col-md-offset-3">
+                <div class="panel panel-danger">
+                    <div class="panel-heading"><strong>Errors</strong></div>
+                    <div class="panel-body">
+                        {{ form.non_field_errors }}
+                    </div>
+                </div>
+            </div>
+        </div>
+    {% endif %}
+    {% with endpoint_a=form.instance.endpoint_a %}
+        <h3>{% block title %}Connect {{ endpoint_a.device }} {{ endpoint_a }}{% endblock %}</h3>
+        <div class="row">
+            <div class="col-md-5">
+                <div class="panel panel-default">
+                    <div class="panel-heading text-center">
+                        <strong>A Side</strong>
+                    </div>
+                    <div class="panel-body">
+                        <div class="form-group">
+                            <label class="col-md-3 control-label required">Site</label>
+                            <div class="col-md-9">
+                                <p class="form-control-static">{{ endpoint_a.device.site }}</p>
+                            </div>
+                        </div>
+                        <div class="form-group">
+                            <label class="col-md-3 control-label required">Rack</label>
+                            <div class="col-md-9">
+                                <p class="form-control-static">{{ endpoint_a.device.rack|default:"None" }}</p>
+                            </div>
+                        </div>
+                        <div class="form-group">
+                            <label class="col-md-3 control-label required">Device</label>
+                            <div class="col-md-9">
+                                <p class="form-control-static">{{ endpoint_a.device }}</p>
+                            </div>
+                        </div>
+                        <div class="form-group">
+                            <label class="col-md-3 control-label required">Name</label>
+                            <div class="col-md-9">
+                                <p class="form-control-static">{{ endpoint_a }}</p>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="col-md-2 text-center" style="padding-top: 90px;">
+                <i class="fa fa-exchange fa-4x"></i>
+            </div>
+            <div class="col-md-5">
+                <div class="panel panel-default">
+                    <div class="panel-heading text-center">
+                        <strong>B Side</strong>
+                    </div>
+                    <div class="panel-body">
+                        <ul class="nav nav-tabs" role="tablist">
+                            <li role="presentation" class="active"><a href="#search" aria-controls="search" role="tab" data-toggle="tab">Search</a></li>
+                            <li role="presentation"><a href="#select" aria-controls="home" role="tab" data-toggle="tab">Select</a></li>
+                        </ul>
+                        <div class="tab-content">
+                            <div class="tab-pane active" id="search">
+                                {% render_field form.livesearch %}
+                            </div>
+                            <div class="tab-pane" id="select">
+                                {% render_field form.endpoint_b_site %}
+                                {% render_field form.endpoint_b_rack %}
+                                {% render_field form.endpoint_b_device %}
+                            </div>
+                        </div>
+                        {% render_field form.endpoint_b_type %}
+                        {% render_field form.endpoint_b_id %}
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div class="row">
+            <div class="col-md-4 col-md-offset-4">
+                {% render_field form.status %}
+                {% render_field form.label %}
+            </div>
+        </div>
+        <div class="form-group">
+            <div class="col-md-12 text-center">
+                <button type="submit" name="_update" class="btn btn-primary">Connect</button>
+                <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+            </div>
+        </div>
+    {% endwith %}
+</form>
+{% endblock %}
+
+{% block javascript %}
+<script src="{% static 'js/livesearch.js' %}?v{{ settings.VERSION }}"></script>
+{% endblock %}

+ 1 - 1
netbox/templates/dcim/inc/consoleport.html

@@ -30,7 +30,7 @@
                     <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
                 </a>
                 </a>
             {% else %}
             {% else %}
-                <a href="{% url 'dcim:consoleport_connect' pk=cp.pk %}" title="Connect" class="btn btn-success btn-xs">
+                <a href="{% url 'dcim:consoleport_connect' endpoint_a_id=cp.pk %}" title="Connect" class="btn btn-success btn-xs">
                     <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
                 </a>
                 </a>
             {% endif %}
             {% endif %}

+ 1 - 1
netbox/templates/dcim/inc/consoleserverport.html

@@ -35,7 +35,7 @@
                     <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
                 </a>
                 </a>
             {% else %}
             {% else %}
-                <a href="{% url 'dcim:consoleserverport_connect' pk=csp.pk %}" title="Connect" class="btn btn-success btn-xs">
+                <a href="{% url 'dcim:consoleserverport_connect' endpoint_a_id=csp.pk %}" title="Connect" class="btn btn-success btn-xs">
                     <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
                 </a>
                 </a>
             {% endif %}
             {% endif %}

+ 1 - 1
netbox/templates/dcim/inc/poweroutlet.html

@@ -35,7 +35,7 @@
                     <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
                 </a>
                 </a>
             {% else %}
             {% else %}
-                <a href="{% url 'dcim:poweroutlet_connect' pk=po.pk %}" title="Connect" class="btn btn-success btn-xs">
+                <a href="{% url 'dcim:poweroutlet_connect' endpoint_a_id=po.pk %}" title="Connect" class="btn btn-success btn-xs">
                     <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
                 </a>
                 </a>
             {% endif %}
             {% endif %}

+ 1 - 1
netbox/templates/dcim/inc/powerport.html

@@ -30,7 +30,7 @@
                 <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
                 <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
             </a>
             </a>
             {% else %}
             {% else %}
-                <a href="{% url 'dcim:powerport_connect' pk=pp.pk %}" title="Connect" class="btn btn-success btn-xs">
+                <a href="{% url 'dcim:powerport_connect' endpoint_a_id=pp.pk %}" title="Connect" class="btn btn-success btn-xs">
                     <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
                 </a>
                 </a>
             {% endif %}
             {% endif %}

+ 9 - 0
netbox/utilities/forms.py

@@ -207,6 +207,15 @@ class SelectWithPK(forms.Select):
     option_template_name = 'widgets/select_option_with_pk.html'
     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:
+        <option value="37" api-value="console-server-port">console server port</option>
+    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):
 class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
     """
     """
     MultiSelect widget for a SimpleArrayField. Choices must be populated on the widget.
     MultiSelect widget for a SimpleArrayField. Choices must be populated on the widget.

+ 1 - 0
netbox/utilities/templates/widgets/select_contenttype.html

@@ -0,0 +1 @@
+<option value="{{ widget.value }}"{% include "django/forms/widgets/attrs.html" %}{% if widget.value %} api-value="{{ widget.label|slugify }}"{% endif %}>{{ widget.label.label|default:widget.label }}</option>