diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index a1fc8661a..e52673874 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -275,6 +275,17 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet): queryset=ProviderNetwork.objects.all(), label=_('ProviderNetwork (ID)'), ) + provider_id = django_filters.ModelMultipleChoiceFilter( + field_name='circuit__provider_id', + queryset=Provider.objects.all(), + label=_('Provider (ID)'), + ) + provider = django_filters.ModelMultipleChoiceFilter( + field_name='circuit__provider__slug', + queryset=Provider.objects.all(), + to_field_name='slug', + label=_('Provider (slug)'), + ) class Meta: model = CircuitTermination diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 3ac311c56..ea15c3010 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -3,16 +3,18 @@ from django.utils.translation import gettext_lazy as _ from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices from circuits.models import * +from dcim.models import Site from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import add_blank_choice from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField -from utilities.forms.rendering import FieldSet -from utilities.forms.widgets import DatePicker, NumberWithOptions +from utilities.forms.rendering import FieldSet, TabbedGroups +from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions __all__ = ( 'CircuitBulkEditForm', + 'CircuitTerminationBulkEditForm', 'CircuitTypeBulkEditForm', 'ProviderBulkEditForm', 'ProviderAccountBulkEditForm', @@ -172,3 +174,48 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ( 'tenant', 'commit_rate', 'description', 'comments', ) + + +class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm): + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + site = DynamicModelChoiceField( + label=_('Site'), + queryset=Site.objects.all(), + required=False + ) + provider_network = DynamicModelChoiceField( + label=_('Provider Network'), + queryset=ProviderNetwork.objects.all(), + required=False + ) + port_speed = forms.IntegerField( + required=False, + label=_('Port speed (Kbps)'), + ) + upstream_speed = forms.IntegerField( + required=False, + label=_('Upstream speed (Kbps)'), + ) + mark_connected = forms.NullBooleanField( + label=_('Mark connected'), + required=False, + widget=BulkEditNullBooleanSelect + ) + + model = CircuitTermination + fieldsets = ( + FieldSet( + 'description', + TabbedGroups( + FieldSet('site', name=_('Site')), + FieldSet('provider_network', name=_('Provider Network')), + ), + 'mark_connected', name=_('Circuit Termination') + ), + FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')), + ) + nullable_fields = ('description') diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 8127d5bcb..1ceb44b60 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -1,10 +1,10 @@ from django import forms - -from circuits.choices import CircuitStatusChoices -from circuits.models import * -from dcim.models import Site from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ + +from circuits.choices import * +from circuits.models import * +from dcim.models import Site from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField @@ -12,6 +12,7 @@ from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugFiel __all__ = ( 'CircuitImportForm', 'CircuitTerminationImportForm', + 'CircuitTerminationImportRelatedForm', 'CircuitTypeImportForm', 'ProviderImportForm', 'ProviderAccountImportForm', @@ -111,7 +112,16 @@ class CircuitImportForm(NetBoxModelImportForm): ] -class CircuitTerminationImportForm(forms.ModelForm): +class BaseCircuitTerminationImportForm(forms.ModelForm): + circuit = CSVModelChoiceField( + label=_('Circuit'), + queryset=Circuit.objects.all(), + to_field_name='cid', + ) + term_side = CSVChoiceField( + label=_('Termination'), + choices=CircuitTerminationSideChoices, + ) site = CSVModelChoiceField( label=_('Site'), queryset=Site.objects.all(), @@ -125,9 +135,21 @@ class CircuitTerminationImportForm(forms.ModelForm): required=False ) + +class CircuitTerminationImportRelatedForm(BaseCircuitTerminationImportForm): class Meta: model = CircuitTermination fields = [ 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', - 'pp_info', 'description', + 'pp_info', 'description' + ] + + +class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTerminationImportForm): + + class Meta: + model = CircuitTermination + fields = [ + 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', + 'pp_info', 'description', 'tags' ] diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index b2426e928..6f6473c3d 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -1,7 +1,7 @@ from django import forms from django.utils.translation import gettext as _ -from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices +from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices, CircuitTerminationSideChoices from circuits.models import * from dcim.models import Region, Site, SiteGroup from ipam.models import ASN @@ -13,6 +13,7 @@ from utilities.forms.widgets import DatePicker, NumberWithOptions __all__ = ( 'CircuitFilterForm', + 'CircuitTerminationFilterForm', 'CircuitTypeFilterForm', 'ProviderFilterForm', 'ProviderAccountFilterForm', @@ -186,3 +187,46 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi ) ) tag = TagFilterField(model) + + +class CircuitTerminationFilterForm(NetBoxModelFilterSetForm): + model = CircuitTermination + fieldsets = ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet('circuit_id', 'term_side', name=_('Circuit')), + FieldSet('provider_id', 'provider_network_id', name=_('Provider')), + FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id', + 'site_group_id': '$site_group_id', + }, + label=_('Site') + ) + circuit_id = DynamicModelMultipleChoiceField( + queryset=Circuit.objects.all(), + required=False, + label=_('Circuit') + ) + term_side = forms.MultipleChoiceField( + label=_('Term Side'), + choices=CircuitTerminationSideChoices, + required=False + ) + provider_network_id = DynamicModelMultipleChoiceField( + queryset=ProviderNetwork.objects.all(), + required=False, + query_params={ + 'provider_id': '$provider_id' + }, + label=_('Provider network') + ) + provider_id = DynamicModelMultipleChoiceField( + queryset=Provider.objects.all(), + required=False, + label=_('Provider') + ) + tag = TagFilterField(model) diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 7b65d52ad..fa21d7cd3 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -227,7 +227,7 @@ class CircuitTermination( return f'{self.circuit}: Termination {self.term_side}' def get_absolute_url(self): - return self.circuit.get_absolute_url() + return reverse('circuits:circuittermination', args=[self.pk]) def clean(self): super().clean() diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index 6ae727eca..5d650df61 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -10,6 +10,7 @@ from .columns import CommitRateColumn __all__ = ( 'CircuitTable', + 'CircuitTerminationTable', 'CircuitTypeTable', ) @@ -88,3 +89,31 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): default_columns = ( 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', ) + + +class CircuitTerminationTable(NetBoxTable): + circuit = tables.Column( + verbose_name=_('Circuit'), + linkify=True + ) + provider = tables.Column( + verbose_name=_('Provider'), + linkify=True, + accessor='circuit.provider' + ) + site = tables.Column( + verbose_name=_('Site'), + linkify=True + ) + provider_network = tables.Column( + verbose_name=_('Provider Network'), + linkify=True + ) + + class Meta(NetBoxTable.Meta): + model = CircuitTermination + fields = ( + 'pk', 'id', 'circuit', 'provider', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', + 'xconnect_id', 'pp_info', 'description', 'created', 'last_updated', 'actions', + ) + default_columns = ('pk', 'id', 'circuit', 'provider', 'term_side', 'description') diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 0480439eb..df10c3929 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -351,24 +351,26 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): providers = ( Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + Provider(name='Provider 3', slug='provider-3'), ) Provider.objects.bulk_create(providers) provider_networks = ( ProviderNetwork(name='Provider Network 1', provider=providers[0]), - ProviderNetwork(name='Provider Network 2', provider=providers[0]), - ProviderNetwork(name='Provider Network 3', provider=providers[0]), + ProviderNetwork(name='Provider Network 2', provider=providers[1]), + ProviderNetwork(name='Provider Network 3', provider=providers[2]), ) ProviderNetwork.objects.bulk_create(provider_networks) circuits = ( Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 2'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 3'), + Circuit(provider=providers[1], type=circuit_types[0], cid='Circuit 2'), + Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 3'), Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'), + Circuit(provider=providers[1], type=circuit_types[0], cid='Circuit 5'), + Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 6'), + Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 7'), ) Circuit.objects.bulk_create(circuits) @@ -413,10 +415,17 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_circuit_id(self): - circuits = Circuit.objects.all()[:2] + circuits = Circuit.objects.filter(cid__in=['Circuit 1', 'Circuit 2']) params = {'circuit_id': [circuits[0].pk, circuits[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_provider(self): + providers = Provider.objects.all()[:2] + params = {'provider_id': [providers[0].pk, providers[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'provider': [providers[0].slug, providers[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 85e2304cf..577548703 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -5,8 +5,11 @@ from django.urls import reverse from circuits.choices import * from circuits.models import * +from core.models import ObjectType from dcim.models import Cable, Interface, Site from ipam.models import ASN, RIR +from netbox.choices import ImportFormatChoices +from users.models import ObjectPermission from utilities.testing import ViewTestCases, create_tags, create_test_device @@ -115,6 +118,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): + Site.objects.create(name='Site 1', slug='site-1') providers = ( Provider(name='Provider 1', slug='provider-1'), @@ -184,6 +188,51 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'comments': 'New comments', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) + def test_bulk_import_objects_with_terminations(self): + json_data = """ + [ + { + "cid": "Circuit 7", + "provider": "Provider 1", + "type": "Circuit Type 1", + "status": "active", + "description": "Testing Import", + "terminations": [ + { + "term_side": "A", + "site": "Site 1" + }, + { + "term_side": "Z", + "site": "Site 1" + } + ] + } + ] + """ + initial_count = self._get_queryset().count() + data = { + 'data': json_data, + 'format': ImportFormatChoices.JSON, + } + + # Assign model-level permission + obj_perm = ObjectPermission( + name='Test permission', + actions=['add'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('import')), 200) + + # Test POST with permission + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302) + self.assertEqual(self._get_queryset().count(), initial_count + 1) + class ProviderAccountTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = ProviderAccount @@ -287,10 +336,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -class CircuitTerminationTestCase( - ViewTestCases.EditObjectViewTestCase, - ViewTestCases.DeleteObjectViewTestCase, -): +class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = CircuitTermination @classmethod @@ -327,6 +373,24 @@ class CircuitTerminationTestCase( 'description': 'New description', } + cls.csv_data = ( + "circuit,term_side,site,description", + "Circuit 3,A,Site 1,Foo", + "Circuit 3,Z,Site 1,Bar", + ) + + cls.csv_update_data = ( + "id,port_speed,description", + f"{circuit_terminations[0].pk},100,New description7", + f"{circuit_terminations[1].pk},200,New description8", + f"{circuit_terminations[2].pk},300,New description9", + ) + + cls.bulk_edit_data = { + 'port_speed': 400, + 'description': 'New description', + } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): device = create_test_device('Device 1') diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 55a192c64..5c0ab99ee 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -48,7 +48,11 @@ urlpatterns = [ path('circuits//', include(get_model_urls('circuits', 'circuit'))), # Circuit terminations + path('circuit-terminations/', views.CircuitTerminationListView.as_view(), name='circuittermination_list'), path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), + path('circuit-terminations/import/', views.CircuitTerminationBulkImportView.as_view(), name='circuittermination_import'), + path('circuit-terminations/edit/', views.CircuitTerminationBulkEditView.as_view(), name='circuittermination_bulk_edit'), + path('circuit-terminations/delete/', views.CircuitTerminationBulkDeleteView.as_view(), name='circuittermination_bulk_delete'), path('circuit-terminations//', include(get_model_urls('circuits', 'circuittermination'))), ] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 54f875975..def9a3640 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -298,7 +298,7 @@ class CircuitBulkImportView(generic.BulkImportView): 'circuits.add_circuittermination', ] related_object_forms = { - 'terminations': forms.CircuitTerminationImportForm, + 'terminations': forms.CircuitTerminationImportRelatedForm, } def prep_related_object_data(self, parent, data): @@ -408,6 +408,18 @@ class CircuitContactsView(ObjectContactsView): # Circuit terminations # +class CircuitTerminationListView(generic.ObjectListView): + queryset = CircuitTermination.objects.all() + filterset = filtersets.CircuitTerminationFilterSet + filterset_form = forms.CircuitTerminationFilterForm + table = tables.CircuitTerminationTable + + +@register_model_view(CircuitTermination) +class CircuitTerminationView(generic.ObjectView): + queryset = CircuitTermination.objects.all() + + @register_model_view(CircuitTermination, 'edit') class CircuitTerminationEditView(generic.ObjectEditView): queryset = CircuitTermination.objects.all() @@ -419,5 +431,23 @@ class CircuitTerminationDeleteView(generic.ObjectDeleteView): queryset = CircuitTermination.objects.all() +class CircuitTerminationBulkImportView(generic.BulkImportView): + queryset = CircuitTermination.objects.all() + model_form = forms.CircuitTerminationImportForm + + +class CircuitTerminationBulkEditView(generic.BulkEditView): + queryset = CircuitTermination.objects.all() + filterset = filtersets.CircuitTerminationFilterSet + table = tables.CircuitTerminationTable + form = forms.CircuitTerminationBulkEditForm + + +class CircuitTerminationBulkDeleteView(generic.BulkDeleteView): + queryset = CircuitTermination.objects.all() + filterset = filtersets.CircuitTerminationFilterSet + table = tables.CircuitTerminationTable + + # Trace view register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView) diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 952f65ba0..002dfd98a 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -258,6 +258,7 @@ CIRCUITS_MENU = Menu( items=( get_model_item('circuits', 'circuit', _('Circuits')), get_model_item('circuits', 'circuittype', _('Circuit Types')), + get_model_item('circuits', 'circuittermination', _('Circuit Terminations')), ), ), MenuGroup( diff --git a/netbox/templates/circuits/circuittermination.html b/netbox/templates/circuits/circuittermination.html new file mode 100644 index 000000000..d74d2c636 --- /dev/null +++ b/netbox/templates/circuits/circuittermination.html @@ -0,0 +1,51 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
+
+ +
+ {% if object %} + + + + + + + + + + {% include 'circuits/inc/circuit_termination_fields.html' with termination=object %} +
{% trans "Circuit" %} + {{ object.circuit|linkify }} +
{% trans "Provider" %} + {{ object.circuit.provider|linkify }} +
+ {% else %} +
+ {% trans "None" %} +
+ {% endif %} +
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index a0afebb02..acec208c0 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -27,93 +27,7 @@ {% if termination %} - {% if termination.site %} - - - - - - - - - {% else %} - - - - - {% endif %} - - - - - - - - - - - - - - - - + {% include 'circuits/inc/circuit_termination_fields.html' with termination=termination %} + + + + + + + +{% else %} + + + + +{% endif %} + + + + + + + + + + + + + + + +
{% trans "Site" %} - {% if termination.site.region %} - {{ termination.site.region|linkify }} / - {% endif %} - {{ termination.site|linkify }} -
{% trans "Termination" %} - {% if termination.mark_connected %} - - {% trans "Marked as connected" %} - {% elif termination.cable %} - {{ termination.cable }} {% trans "to" %} - {% for peer in termination.link_peers %} - {% if peer.device %} - {{ peer.device|linkify }}
- {% elif peer.circuit %} - {{ peer.circuit|linkify }}
- {% endif %} - {{ peer|linkify }}{% if not forloop.last %},{% endif %} - {% endfor %} -
- - {% trans "Trace" %} - - {% if perms.dcim.change_cable %} - - {% trans "Edit" %} - - {% endif %} - {% if perms.dcim.delete_cable %} - - {% trans "Disconnect" %} - - {% endif %} -
- {% elif perms.dcim.add_cable %} - - {% endif %} -
{% trans "Provider Network" %}{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}
{% trans "Speed" %} - {% if termination.port_speed and termination.upstream_speed %} - {{ termination.port_speed|humanize_speed }}   - {{ termination.upstream_speed|humanize_speed }} - {% elif termination.port_speed %} - {{ termination.port_speed|humanize_speed }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Cross-Connect" %}{{ termination.xconnect_id|placeholder }}
{% trans "Patch Panel/Port" %}{{ termination.pp_info|placeholder }}
{% trans "Description" %}{{ termination.description|placeholder }}
{% trans "Tags" %} diff --git a/netbox/templates/circuits/inc/circuit_termination_fields.html b/netbox/templates/circuits/inc/circuit_termination_fields.html new file mode 100644 index 000000000..97d194f24 --- /dev/null +++ b/netbox/templates/circuits/inc/circuit_termination_fields.html @@ -0,0 +1,90 @@ +{% load helpers %} +{% load i18n %} + +{% if termination.site %} +
{% trans "Site" %} + {% if termination.site.region %} + {{ termination.site.region|linkify }} / + {% endif %} + {{ termination.site|linkify }} +
{% trans "Termination" %} + {% if termination.mark_connected %} + + {% trans "Marked as connected" %} + {% elif termination.cable %} + {{ termination.cable }} {% trans "to" %} + {% for peer in termination.link_peers %} + {% if peer.device %} + {{ peer.device|linkify }}
+ {% elif peer.circuit %} + {{ peer.circuit|linkify }}
+ {% endif %} + {{ peer|linkify }}{% if not forloop.last %},{% endif %} + {% endfor %} +
+ + {% trans "Trace" %} + + {% if perms.dcim.change_cable %} + + {% trans "Edit" %} + + {% endif %} + {% if perms.dcim.delete_cable %} + + {% trans "Disconnect" %} + + {% endif %} +
+ {% elif perms.dcim.add_cable %} + + {% endif %} +
{% trans "Provider Network" %}{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}
{% trans "Speed" %} + {% if termination.port_speed and termination.upstream_speed %} + {{ termination.port_speed|humanize_speed }}   + {{ termination.upstream_speed|humanize_speed }} + {% elif termination.port_speed %} + {{ termination.port_speed|humanize_speed }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Cross-Connect" %}{{ termination.xconnect_id|placeholder }}
{% trans "Patch Panel/Port" %}{{ termination.pp_info|placeholder }}
{% trans "Description" %}{{ termination.description|placeholder }}