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

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>
Arthur Hanson 1 год назад
Родитель
Сommit
b2d2a23c26

+ 11 - 0
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

+ 49 - 2
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')

+ 27 - 5
netbox/circuits/forms/bulk_import.py

@@ -1,10 +1,10 @@
 from django import forms
+from django.utils.safestring import mark_safe
+from django.utils.translation import gettext_lazy as _
 
-from circuits.choices import CircuitStatusChoices
+from circuits.choices import *
 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 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'
+        ]
+
+
+class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTerminationImportForm):
+
     class Meta:
         model = CircuitTermination
         fields = [
             'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
-            'pp_info', 'description',
+            'pp_info', 'description', 'tags'
         ]

+ 45 - 1
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)

+ 1 - 1
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()

+ 29 - 0
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')

+ 17 - 8
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]}

+ 68 - 4
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')

+ 4 - 0
netbox/circuits/urls.py

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

+ 31 - 1
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)

+ 1 - 0
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(

+ 51 - 0
netbox/templates/circuits/circuittermination.html

@@ -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 %}

+ 1 - 87
netbox/templates/circuits/inc/circuit_termination.html

@@ -27,93 +27,7 @@
     </h5>
       {% if termination %}
         <table class="table table-hover attr-table">
-          {% 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>
+          {% include 'circuits/inc/circuit_termination_fields.html' with termination=termination %}
             <tr>
               <th scope="row">{% trans "Tags" %}</th>
               <td>

+ 90 - 0
netbox/templates/circuits/inc/circuit_termination_fields.html

@@ -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>