15496 Add circuit termination to menu and associated forms (#15980)

* 15496 base changes

* 15496 detail view template

* 15496 tweaks

* 15496 bulk views

* 15496 filterset

* 15496 optimize qs

* 15496 bulk edit

* 15496 bulk import

* 15496 update tests

* Update netbox/templates/circuits/circuittermination.html

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* 15496 review changes

* 15496 template include

* 15496 expand filters

* 15496 split import form

* 15496 split import form

* 15496 add test for circuit bulk import with termiantions

* Add test for provider filters

* Rename provider column

* Fix test

* Misc cleanup

* Fix test

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
Arthur Hanson 2024-05-17 12:30:10 -07:00 committed by GitHub
parent d060b380c9
commit b2d2a23c26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 426 additions and 110 deletions

View File

@ -275,6 +275,17 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
queryset=ProviderNetwork.objects.all(), queryset=ProviderNetwork.objects.all(),
label=_('ProviderNetwork (ID)'), 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: class Meta:
model = CircuitTermination model = CircuitTermination

View File

@ -3,16 +3,18 @@ from django.utils.translation import gettext_lazy as _
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
from circuits.models import * from circuits.models import *
from dcim.models import Site
from ipam.models import ASN from ipam.models import ASN
from netbox.forms import NetBoxModelBulkEditForm from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import add_blank_choice from utilities.forms import add_blank_choice
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet, TabbedGroups
from utilities.forms.widgets import DatePicker, NumberWithOptions from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions
__all__ = ( __all__ = (
'CircuitBulkEditForm', 'CircuitBulkEditForm',
'CircuitTerminationBulkEditForm',
'CircuitTypeBulkEditForm', 'CircuitTypeBulkEditForm',
'ProviderBulkEditForm', 'ProviderBulkEditForm',
'ProviderAccountBulkEditForm', 'ProviderAccountBulkEditForm',
@ -172,3 +174,48 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
nullable_fields = ( nullable_fields = (
'tenant', 'commit_rate', 'description', 'comments', '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')

View File

@ -1,10 +1,10 @@
from django import forms 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.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ 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 netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
@ -12,6 +12,7 @@ from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugFiel
__all__ = ( __all__ = (
'CircuitImportForm', 'CircuitImportForm',
'CircuitTerminationImportForm', 'CircuitTerminationImportForm',
'CircuitTerminationImportRelatedForm',
'CircuitTypeImportForm', 'CircuitTypeImportForm',
'ProviderImportForm', 'ProviderImportForm',
'ProviderAccountImportForm', '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( site = CSVModelChoiceField(
label=_('Site'), label=_('Site'),
queryset=Site.objects.all(), queryset=Site.objects.all(),
@ -125,9 +135,21 @@ class CircuitTerminationImportForm(forms.ModelForm):
required=False required=False
) )
class CircuitTerminationImportRelatedForm(BaseCircuitTerminationImportForm):
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination
fields = [ fields = [
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', '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'
] ]

View File

@ -1,7 +1,7 @@
from django import forms from django import forms
from django.utils.translation import gettext as _ 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 circuits.models import *
from dcim.models import Region, Site, SiteGroup from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN from ipam.models import ASN
@ -13,6 +13,7 @@ from utilities.forms.widgets import DatePicker, NumberWithOptions
__all__ = ( __all__ = (
'CircuitFilterForm', 'CircuitFilterForm',
'CircuitTerminationFilterForm',
'CircuitTypeFilterForm', 'CircuitTypeFilterForm',
'ProviderFilterForm', 'ProviderFilterForm',
'ProviderAccountFilterForm', 'ProviderAccountFilterForm',
@ -186,3 +187,46 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
) )
) )
tag = TagFilterField(model) 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)

View File

@ -227,7 +227,7 @@ class CircuitTermination(
return f'{self.circuit}: Termination {self.term_side}' return f'{self.circuit}: Termination {self.term_side}'
def get_absolute_url(self): def get_absolute_url(self):
return self.circuit.get_absolute_url() return reverse('circuits:circuittermination', args=[self.pk])
def clean(self): def clean(self):
super().clean() super().clean()

View File

@ -10,6 +10,7 @@ from .columns import CommitRateColumn
__all__ = ( __all__ = (
'CircuitTable', 'CircuitTable',
'CircuitTerminationTable',
'CircuitTypeTable', 'CircuitTypeTable',
) )
@ -88,3 +89,31 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
default_columns = ( default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', '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')

View File

@ -351,24 +351,26 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
providers = ( providers = (
Provider(name='Provider 1', slug='provider-1'), 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.objects.bulk_create(providers)
provider_networks = ( provider_networks = (
ProviderNetwork(name='Provider Network 1', provider=providers[0]), ProviderNetwork(name='Provider Network 1', provider=providers[0]),
ProviderNetwork(name='Provider Network 2', provider=providers[0]), ProviderNetwork(name='Provider Network 2', provider=providers[1]),
ProviderNetwork(name='Provider Network 3', provider=providers[0]), ProviderNetwork(name='Provider Network 3', provider=providers[2]),
) )
ProviderNetwork.objects.bulk_create(provider_networks) ProviderNetwork.objects.bulk_create(provider_networks)
circuits = ( circuits = (
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'), 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[1], type=circuit_types[0], cid='Circuit 2'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 3'), 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 4'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'), Circuit(provider=providers[1], type=circuit_types[0], cid='Circuit 5'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'), Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 6'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'), Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 7'),
) )
Circuit.objects.bulk_create(circuits) Circuit.objects.bulk_create(circuits)
@ -413,10 +415,17 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_circuit_id(self): 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]} params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) 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): def test_site(self):
sites = Site.objects.all()[:2] sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]} params = {'site_id': [sites[0].pk, sites[1].pk]}

View File

@ -5,8 +5,11 @@ from django.urls import reverse
from circuits.choices import * from circuits.choices import *
from circuits.models import * from circuits.models import *
from core.models import ObjectType
from dcim.models import Cable, Interface, Site from dcim.models import Cable, Interface, Site
from ipam.models import ASN, RIR 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 from utilities.testing import ViewTestCases, create_tags, create_test_device
@ -115,6 +118,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
Site.objects.create(name='Site 1', slug='site-1')
providers = ( providers = (
Provider(name='Provider 1', slug='provider-1'), Provider(name='Provider 1', slug='provider-1'),
@ -184,6 +188,51 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'comments': 'New comments', '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): class ProviderAccountTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ProviderAccount model = ProviderAccount
@ -287,10 +336,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
class CircuitTerminationTestCase( class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
):
model = CircuitTermination model = CircuitTermination
@classmethod @classmethod
@ -327,6 +373,24 @@ class CircuitTerminationTestCase(
'description': 'New description', '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=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self): def test_trace(self):
device = create_test_device('Device 1') device = create_test_device('Device 1')

View File

@ -48,7 +48,11 @@ urlpatterns = [
path('circuits/<int:pk>/', include(get_model_urls('circuits', 'circuit'))), path('circuits/<int:pk>/', include(get_model_urls('circuits', 'circuit'))),
# Circuit terminations # 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/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/<int:pk>/', include(get_model_urls('circuits', 'circuittermination'))), path('circuit-terminations/<int:pk>/', include(get_model_urls('circuits', 'circuittermination'))),
] ]

View File

@ -298,7 +298,7 @@ class CircuitBulkImportView(generic.BulkImportView):
'circuits.add_circuittermination', 'circuits.add_circuittermination',
] ]
related_object_forms = { related_object_forms = {
'terminations': forms.CircuitTerminationImportForm, 'terminations': forms.CircuitTerminationImportRelatedForm,
} }
def prep_related_object_data(self, parent, data): def prep_related_object_data(self, parent, data):
@ -408,6 +408,18 @@ class CircuitContactsView(ObjectContactsView):
# Circuit terminations # 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') @register_model_view(CircuitTermination, 'edit')
class CircuitTerminationEditView(generic.ObjectEditView): class CircuitTerminationEditView(generic.ObjectEditView):
queryset = CircuitTermination.objects.all() queryset = CircuitTermination.objects.all()
@ -419,5 +431,23 @@ class CircuitTerminationDeleteView(generic.ObjectDeleteView):
queryset = CircuitTermination.objects.all() 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 # Trace view
register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView) register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView)

View File

@ -258,6 +258,7 @@ CIRCUITS_MENU = Menu(
items=( items=(
get_model_item('circuits', 'circuit', _('Circuits')), get_model_item('circuits', 'circuit', _('Circuits')),
get_model_item('circuits', 'circuittype', _('Circuit Types')), get_model_item('circuits', 'circuittype', _('Circuit Types')),
get_model_item('circuits', 'circuittermination', _('Circuit Terminations')),
), ),
), ),
MenuGroup( MenuGroup(

View File

@ -0,0 +1,51 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item"><a href="{% url 'circuits:circuit_list' %}?provider_id={{ object.circuit.provider.pk }}">{{ object.circuit.provider }}</a></li>
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
{% if object %}
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Circuit" %}</th>
<td>
{{ object.circuit|linkify }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Provider" %}</th>
<td>
{{ object.circuit.provider|linkify }}
</td>
</tr>
{% include 'circuits/inc/circuit_termination_fields.html' with termination=object %}
</table>
{% else %}
<div class="card-body">
<span class="text-muted">{% trans "None" %}</span>
</div>
{% endif %}
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -27,93 +27,7 @@
</h5> </h5>
{% if termination %} {% if termination %}
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
{% if termination.site %} {% include 'circuits/inc/circuit_termination_fields.html' with termination=termination %}
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>
{% if termination.site.region %}
{{ termination.site.region|linkify }} /
{% endif %}
{{ termination.site|linkify }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Termination" %}</th>
<td>
{% if termination.mark_connected %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
<span class="text-muted">{% trans "Marked as connected" %}</span>
{% elif termination.cable %}
<a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a> {% trans "to" %}
{% for peer in termination.link_peers %}
{% if peer.device %}
{{ peer.device|linkify }}<br/>
{% elif peer.circuit %}
{{ peer.circuit|linkify }}<br/>
{% endif %}
{{ peer|linkify }}{% if not forloop.last %},{% endif %}
{% endfor %}
<div class="mt-1">
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> {% trans "Trace" %}
</a>
{% if perms.dcim.change_cable %}
<a href="{% url 'dcim:cable_edit' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Edit cable" %}" class="btn btn-warning lh-1">
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> {% trans "Edit" %}
</a>
{% endif %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Remove cable" %}" class="btn btn-danger lh-1">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> {% trans "Disconnect" %}
</a>
{% endif %}
</div>
{% elif perms.dcim.add_cable %}
<div class="dropdown">
<button type="button" class="btn btn-success dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">{% trans "Interface" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">{% trans "Front Port" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">{% trans "Rear Port" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">{% trans "Circuit Termination" %}</a></li>
</ul>
</div>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<th scope="row">{% trans "Provider Network" %}</th>
<td>{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Speed" %}</th>
<td>
{% if termination.port_speed and termination.upstream_speed %}
<i class="mdi mdi-arrow-down-bold" title="{% trans "Downstream" %}"></i> {{ termination.port_speed|humanize_speed }} &nbsp;
<i class="mdi mdi-arrow-up-bold" title="{% trans "Upstream" %}"></i> {{ termination.upstream_speed|humanize_speed }}
{% elif termination.port_speed %}
{{ termination.port_speed|humanize_speed }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Cross-Connect" %}</th>
<td>{{ termination.xconnect_id|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Patch Panel/Port" %}</th>
<td>{{ termination.pp_info|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ termination.description|placeholder }}</td>
</tr>
<tr> <tr>
<th scope="row">{% trans "Tags" %}</th> <th scope="row">{% trans "Tags" %}</th>
<td> <td>

View File

@ -0,0 +1,90 @@
{% load helpers %}
{% load i18n %}
{% if termination.site %}
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>
{% if termination.site.region %}
{{ termination.site.region|linkify }} /
{% endif %}
{{ termination.site|linkify }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Termination" %}</th>
<td>
{% if termination.mark_connected %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
<span class="text-muted">{% trans "Marked as connected" %}</span>
{% elif termination.cable %}
<a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a> {% trans "to" %}
{% for peer in termination.link_peers %}
{% if peer.device %}
{{ peer.device|linkify }}<br/>
{% elif peer.circuit %}
{{ peer.circuit|linkify }}<br/>
{% endif %}
{{ peer|linkify }}{% if not forloop.last %},{% endif %}
{% endfor %}
<div class="mt-1">
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> {% trans "Trace" %}
</a>
{% if perms.dcim.change_cable %}
<a href="{% url 'dcim:cable_edit' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Edit cable" %}" class="btn btn-warning lh-1">
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> {% trans "Edit" %}
</a>
{% endif %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Remove cable" %}" class="btn btn-danger lh-1">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> {% trans "Disconnect" %}
</a>
{% endif %}
</div>
{% elif perms.dcim.add_cable %}
<div class="dropdown">
<button type="button" class="btn btn-success dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">{% trans "Interface" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">{% trans "Front Port" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">{% trans "Rear Port" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">{% trans "Circuit Termination" %}</a></li>
</ul>
</div>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<th scope="row">{% trans "Provider Network" %}</th>
<td>{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Speed" %}</th>
<td>
{% if termination.port_speed and termination.upstream_speed %}
<i class="mdi mdi-arrow-down-bold" title="{% trans "Downstream" %}"></i> {{ termination.port_speed|humanize_speed }} &nbsp;
<i class="mdi mdi-arrow-up-bold" title="{% trans "Upstream" %}"></i> {{ termination.upstream_speed|humanize_speed }}
{% elif termination.port_speed %}
{{ termination.port_speed|humanize_speed }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Cross-Connect" %}</th>
<td>{{ termination.xconnect_id|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Patch Panel/Port" %}</th>
<td>{{ termination.pp_info|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ termination.description|placeholder }}</td>
</tr>