From f8d7c024e7696b4d0dbb27ed685faf7efc21cdac Mon Sep 17 00:00:00 2001 From: hellerve Date: Thu, 5 Aug 2021 10:30:11 +0200 Subject: [PATCH] feat: add cable reconnection --- netbox/dcim/forms.py | 568 +++++++++++++++++++++ netbox/dcim/urls.py | 1 + netbox/dcim/views.py | 98 ++++ netbox/templates/dcim/cable.html | 4 + netbox/templates/dcim/cable_reconnect.html | 140 +++++ 5 files changed, 811 insertions(+) create mode 100644 netbox/templates/dcim/cable_reconnect.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9437225de..16a7ac13c 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -4269,6 +4269,574 @@ class CableCSVForm(CustomFieldModelCSVForm): return length_unit if length_unit is not None else '' +class ReconnectCableToDeviceForm(BootstrapMixin, forms.ModelForm): + """ + Base form for connecting a Cable to a Device component + """ + termination_a_site = DynamicModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False, + ) + termination_a_rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + display_field='display_name', + null_option='None', + query_params={ + 'site_id': '$termination_a_site' + } + ) + termination_a_device = DynamicModelChoiceField( + queryset=Device.objects.all(), + label='Device', + required=False, + display_field='display_name', + query_params={ + 'site_id': '$termination_a_site', + 'rack_id': '$termination_a_rack', + } + ) + termination_b_site = DynamicModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False + ) + termination_b_rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + display_field='display_name', + null_option='None', + query_params={ + 'site_id': '$termination_b_site' + } + ) + termination_b_device = DynamicModelChoiceField( + queryset=Device.objects.all(), + label='Device', + required=False, + display_field='display_name', + query_params={ + 'site_id': '$termination_b_site', + 'rack_id': '$termination_b_rack', + } + ) + + class Meta: + model = Cable + fields = [ + 'termination_a_site', 'termination_a_rack', 'termination_a_device', 'termination_a_id', + 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id' + ] + + def clean_termination_a_id(self): + # Return the PK rather than the object + return getattr(self.cleaned_data['termination_a_id'], 'pk', None) + + def clean_termination_b_id(self): + # Return the PK rather than the object + return getattr(self.cleaned_data['termination_b_id'], 'pk', None) + + +class ReconnectConsolePortToConsoleServerPortForm(ReconnectCableToDeviceForm): + termination_a_id = DynamicModelChoiceField( + queryset=ConsolePort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_a_device' + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=ConsoleServerPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ReconnectConsolePortToFrontPortForm(ReconnectCableToDeviceForm): + termination_a_id = DynamicModelChoiceField( + queryset=ConsolePort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_a_device' + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=FrontPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ReconnectConsolePortToRearPortForm(ReconnectCableToDeviceForm): + termination_a_id = DynamicModelChoiceField( + queryset=ConsolePort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_a_device' + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=RearPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ReconnectConsoleServerPortToFrontPortForm(ReconnectCableToDeviceForm): + termination_a_id = DynamicModelChoiceField( + queryset=ConsoleServerPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_a_device' + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=FrontPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ReconnectConsoleServerPortToRearPortForm(ReconnectCableToDeviceForm): + termination_a_id = DynamicModelChoiceField( + queryset=ConsoleServerPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_a_device' + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=RearPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ReconnectPowerfeedToPowerPortForm(ReconnectCableToDeviceForm): + termination_a_site = DynamicModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False, + display_field='cid' + ) + termination_a_rackgroup = DynamicModelChoiceField( + queryset=Location.objects.all(), + label='Location', + required=False, + display_field='cid', + query_params={ + 'site_id': '$termination_a_site' + } + ) + termination_a_powerpanel = DynamicModelChoiceField( + queryset=PowerPanel.objects.all(), + label='Power Panel', + required=False, + query_params={ + 'site_id': '$termination_a_site', + 'rack_group_id': '$termination_a_rackgroup', + } + ) + termination_a_id = DynamicModelChoiceField( + queryset=PowerFeed.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'power_panel_id': '$termination_a_powerpanel' + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=PowerPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + class Meta: + model = Cable + fields = [ + 'termination_a_site', 'termination_a_rackgroup', 'termination_a_powerpanel', 'termination_a_id', + 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id' + ] + + +class ReconnectPowerOutletToPowerPortForm(ReconnectCableToDeviceForm): + termination_a_id = DynamicModelChoiceField( + queryset=PowerOutlet.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_a_device' + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=PowerPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ReconnectInterfaceForm(ReconnectCableToDeviceForm): + termination_a_id = DynamicModelChoiceField( + queryset=Interface.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_a_device', + 'kind': 'physical', + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=Interface.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_b_device', + 'kind': 'physical', + } + ) + + +class ReconnectFrontPortToInterfaceForm(ReconnectCableToDeviceForm): + termination_a_id = DynamicModelChoiceField( + queryset=FrontPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_a_device' + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=Interface.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_b_device', + 'kind': 'physical', + } + ) + + +class ReconnectInterfaceToRearPortForm(ReconnectCableToDeviceForm): + termination_a_id = DynamicModelChoiceField( + queryset=Interface.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_a_device', + 'kind': 'physical', + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=RearPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ReconnectCircuitTerminationToInterfaceForm(ReconnectCableToDeviceForm): + termination_a_provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + label='Provider', + required=False + ) + termination_a_site = DynamicModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False + ) + termination_a_circuit = DynamicModelChoiceField( + queryset=Circuit.objects.all(), + label='Circuit', + display_field='cid', + query_params={ + 'provider_id': '$termination_a_provider', + 'site_id': '$termination_a_site', + } + ) + termination_a_id = DynamicModelChoiceField( + queryset=CircuitTermination.objects.all(), + label='Side', + display_field='term_side', + disabled_indicator='cable', + query_params={ + 'circuit_id': '$termination_a_circuit' + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=Interface.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_b_device', + 'kind': 'physical', + } + ) + + class Meta: + model = Cable + fields = [ + 'termination_a_provider', 'termination_a_site', 'termination_a_circuit', 'termination_a_id', + 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id' + ] + + +class ReconnectCircuitTerminationForm(BootstrapMixin, forms.ModelForm): + termination_a_provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + label='Provider', + required=False + ) + termination_a_site = DynamicModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False + ) + termination_a_circuit = DynamicModelChoiceField( + queryset=Circuit.objects.all(), + label='Circuit', + display_field='cid', + query_params={ + 'provider_id': '$termination_a_provider', + 'site_id': '$termination_a_site', + } + ) + termination_a_id = DynamicModelChoiceField( + queryset=CircuitTermination.objects.all(), + label='Side', + display_field='term_side', + disabled_indicator='cable', + query_params={ + 'circuit_id': '$termination_a_circuit' + } + ) + termination_b_provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + label='Provider', + required=False + ) + termination_b_site = DynamicModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False + ) + termination_b_circuit = DynamicModelChoiceField( + queryset=Circuit.objects.all(), + label='Circuit', + display_field='cid', + query_params={ + 'provider_id': '$termination_b_provider', + 'site_id': '$termination_b_site', + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=CircuitTermination.objects.all(), + label='Side', + display_field='term_side', + disabled_indicator='cable', + query_params={ + 'circuit_id': '$termination_b_circuit' + } + ) + + class Meta: + model = Cable + fields = [ + 'termination_a_provider', 'termination_a_site', 'termination_a_circuit', 'termination_a_id', + 'termination_b_provider', 'termination_b_site', 'termination_b_circuit', 'termination_b_id', 'type', + 'status', 'label', 'color', 'length', 'length_unit', + ] + + def clean_termination_a_id(self): + # Return the PK rather than the object + return getattr(self.cleaned_data['termination_a_id'], 'pk', None) + + def clean_termination_b_id(self): + # Return the PK rather than the object + return getattr(self.cleaned_data['termination_b_id'], 'pk', None) + + +class ReconnectFrontPortForm(ReconnectCableToDeviceForm): + termination_a_id = DynamicModelChoiceField( + queryset=FrontPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_a_device' + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=FrontPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ReconnectFrontPortToRearPortForm(ReconnectCableToDeviceForm): + termination_a_id = DynamicModelChoiceField( + queryset=FrontPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_a_device' + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=RearPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ReconnectRearPortForm(ReconnectCableToDeviceForm): + termination_a_id = DynamicModelChoiceField( + queryset=RearPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_a_device' + } + ) + termination_b_id = DynamicModelChoiceField( + queryset=RearPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ReconnectCircuitTerminationToForm(BootstrapMixin, forms.ModelForm): + termination_a_provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + label='Provider', + required=False + ) + termination_a_site = DynamicModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False + ) + termination_a_circuit = DynamicModelChoiceField( + queryset=Circuit.objects.all(), + label='Circuit', + display_field='cid', + query_params={ + 'provider_id': '$termination_a_provider', + 'site_id': '$termination_a_site', + } + ) + termination_a_id = DynamicModelChoiceField( + queryset=CircuitTermination.objects.all(), + label='Side', + display_field='term_side', + disabled_indicator='cable', + query_params={ + 'circuit_id': '$termination_a_circuit' + } + ) + termination_b_site = DynamicModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False + ) + termination_b_rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + display_field='display_name', + null_option='None', + query_params={ + 'site_id': '$termination_b_site' + } + ) + termination_b_device = DynamicModelChoiceField( + queryset=Device.objects.all(), + label='Device', + required=False, + display_field='display_name', + query_params={ + 'site_id': '$termination_b_site', + 'rack_id': '$termination_b_rack', + } + ) + + class Meta: + model = Cable + fields = [ + 'termination_a_provider', 'termination_a_site', 'termination_a_circuit', 'termination_a_id', + 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id' + ] + + def clean_termination_a_id(self): + # Return the PK rather than the object + return getattr(self.cleaned_data['termination_a_id'], 'pk', None) + + def clean_termination_b_id(self): + # Return the PK rather than the object + return getattr(self.cleaned_data['termination_b_id'], 'pk', None) + + +class ReconnectCircuitTerminationToFrontPortForm(ReconnectCircuitTerminationToForm): + termination_b_id = DynamicModelChoiceField( + queryset=FrontPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + +class ReconnectCircuitTerminationToRearPortForm(ReconnectCircuitTerminationToForm): + termination_b_id = DynamicModelChoiceField( + queryset=RearPort.objects.all(), + label='Name', + disabled_indicator='cable', + query_params={ + 'device_id': '$termination_b_device' + } + ) + + class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Cable.objects.all(), diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index b23603c97..7cd2c14b5 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -378,6 +378,7 @@ urlpatterns = [ path('cables//delete/', views.CableDeleteView.as_view(), name='cable_delete'), path('cables//changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}), path('cables//journal/', ObjectJournalView.as_view(), name='cable_journal', kwargs={'model': Cable}), + path(r'cables//reconnect/', views.CableReconnectView.as_view(), name='cable_reconnect'), # Console/power/interface connections (read-only) path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d892c0823..d130eacd9 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2478,6 +2478,104 @@ class CableBulkDeleteView(generic.BulkDeleteView): table = tables.CableTable +class CableReconnectView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View): + queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') + template_name = 'dcim/cable_reconnect.html' + + def get_required_permission(self): + return 'dcim.change_cable' + + def dispatch(self, request, *args, pk=None, **kwargs): + self.obj = Cable.objects.get(pk=pk) + + idx = (self.obj.termination_a_type.model, self.obj.termination_b_type.model) + idx_sorted = tuple(sorted(idx)) + if idx != idx_sorted: + termination_a = self.obj.termination_a + self.obj.termination_a = self.obj.termination_b + self.obj.termination_b = termination_a + + self.form_class = { + ('circuittermination', 'circuittermination'): forms.ReconnectCircuitTerminationForm, + ('circuittermination', 'rearport'): forms.ReconnectCircuitTerminationToRearPortForm, + ('consoleport', 'consoleserverport'): forms.ReconnectConsolePortToConsoleServerPortForm, + ('consoleport', 'frontport'): forms.ReconnectConsolePortToFrontPortForm, + ('consoleport', 'rearport'): forms.ReconnectConsolePortToRearPortForm, + ('consoleserverport', 'frontport'): forms.ReconnectConsoleServerPortToFrontPortForm, + ('consoleserverport', 'rearport'): forms.ReconnectConsoleServerPortToRearPortForm, + ('powerfeed', 'powerport'): forms.ReconnectPowerfeedToPowerPortForm, + ('poweroutlet', 'powerport'): forms.ReconnectPowerOutletToPowerPortForm, + ('circuittermination', 'interface'): forms.ReconnectCircuitTerminationToInterfaceForm, + ('frontport', 'interface'): forms.ReconnectFrontPortToInterfaceForm, + ('interface', 'rearport'): forms.ReconnectInterfaceToRearPortForm, + ('interface', 'interface'): forms.ReconnectInterfaceForm, + ('frontport', 'frontport'): forms.ReconnectFrontPortForm, + ('frontport', 'rearport'): forms.ReconnectFrontPortToRearPortForm, + ('circuittermination', 'frontport'): forms.ReconnectCircuitTerminationToFrontPortForm, + ('rearport', 'rearport'): forms.ReconnectRearPortForm, + }[idx_sorted] + + return super().dispatch(request, *args, **kwargs) + + def prefill_form(self, initial_data, termination): + o = getattr(self.obj, termination) + if o and hasattr(o, 'device'): + device = o.device + initial_data['{}_device'.format(termination)] = device + if device.site: + initial_data['{}_site'.format(termination)] = device.site + if device.rack: + initial_data['{}_rack'.format(termination)] = device.rack + + def get(self, request, *args, **kwargs): + # Parse initial data manually to avoid setting field values as lists + initial_data = {k: request.GET[k] for k in request.GET} + + self.prefill_form(initial_data, 'termination_a') + self.prefill_form(initial_data, 'termination_b') + + form = self.form_class(instance=self.obj, initial=initial_data) + + return render(request, self.template_name, { + 'obj': self.obj, + 'obj_type': Cable._meta.verbose_name, + 'form': form, + 'return_url': self.get_return_url(request, self.obj), + }) + + def post(self, request, *args, **kwargs): + with transaction.atomic(): + try: + if hasattr(self.obj.termination_a, '_path') and self.obj.termination_a._path_id: + self.obj.termination_a._path = None + except CablePath.DoesNotExist: + pass + try: + if hasattr(self.obj.termination_b, '_path') and self.obj.termination_b._path_id: + self.obj.termination_b._path = None + except CablePath.DoesNotExist: + pass + self.obj.delete() + + self.obj.termination_a.cable = None + self.obj.termination_b.cable = None + + form = self.form_class(request.POST, request.FILES, instance=self.obj) + if form.is_valid(): + obj = form.save() + + return redirect(self.get_return_url(request, obj)) + + return render(request, self.template_name, { + 'obj': self.obj, + 'obj_type': Cable._meta.verbose_name, + 'form': form, + + + 'return_url': self.get_return_url(request, self.obj), + }) + + # # Connections # diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index a69bac6d6..ed10643d6 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -10,6 +10,10 @@ {% endblock %} {% block buttons %} + {% if request.user|can_change:object %} + + Reconnect + {% endif %} {% if request.user|can_change:object %} {% edit_button object %} {% endif %} diff --git a/netbox/templates/dcim/cable_reconnect.html b/netbox/templates/dcim/cable_reconnect.html new file mode 100644 index 000000000..3791a13ef --- /dev/null +++ b/netbox/templates/dcim/cable_reconnect.html @@ -0,0 +1,140 @@ +{% extends 'base.html' %} +{% load static %} +{% load helpers %} +{% 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 termination_a=form.instance.termination_a %} +

{% block title %}Reconnect {{ form.instance.termination_a_type.name|bettertitle }}<->{{ form.instance.termination_b_type.name|bettertitle }} cable{% endblock %}

+
+
+
+
+ A Side +
+
+ {% if tabs %} + + {% endif %} + {% if 'termination_a_provider' in form.fields %} + {% render_field form.termination_a_provider %} + {% endif %} + {% if 'termination_a_site' in form.fields %} + {% render_field form.termination_a_site %} + {% endif %} + {% if 'termination_a_rackgroup' in form.fields %} + {% render_field form.termination_a_rackgroup %} + {% endif %} + {% if 'termination_a_rack' in form.fields %} + {% render_field form.termination_a_rack %} + {% endif %} + {% if 'termination_a_device' in form.fields %} + {% render_field form.termination_a_device %} + {% endif %} + {% if 'termination_a_type' in form.fields %} + {% render_field form.termination_a_type %} + {% endif %} + {% if 'termination_a_powerpanel' in form.fields %} + {% render_field form.termination_a_powerpanel %} + {% endif %} + {% if 'termination_a_circuit' in form.fields %} + {% render_field form.termination_a_circuit %} + {% endif %} +
+ +
+

{{ form.instance.termination_a_type.name|capfirst }}

+
+
+ {% render_field form.termination_a_id %} +
+
+
+
+ +
+
+
+
+ B Side +
+
+ {% if tabs %} + + {% endif %} + {% if 'termination_b_provider' in form.fields %} + {% render_field form.termination_b_provider %} + {% endif %} + {% if 'termination_b_site' in form.fields %} + {% render_field form.termination_b_site %} + {% endif %} + {% if 'termination_b_rackgroup' in form.fields %} + {% render_field form.termination_b_rackgroup %} + {% endif %} + {% if 'termination_b_rack' in form.fields %} + {% render_field form.termination_b_rack %} + {% endif %} + {% if 'termination_b_device' in form.fields %} + {% render_field form.termination_b_device %} + {% endif %} + {% if 'termination_b_type' in form.fields %} + {% render_field form.termination_b_type %} + {% endif %} + {% if 'termination_b_powerpanel' in form.fields %} + {% render_field form.termination_b_powerpanel %} + {% endif %} + {% if 'termination_b_circuit' in form.fields %} + {% render_field form.termination_b_circuit %} + {% endif %} +
+ +
+

{{ form.instance.termination_b_type.name|capfirst }}

+
+
+ {% render_field form.termination_b_id %} +
+
+
+
+
+ {% include 'dcim/inc/cable_form.html' %} +
+
+
+ + Cancel +
+
+ {% endwith %} +
+{% endblock %} + +{% block javascript %} + +{% endblock %}