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

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(),
         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

+ 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.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.widgets import DatePicker, NumberWithOptions
+from utilities.forms.rendering import FieldSet, TabbedGroups
+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')

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

@@ -1,10 +1,10 @@
 from django import forms
 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 circuits.models import *
 from dcim.models import Site
 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 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:
+        model = CircuitTermination
+        fields = [
+            'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
+            'pp_info', 'description'
+        ]
+
+
+class CircuitTerminationImportForm(NetBoxModelImportForm, 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', 'tags'
         ]
         ]

+ 45 - 1
netbox/circuits/forms/filtersets.py

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

+ 1 - 1
netbox/circuits/models/circuits.py

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

+ 29 - 0
netbox/circuits/tables/circuits.py

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

+ 17 - 8
netbox/circuits/tests/test_filtersets.py

@@ -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 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)
         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[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 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)
         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]}

+ 68 - 4
netbox/circuits/tests/test_views.py

@@ -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(
-    ViewTestCases.EditObjectViewTestCase,
-    ViewTestCases.DeleteObjectViewTestCase,
-):
+class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     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')

+ 4 - 0
netbox/circuits/urls.py

@@ -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'))),
 
 
 ]
 ]

+ 31 - 1
netbox/circuits/views.py

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

+ 1 - 0
netbox/netbox/navigation/menu.py

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

+ 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>
     </h5>
       {% if termination %}
       {% if termination %}
         <table class="table table-hover attr-table">
         <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>
             <tr>
               <th scope="row">{% trans "Tags" %}</th>
               <th scope="row">{% trans "Tags" %}</th>
               <td>
               <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>