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

Extend Cable model to support multiple A/B terminations

jeremystretch 3 лет назад
Родитель
Сommit
4bb9b6ee26

+ 7 - 9
netbox/dcim/api/serializers.py

@@ -977,8 +977,8 @@ class CableSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = Cable
         model = Cable
         fields = [
         fields = [
-            'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
-            'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
+            'id', 'url', 'display', 'termination_a_type', 'termination_a_ids', 'termination_a', 'termination_b_type',
+            'termination_b_ids', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
             'tags', 'custom_fields', 'created', 'last_updated',
             'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
 
 
@@ -986,14 +986,12 @@ class CableSerializer(NetBoxModelSerializer):
         """
         """
         Serialize a nested representation of a termination.
         Serialize a nested representation of a termination.
         """
         """
-        if side.lower() not in ['a', 'b']:
-            raise ValueError("Termination side must be either A or B.")
-        termination = getattr(obj, 'termination_{}'.format(side.lower()))
-        if termination is None:
-            return None
-        serializer = get_serializer_for_model(termination, prefix='Nested')
+        assert side.lower() in ('a', 'b')
+        termination_type = getattr(obj, f'termination_{side}_type').model_class()
+        termination = getattr(obj, f'termination_{side}')
+        serializer = get_serializer_for_model(termination_type, prefix='Nested')
         context = {'request': self.context['request']}
         context = {'request': self.context['request']}
-        data = serializer(termination, context=context).data
+        data = serializer(termination, context=context, many=True).data
 
 
         return data
         return data
 
 

+ 1 - 3
netbox/dcim/api/views.py

@@ -647,9 +647,7 @@ class InventoryItemRoleViewSet(NetBoxModelViewSet):
 
 
 class CableViewSet(NetBoxModelViewSet):
 class CableViewSet(NetBoxModelViewSet):
     metadata_class = ContentTypeMetadata
     metadata_class = ContentTypeMetadata
-    queryset = Cable.objects.prefetch_related(
-        'termination_a', 'termination_b'
-    )
+    queryset = Cable.objects.all()
     serializer_class = serializers.CableSerializer
     serializer_class = serializers.CableSerializer
     filterset_class = filtersets.CableFilterSet
     filterset_class = filtersets.CableFilterSet
 
 

+ 5 - 5
netbox/dcim/filtersets.py

@@ -1499,9 +1499,9 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet):
 
 
 class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
 class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
     termination_a_type = ContentTypeFilter()
     termination_a_type = ContentTypeFilter()
-    termination_a_id = MultiValueNumberFilter()
+    termination_a_ids = MultiValueNumberFilter()
     termination_b_type = ContentTypeFilter()
     termination_b_type = ContentTypeFilter()
-    termination_b_id = MultiValueNumberFilter()
+    termination_b_ids = MultiValueNumberFilter()
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
         choices=CableTypeChoices
         choices=CableTypeChoices
     )
     )
@@ -1537,7 +1537,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = Cable
         model = Cable
-        fields = ['id', 'label', 'length', 'length_unit', 'termination_a_id', 'termination_b_id']
+        fields = ['id', 'label', 'length', 'length_unit', 'termination_a_ids', 'termination_b_ids']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -1546,8 +1546,8 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
 
 
     def filter_device(self, queryset, name, value):
     def filter_device(self, queryset, name, value):
         queryset = queryset.filter(
         queryset = queryset.filter(
-            Q(**{'_termination_a_{}__in'.format(name): value}) |
-            Q(**{'_termination_b_{}__in'.format(name): value})
+            Q(**{f'_termination_a_{name}__in': value}) |
+            Q(**{f'_termination_b_{name}__in': value})
         )
         )
         return queryset
         return queryset
 
 

+ 23 - 23
netbox/dcim/forms/connections.py

@@ -2,7 +2,7 @@ from circuits.models import Circuit, CircuitTermination, Provider
 from dcim.models import *
 from dcim.models import *
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
-from utilities.forms import DynamicModelChoiceField, StaticSelect
+from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
 
 
 __all__ = (
 __all__ = (
     'ConnectCableToCircuitTerminationForm',
     'ConnectCableToCircuitTerminationForm',
@@ -22,7 +22,7 @@ class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm):
     Base form for connecting a Cable to a Device component
     Base form for connecting a Cable to a Device component
     """
     """
     # Termination A
     # Termination A
-    termination_a_id = DynamicModelChoiceField(
+    termination_a_ids = DynamicModelMultipleChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         label='Name',
         label='Name',
         disabled_indicator='_occupied'
         disabled_indicator='_occupied'
@@ -87,8 +87,8 @@ class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm):
     class Meta:
     class Meta:
         model = Cable
         model = Cable
         fields = [
         fields = [
-            'termination_a_id', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site',
-            'termination_b_rack', 'termination_b_device', 'termination_b_id', 'type', 'status', 'tenant_group',
+            'termination_a_ids', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site',
+            'termination_b_rack', 'termination_b_device', 'termination_b_ids', 'type', 'status', 'tenant_group',
             'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
             'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
         ]
         ]
         widgets = {
         widgets = {
@@ -97,17 +97,17 @@ class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm):
             'length_unit': StaticSelect,
             'length_unit': StaticSelect,
         }
         }
 
 
-    def clean_termination_a_id(self):
+    def clean_termination_a_ids(self):
         # Return the PK rather than the object
         # Return the PK rather than the object
-        return getattr(self.cleaned_data['termination_a_id'], 'pk', None)
+        return [getattr(obj, 'pk') for obj in self.cleaned_data['termination_a_ids']]
 
 
-    def clean_termination_b_id(self):
+    def clean_termination_b_ids(self):
         # Return the PK rather than the object
         # Return the PK rather than the object
-        return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
+        return [getattr(obj, 'pk') for obj in self.cleaned_data['termination_b_ids']]
 
 
 
 
 class ConnectCableToConsolePortForm(ConnectCableToDeviceForm):
 class ConnectCableToConsolePortForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
+    termination_b_ids = DynamicModelMultipleChoiceField(
         queryset=ConsolePort.objects.all(),
         queryset=ConsolePort.objects.all(),
         label='Name',
         label='Name',
         disabled_indicator='_occupied',
         disabled_indicator='_occupied',
@@ -118,7 +118,7 @@ class ConnectCableToConsolePortForm(ConnectCableToDeviceForm):
 
 
 
 
 class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm):
 class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
+    termination_b_ids = DynamicModelMultipleChoiceField(
         queryset=ConsoleServerPort.objects.all(),
         queryset=ConsoleServerPort.objects.all(),
         label='Name',
         label='Name',
         disabled_indicator='_occupied',
         disabled_indicator='_occupied',
@@ -129,7 +129,7 @@ class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm):
 
 
 
 
 class ConnectCableToPowerPortForm(ConnectCableToDeviceForm):
 class ConnectCableToPowerPortForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
+    termination_b_ids = DynamicModelMultipleChoiceField(
         queryset=PowerPort.objects.all(),
         queryset=PowerPort.objects.all(),
         label='Name',
         label='Name',
         disabled_indicator='_occupied',
         disabled_indicator='_occupied',
@@ -140,7 +140,7 @@ class ConnectCableToPowerPortForm(ConnectCableToDeviceForm):
 
 
 
 
 class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm):
 class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
+    termination_b_ids = DynamicModelMultipleChoiceField(
         queryset=PowerOutlet.objects.all(),
         queryset=PowerOutlet.objects.all(),
         label='Name',
         label='Name',
         disabled_indicator='_occupied',
         disabled_indicator='_occupied',
@@ -151,7 +151,7 @@ class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm):
 
 
 
 
 class ConnectCableToInterfaceForm(ConnectCableToDeviceForm):
 class ConnectCableToInterfaceForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
+    termination_b_ids = DynamicModelMultipleChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         label='Name',
         label='Name',
         disabled_indicator='_occupied',
         disabled_indicator='_occupied',
@@ -163,7 +163,7 @@ class ConnectCableToInterfaceForm(ConnectCableToDeviceForm):
 
 
 
 
 class ConnectCableToFrontPortForm(ConnectCableToDeviceForm):
 class ConnectCableToFrontPortForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
+    termination_b_ids = DynamicModelMultipleChoiceField(
         queryset=FrontPort.objects.all(),
         queryset=FrontPort.objects.all(),
         label='Name',
         label='Name',
         disabled_indicator='_occupied',
         disabled_indicator='_occupied',
@@ -174,7 +174,7 @@ class ConnectCableToFrontPortForm(ConnectCableToDeviceForm):
 
 
 
 
 class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
 class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
+    termination_b_ids = DynamicModelMultipleChoiceField(
         queryset=RearPort.objects.all(),
         queryset=RearPort.objects.all(),
         label='Name',
         label='Name',
         disabled_indicator='_occupied',
         disabled_indicator='_occupied',
@@ -186,7 +186,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
 
 
 class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm):
 class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm):
     # Termination A
     # Termination A
-    termination_a_id = DynamicModelChoiceField(
+    termination_a_ids = DynamicModelMultipleChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         label='Side',
         label='Side',
         disabled_indicator='_occupied'
         disabled_indicator='_occupied'
@@ -231,7 +231,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm):
             'site_id': '$termination_b_site',
             'site_id': '$termination_b_site',
         }
         }
     )
     )
-    termination_b_id = DynamicModelChoiceField(
+    termination_b_ids = DynamicModelMultipleChoiceField(
         queryset=CircuitTermination.objects.all(),
         queryset=CircuitTermination.objects.all(),
         label='Side',
         label='Side',
         disabled_indicator='_occupied',
         disabled_indicator='_occupied',
@@ -242,8 +242,8 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm):
 
 
     class Meta(ConnectCableToDeviceForm.Meta):
     class Meta(ConnectCableToDeviceForm.Meta):
         fields = [
         fields = [
-            'termination_a_id', 'termination_b_provider', 'termination_b_region', 'termination_b_sitegroup',
-            'termination_b_site', 'termination_b_circuit', 'termination_b_id', 'type', 'status', 'tenant_group',
+            'termination_a_ids', 'termination_b_provider', 'termination_b_region', 'termination_b_sitegroup',
+            'termination_b_site', 'termination_b_circuit', 'termination_b_ids', 'type', 'status', 'tenant_group',
             'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
             'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
         ]
         ]
 
 
@@ -258,7 +258,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm):
 
 
 class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm):
 class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm):
     # Termination A
     # Termination A
-    termination_a_id = DynamicModelChoiceField(
+    termination_a_ids = DynamicModelMultipleChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         label='Name',
         label='Name',
         disabled_indicator='_occupied'
         disabled_indicator='_occupied'
@@ -307,7 +307,7 @@ class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm):
             'location_id': '$termination_b_location',
             'location_id': '$termination_b_location',
         }
         }
     )
     )
-    termination_b_id = DynamicModelChoiceField(
+    termination_b_ids = DynamicModelMultipleChoiceField(
         queryset=PowerFeed.objects.all(),
         queryset=PowerFeed.objects.all(),
         label='Name',
         label='Name',
         disabled_indicator='_occupied',
         disabled_indicator='_occupied',
@@ -318,8 +318,8 @@ class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm):
 
 
     class Meta(ConnectCableToDeviceForm.Meta):
     class Meta(ConnectCableToDeviceForm.Meta):
         fields = [
         fields = [
-            'termination_a_id', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site',
-            'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group',
+            'termination_a_ids', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site',
+            'termination_b_location', 'termination_b_powerpanel', 'termination_b_ids', 'type', 'status', 'tenant_group',
             'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
             'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
         ]
         ]
 
 

+ 24 - 0
netbox/dcim/migrations/0154_cable_add_termination_id_arrays.py

@@ -0,0 +1,24 @@
+# Generated by Django 4.0.4 on 2022-04-25 16:35
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0153_created_datetimefield'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='cable',
+            name='termination_a_ids',
+            field=django.contrib.postgres.fields.ArrayField(base_field=models.PositiveBigIntegerField(), null=True, size=None),
+        ),
+        migrations.AddField(
+            model_name='cable',
+            name='termination_b_ids',
+            field=django.contrib.postgres.fields.ArrayField(base_field=models.PositiveBigIntegerField(), null=True, size=None),
+        ),
+    ]

+ 36 - 0
netbox/dcim/migrations/0155_cable_copy_termination_ids.py

@@ -0,0 +1,36 @@
+from django.contrib.postgres.fields import ArrayField
+from django.db import migrations
+from django.db.models import ExpressionWrapper, F
+
+
+def copy_termination_ids(apps, schema_editor):
+    """
+    Copy original A & B termination ID values to new array fields.
+    """
+    Cable = apps.get_model('dcim', 'Cable')
+
+    # TODO: Optimize data migration using F expressions
+    # Cable.objects.update(
+    #     termination_a_ids=ExpressionWrapper(F('termination_a_id'), output_field=ArrayField),
+    #     termination_b_ids=ExpressionWrapper(F('termination_b_id'), output_field=ArrayField)
+    # )
+
+    for cable in Cable.objects.all():
+        Cable.objects.filter(pk=cable.pk).update(
+            termination_a_ids=[cable.termination_a_id],
+            termination_b_ids=[cable.termination_b_id]
+        )
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0154_cable_add_termination_id_arrays'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=copy_termination_ids,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 25 - 0
netbox/dcim/migrations/0156_cable_delete_old_termination_ids.py

@@ -0,0 +1,25 @@
+# Generated by Django 4.0.4 on 2022-04-25 20:45
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0155_cable_copy_termination_ids'),
+    ]
+
+    operations = [
+        migrations.AlterUniqueTogether(
+            name='cable',
+            unique_together=set(),
+        ),
+        migrations.RemoveField(
+            model_name='cable',
+            name='termination_a_id',
+        ),
+        migrations.RemoveField(
+            model_name='cable',
+            name='termination_b_id',
+        ),
+    ]

+ 79 - 84
netbox/dcim/models/cables.py

@@ -2,6 +2,7 @@ from collections import defaultdict
 
 
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.db import models
 from django.db import models
 from django.db.models import Sum
 from django.db.models import Sum
@@ -38,10 +39,9 @@ class Cable(NetBoxModel):
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='+'
         related_name='+'
     )
     )
-    termination_a_id = models.PositiveBigIntegerField()
-    termination_a = GenericForeignKey(
-        ct_field='termination_a_type',
-        fk_field='termination_a_id'
+    termination_a_ids = ArrayField(
+        base_field=models.PositiveBigIntegerField(),
+        null=True
     )
     )
     termination_b_type = models.ForeignKey(
     termination_b_type = models.ForeignKey(
         to=ContentType,
         to=ContentType,
@@ -49,10 +49,9 @@ class Cable(NetBoxModel):
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='+'
         related_name='+'
     )
     )
-    termination_b_id = models.PositiveBigIntegerField()
-    termination_b = GenericForeignKey(
-        ct_field='termination_b_type',
-        fk_field='termination_b_id'
+    termination_b_ids = ArrayField(
+        base_field=models.PositiveBigIntegerField(),
+        null=True
     )
     )
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
@@ -115,10 +114,6 @@ class Cable(NetBoxModel):
 
 
     class Meta:
     class Meta:
         ordering = ['pk']
         ordering = ['pk']
-        unique_together = (
-            ('termination_a_type', 'termination_a_id'),
-            ('termination_b_type', 'termination_b_id'),
-        )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
@@ -137,9 +132,9 @@ class Cable(NetBoxModel):
         instance = super().from_db(db, field_names, values)
         instance = super().from_db(db, field_names, values)
 
 
         instance._orig_termination_a_type_id = instance.termination_a_type_id
         instance._orig_termination_a_type_id = instance.termination_a_type_id
-        instance._orig_termination_a_id = instance.termination_a_id
+        instance._orig_termination_a_ids = instance.termination_a_ids
         instance._orig_termination_b_type_id = instance.termination_b_type_id
         instance._orig_termination_b_type_id = instance.termination_b_type_id
-        instance._orig_termination_b_id = instance.termination_b_id
+        instance._orig_termination_b_ids = instance.termination_b_ids
 
 
         return instance
         return instance
 
 
@@ -150,6 +145,18 @@ class Cable(NetBoxModel):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('dcim:cable', args=[self.pk])
         return reverse('dcim:cable', args=[self.pk])
 
 
+    @property
+    def termination_a(self):
+        if not hasattr(self, 'termination_a_type') or not self.termination_a_ids:
+            return []
+        return list(self.termination_a_type.model_class().objects.filter(pk__in=self.termination_a_ids))
+
+    @property
+    def termination_b(self):
+        if not hasattr(self, 'termination_b_type') or not self.termination_b_ids:
+            return []
+        return list(self.termination_b_type.model_class().objects.filter(pk__in=self.termination_b_ids))
+
     def clean(self):
     def clean(self):
         from circuits.models import CircuitTermination
         from circuits.models import CircuitTermination
 
 
@@ -158,9 +165,8 @@ class Cable(NetBoxModel):
         # Validate that termination A exists
         # Validate that termination A exists
         if not hasattr(self, 'termination_a_type'):
         if not hasattr(self, 'termination_a_type'):
             raise ValidationError('Termination A type has not been specified')
             raise ValidationError('Termination A type has not been specified')
-        try:
-            self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
-        except ObjectDoesNotExist:
+        model = self.termination_a_type.model_class()
+        if model.objects.filter(pk__in=self.termination_a_ids).count() != len(self.termination_a_ids):
             raise ValidationError({
             raise ValidationError({
                 'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
                 'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
             })
             })
@@ -168,9 +174,8 @@ class Cable(NetBoxModel):
         # Validate that termination B exists
         # Validate that termination B exists
         if not hasattr(self, 'termination_b_type'):
         if not hasattr(self, 'termination_b_type'):
             raise ValidationError('Termination B type has not been specified')
             raise ValidationError('Termination B type has not been specified')
-        try:
-            self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
-        except ObjectDoesNotExist:
+        model = self.termination_a_type.model_class()
+        if model.objects.filter(pk__in=self.termination_b_ids).count() != len(self.termination_b_ids):
             raise ValidationError({
             raise ValidationError({
                 'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
                 'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
             })
             })
@@ -180,14 +185,14 @@ class Cable(NetBoxModel):
             err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
             err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
             if (
             if (
                 self.termination_a_type_id != self._orig_termination_a_type_id or
                 self.termination_a_type_id != self._orig_termination_a_type_id or
-                self.termination_a_id != self._orig_termination_a_id
+                set(self.termination_a_ids) != set(self._orig_termination_a_ids)
             ):
             ):
                 raise ValidationError({
                 raise ValidationError({
                     'termination_a': err_msg
                     'termination_a': err_msg
                 })
                 })
             if (
             if (
                 self.termination_b_type_id != self._orig_termination_b_type_id or
                 self.termination_b_type_id != self._orig_termination_b_type_id or
-                self.termination_b_id != self._orig_termination_b_id
+                set(self.termination_b_ids) != set(self._orig_termination_b_ids)
             ):
             ):
                 raise ValidationError({
                 raise ValidationError({
                     'termination_b': err_msg
                     'termination_b': err_msg
@@ -197,18 +202,18 @@ class Cable(NetBoxModel):
         type_b = self.termination_b_type.model
         type_b = self.termination_b_type.model
 
 
         # Validate interface types
         # Validate interface types
-        if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
-            raise ValidationError({
-                'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
-                    self.termination_a.get_type_display()
-                )
-            })
-        if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
-            raise ValidationError({
-                'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
-                    self.termination_b.get_type_display()
-                )
-            })
+        if type_a == 'interface':
+            for term in self.termination_a:
+                if term.type in NONCONNECTABLE_IFACE_TYPES:
+                    raise ValidationError({
+                        'termination_a_id': f'Cables cannot be terminated to {term.get_type_display()} interfaces'
+                    })
+        if type_a == 'interface':
+            for term in self.termination_b:
+                if term.type in NONCONNECTABLE_IFACE_TYPES:
+                    raise ValidationError({
+                        'termination_b_id': f'Cables cannot be terminated to {term.get_type_display()} interfaces'
+                    })
 
 
         # Check that termination types are compatible
         # Check that termination types are compatible
         if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
         if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
@@ -216,50 +221,48 @@ class Cable(NetBoxModel):
                 f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
                 f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
             )
             )
 
 
-        # Check that two connected RearPorts have the same number of positions (if both are >1)
-        if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
-            if self.termination_a.positions > 1 and self.termination_b.positions > 1:
-                if self.termination_a.positions != self.termination_b.positions:
-                    raise ValidationError(
-                        f"{self.termination_a} has {self.termination_a.positions} position(s) but "
-                        f"{self.termination_b} has {self.termination_b.positions}. "
-                        f"Both terminations must have the same number of positions (if greater than one)."
-                    )
+        # TODO: Is this validation still necessary?
+        # # Check that two connected RearPorts have the same number of positions (if both are >1)
+        # if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
+        #     if self.termination_a.positions > 1 and self.termination_b.positions > 1:
+        #         if self.termination_a.positions != self.termination_b.positions:
+        #             raise ValidationError(
+        #                 f"{self.termination_a} has {self.termination_a.positions} position(s) but "
+        #                 f"{self.termination_b} has {self.termination_b.positions}. "
+        #                 f"Both terminations must have the same number of positions (if greater than one)."
+        #             )
 
 
         # A termination point cannot be connected to itself
         # A termination point cannot be connected to itself
-        if self.termination_a == self.termination_b:
+        if set(self.termination_a).intersection(self.termination_b):
             raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
             raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
 
 
-        # A front port cannot be connected to its corresponding rear port
-        if (
-            type_a in ['frontport', 'rearport'] and
-            type_b in ['frontport', 'rearport'] and
-            (
-                getattr(self.termination_a, 'rear_port', None) == self.termination_b or
-                getattr(self.termination_b, 'rear_port', None) == self.termination_a
-            )
-        ):
-            raise ValidationError("A front port cannot be connected to it corresponding rear port")
-
-        # A CircuitTermination attached to a ProviderNetwork cannot have a Cable
-        if isinstance(self.termination_a, CircuitTermination) and self.termination_a.provider_network is not None:
-            raise ValidationError({
-                'termination_a_id': "Circuit terminations attached to a provider network may not be cabled."
-            })
-        if isinstance(self.termination_b, CircuitTermination) and self.termination_b.provider_network is not None:
-            raise ValidationError({
-                'termination_b_id': "Circuit terminations attached to a provider network may not be cabled."
-            })
+        # TODO
+        # # A front port cannot be connected to its corresponding rear port
+        # if (
+        #     type_a in ['frontport', 'rearport'] and
+        #     type_b in ['frontport', 'rearport'] and
+        #     (
+        #         getattr(self.termination_a, 'rear_port', None) == self.termination_b or
+        #         getattr(self.termination_b, 'rear_port', None) == self.termination_a
+        #     )
+        # ):
+        #     raise ValidationError("A front port cannot be connected to it corresponding rear port")
+
+        # TODO
+        # # A CircuitTermination attached to a ProviderNetwork cannot have a Cable
+        # if isinstance(self.termination_a, CircuitTermination) and self.termination_a.provider_network is not None:
+        #     raise ValidationError({
+        #         'termination_a_id': "Circuit terminations attached to a provider network may not be cabled."
+        #     })
+        # if isinstance(self.termination_b, CircuitTermination) and self.termination_b.provider_network is not None:
+        #     raise ValidationError({
+        #         'termination_b_id': "Circuit terminations attached to a provider network may not be cabled."
+        #     })
 
 
         # Check for an existing Cable connected to either termination object
         # Check for an existing Cable connected to either termination object
-        if self.termination_a.cable not in (None, self):
-            raise ValidationError("{} already has a cable attached (#{})".format(
-                self.termination_a, self.termination_a.cable_id
-            ))
-        if self.termination_b.cable not in (None, self):
-            raise ValidationError("{} already has a cable attached (#{})".format(
-                self.termination_b, self.termination_b.cable_id
-            ))
+        for term in [*self.termination_a, *self.termination_b]:
+            if term.cable not in (None, self):
+                raise ValidationError(f'{term} already has a cable attached (#{term.cable_id})')
 
 
         # Validate length and length_unit
         # Validate length and length_unit
         if self.length is not None and not self.length_unit:
         if self.length is not None and not self.length_unit:
@@ -276,10 +279,10 @@ class Cable(NetBoxModel):
             self._abs_length = None
             self._abs_length = None
 
 
         # Store the parent Device for the A and B terminations (if applicable) to enable filtering
         # Store the parent Device for the A and B terminations (if applicable) to enable filtering
-        if hasattr(self.termination_a, 'device'):
-            self._termination_a_device = self.termination_a.device
-        if hasattr(self.termination_b, 'device'):
-            self._termination_b_device = self.termination_b.device
+        if hasattr(self.termination_a[0], 'device'):
+            self._termination_a_device = self.termination_a[0].device
+        if hasattr(self.termination_b[0], 'device'):
+            self._termination_b_device = self.termination_b[0].device
 
 
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
@@ -289,14 +292,6 @@ class Cable(NetBoxModel):
     def get_status_color(self):
     def get_status_color(self):
         return LinkStatusChoices.colors.get(self.status)
         return LinkStatusChoices.colors.get(self.status)
 
 
-    def get_compatible_types(self):
-        """
-        Return all termination types compatible with termination A.
-        """
-        if self.termination_a is None:
-            return
-        return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
-
 
 
 class CablePath(models.Model):
 class CablePath(models.Model):
     """
     """

+ 21 - 18
netbox/dcim/signals.py

@@ -79,21 +79,24 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs):
         logger.debug(f"Skipping endpoint updates for imported cable {instance}")
         logger.debug(f"Skipping endpoint updates for imported cable {instance}")
         return
         return
 
 
-    # Cache the Cable on its two termination points
-    if instance.termination_a.cable != instance:
-        logger.debug(f"Updating termination A for cable {instance}")
-        instance.termination_a.cable = instance
-        instance.termination_a._link_peer = instance.termination_b
-        instance.termination_a.save()
-    if instance.termination_b.cable != instance:
-        logger.debug(f"Updating termination B for cable {instance}")
-        instance.termination_b.cable = instance
-        instance.termination_b._link_peer = instance.termination_a
-        instance.termination_b.save()
+    # TODO: Update link peer fields
+    # Cache the Cable on its termination points
+    for term in instance.termination_a:
+        if term.cable != instance:
+            logger.debug(f"Updating termination A for cable {instance}: {term}")
+            term.cable = instance
+            # term._link_peer = instance.termination_b
+            term.save()
+    for term in instance.termination_b:
+        if term.cable != instance:
+            logger.debug(f"Updating termination B for cable {instance}")
+            term.cable = instance
+            # term._link_peer = instance.termination_a
+            term.save()
 
 
     # Create/update cable paths
     # Create/update cable paths
     if created:
     if created:
-        for termination in (instance.termination_a, instance.termination_b):
+        for termination in [*instance.termination_a, *instance.termination_b]:
             if isinstance(termination, PathEndpoint):
             if isinstance(termination, PathEndpoint):
                 create_cablepath(termination)
                 create_cablepath(termination)
             else:
             else:
@@ -116,14 +119,14 @@ def nullify_connected_endpoints(instance, **kwargs):
     logger = logging.getLogger('netbox.dcim.cable')
     logger = logging.getLogger('netbox.dcim.cable')
 
 
     # Disassociate the Cable from its termination points
     # Disassociate the Cable from its termination points
-    if instance.termination_a is not None:
+    if instance.termination_a:
         logger.debug(f"Nullifying termination A for cable {instance}")
         logger.debug(f"Nullifying termination A for cable {instance}")
-        model = instance.termination_a._meta.model
-        model.objects.filter(pk=instance.termination_a.pk).update(_link_peer_type=None, _link_peer_id=None)
-    if instance.termination_b is not None:
+        model = instance.termination_a_type.model_class()
+        model.objects.filter(pk__in=instance.termination_a_ids).update(_link_peer_type=None, _link_peer_id=None)
+    if instance.termination_b:
         logger.debug(f"Nullifying termination B for cable {instance}")
         logger.debug(f"Nullifying termination B for cable {instance}")
-        model = instance.termination_b._meta.model
-        model.objects.filter(pk=instance.termination_b.pk).update(_link_peer_type=None, _link_peer_id=None)
+        model = instance.termination_b_type.model_class()
+        model.objects.filter(pk__in=instance.termination_b_ids).update(_link_peer_type=None, _link_peer_id=None)
 
 
     # Delete and retrace any dependent cable paths
     # Delete and retrace any dependent cable paths
     for cablepath in CablePath.objects.filter(path__contains=instance):
     for cablepath in CablePath.objects.filter(path__contains=instance):

+ 5 - 3
netbox/dcim/tables/cables.py

@@ -4,7 +4,7 @@ from django_tables2.utils import Accessor
 from dcim.models import Cable
 from dcim.models import Cable
 from netbox.tables import NetBoxTable, columns
 from netbox.tables import NetBoxTable, columns
 from tenancy.tables import TenantColumn
 from tenancy.tables import TenantColumn
-from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
+from .template_code import CABLE_LENGTH, CABLE_TERMINATION, CABLE_TERMINATION_PARENT
 
 
 __all__ = (
 __all__ = (
     'CableTable',
     'CableTable',
@@ -28,7 +28,8 @@ class CableTable(NetBoxTable):
         linkify=True,
         linkify=True,
         verbose_name='Rack A'
         verbose_name='Rack A'
     )
     )
-    termination_a = tables.Column(
+    termination_a = tables.TemplateColumn(
+        template_code=CABLE_TERMINATION,
         accessor=Accessor('termination_a'),
         accessor=Accessor('termination_a'),
         orderable=False,
         orderable=False,
         linkify=True,
         linkify=True,
@@ -46,7 +47,8 @@ class CableTable(NetBoxTable):
         linkify=True,
         linkify=True,
         verbose_name='Rack B'
         verbose_name='Rack B'
     )
     )
-    termination_b = tables.Column(
+    termination_b = tables.TemplateColumn(
+        template_code=CABLE_TERMINATION,
         accessor=Accessor('termination_b'),
         accessor=Accessor('termination_b'),
         orderable=False,
         orderable=False,
         linkify=True,
         linkify=True,

+ 13 - 7
netbox/dcim/tables/template_code.py

@@ -13,14 +13,20 @@ CABLE_LENGTH = """
 {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
 {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
 """
 """
 
 
+CABLE_TERMINATION = """
+{{ value|join:", " }}
+"""
+
 CABLE_TERMINATION_PARENT = """
 CABLE_TERMINATION_PARENT = """
-{% if value.device %}
-    <a href="{{ value.device.get_absolute_url }}">{{ value.device }}</a>
-{% elif value.circuit %}
-    <a href="{{ value.circuit.get_absolute_url }}">{{ value.circuit }}</a>
-{% elif value.power_panel %}
-    <a href="{{ value.power_panel.get_absolute_url }}">{{ value.power_panel }}</a>
-{% endif %}
+{% with value.0 as termination %}
+  {% if termination.device %}
+    <a href="{{ termination.device.get_absolute_url }}">{{ termination.device }}</a>
+  {% elif termination.circuit %}
+    <a href="{{ termination.circuit.get_absolute_url }}">{{ termination.circuit }}</a>
+  {% elif termination.power_panel %}
+    <a href="{{ termination.power_panel.get_absolute_url }}">{{ termination.power_panel }}</a>
+  {% endif %}
+{% endwith %}
 """
 """
 
 
 DEVICE_LINK = """
 DEVICE_LINK = """

+ 13 - 14
netbox/dcim/views.py

@@ -2831,12 +2831,13 @@ class CableCreateView(generic.ObjectEditView):
 
 
     def alter_object(self, obj, request, url_args, url_kwargs):
     def alter_object(self, obj, request, url_args, url_kwargs):
         termination_a_type = url_kwargs.get('termination_a_type')
         termination_a_type = url_kwargs.get('termination_a_type')
-        termination_a_id = request.GET.get('termination_a_id')
+        termination_a_ids = request.GET.get('termination_a_ids', [])
         app_label, model = request.GET.get('termination_b_type').split('.')
         app_label, model = request.GET.get('termination_b_type').split('.')
         self.termination_b_type = ContentType.objects.get(app_label=app_label, model=model)
         self.termination_b_type = ContentType.objects.get(app_label=app_label, model=model)
 
 
         # Initialize Cable termination attributes
         # Initialize Cable termination attributes
-        obj.termination_a = termination_a_type.objects.get(pk=termination_a_id)
+        obj.termination_a_type = ContentType.objects.get_for_model(termination_a_type)
+        obj.termination_a_ids = termination_a_type.objects.filter(pk__in=termination_a_ids)
         obj.termination_b_type = self.termination_b_type
         obj.termination_b_type = self.termination_b_type
 
 
         return obj
         return obj
@@ -2844,21 +2845,19 @@ class CableCreateView(generic.ObjectEditView):
     def get(self, request, *args, **kwargs):
     def get(self, request, *args, **kwargs):
         obj = self.get_object(**kwargs)
         obj = self.get_object(**kwargs)
         obj = self.alter_object(obj, request, args, kwargs)
         obj = self.alter_object(obj, request, args, kwargs)
-
-        # Parse initial data manually to avoid setting field values as lists
-        initial_data = {k: request.GET[k] for k in request.GET}
-
-        # Set initial site and rack based on side A termination (if not already set)
-        termination_a_site = getattr(obj.termination_a.parent_object, 'site', None)
-        if 'termination_b_site' not in initial_data:
-            initial_data['termination_b_site'] = termination_a_site
-        if 'termination_b_rack' not in initial_data:
-            initial_data['termination_b_rack'] = getattr(obj.termination_a.parent_object, 'rack', None)
-
+        initial_data = request.GET
+
+        # TODO
+        # # Set initial site and rack based on side A termination (if not already set)
+        # termination_a_site = getattr(obj.termination_a.parent_object, 'site', None)
+        # if 'termination_b_site' not in initial_data:
+        #     initial_data['termination_b_site'] = termination_a_site
+        # if 'termination_b_rack' not in initial_data:
+        #     initial_data['termination_b_rack'] = getattr(obj.termination_a.parent_object, 'rack', None)
         form = self.form(instance=obj, initial=initial_data)
         form = self.form(instance=obj, initial=initial_data)
 
 
         # Set the queryset of termination A
         # Set the queryset of termination A
-        form.fields['termination_a_id'].queryset = kwargs['termination_a_type'].objects.all()
+        form.fields['termination_a_ids'].queryset = kwargs['termination_a_type'].objects.all()
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'obj': obj,
             'obj': obj,

+ 71 - 77
netbox/templates/dcim/cable.html

@@ -5,85 +5,79 @@
 {% load plugins %}
 {% load plugins %}
 
 
 {% block content %}
 {% block content %}
-    <div class="row">
-        <div class="col col-md-6">
-            <div class="card">
-                <h5 class="card-header">
-                    Cable
-                </h5>
-                <div class="card-body">
-                    <table class="table table-hover attr-table">
-                        <tr>
-                            <th scope="row">Type</th>
-                            <td>{{ object.get_type_display|placeholder }}</td>
-                        </tr>
-                        <tr>
-                            <th scope="row">Status</th>
-                            <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
-                        </tr>
-                        <tr>
-                            <th scope="row">Tenant</th>
-                            <td>
-                                {% if object.tenant.group %}
-                                    {{ object.tenant.group|linkify }} /
-                                {% endif %}
-                                {{ object.tenant|linkify|placeholder }}
-                            </td>
-                        </tr>
-                        <tr>
-                            <th scope="row">Label</th>
-                            <td>{{ object.label|placeholder }}</td>
-                        </tr>
-                        <tr>
-                            <th scope="row">Color</th>
-                            <td>
-                                {% if object.color %}
-                                    <span class="color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
-                                {% else %}
-                                    <span class="text-muted">&mdash;</span>
-                                {% endif %}
-                            </td>
-                        </tr>
-                        <tr>
-                            <th scope="row">Length</th>
-                            <td>
-                                {% if object.length %}
-                                    {{ object.length|floatformat }} {{ object.get_length_unit_display }}
-                                {% else %}
-                                    <span class="text-muted">&mdash;</span>
-                                {% endif %}
-                            </td>
-                        </tr>
-                    </table>
-                </div>
-            </div>
-            {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' %}
-            {% plugin_left_page object %}
-        </div>
-        <div class="col col-md-6">
-            <div class="card">
-                <h5 class="card-header">
-                    Termination A
-                </h5>
-                <div class="card-body">
-                {% include 'dcim/inc/cable_termination.html' with termination=object.termination_a %}
-                </div>
-            </div>
-            <div class="card">
-                <h5 class="card-header">
-                    Termination B
-                </h5>
-                <div class="card-body">
-                {% include 'dcim/inc/cable_termination.html' with termination=object.termination_b %}
-                </div>
-            </div>
-            {% plugin_right_page object %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Cable</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Type</th>
+              <td>{{ object.get_type_display|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Status</th>
+              <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
+            </tr>
+            <tr>
+              <th scope="row">Tenant</th>
+              <td>
+                {% if object.tenant.group %}
+                  {{ object.tenant.group|linkify }} /
+                {% endif %}
+                {{ object.tenant|linkify|placeholder }}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Label</th>
+              <td>{{ object.label|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Color</th>
+              <td>
+                {% if object.color %}
+                  <span class="color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
+                {% else %}
+                  <span class="text-muted">&mdash;</span>
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Length</th>
+              <td>
+                {% if object.length %}
+                  {{ object.length|floatformat }} {{ object.get_length_unit_display }}
+                {% else %}
+                  <span class="text-muted">&mdash;</span>
+                {% endif %}
+              </td>
+            </tr>
+          </table>
         </div>
         </div>
+      </div>
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_left_page object %}
     </div>
     </div>
-    <div class="row">
-        <div class="col col-md-12">
-            {% plugin_full_width_page object %}
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Termination A</h5>
+        <div class="card-body">
+          {% include 'dcim/inc/cable_termination.html' with termination=object.termination_a %}
+        </div>
+      </div>
+      <div class="card">
+        <h5 class="card-header">Termination B</h5>
+        <div class="card-body">
+          {% include 'dcim/inc/cable_termination.html' with termination=object.termination_b %}
         </div>
         </div>
+      </div>
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
     </div>
     </div>
+  </div>
 {% endblock %}
 {% endblock %}

+ 4 - 4
netbox/templates/dcim/cable_connect.html

@@ -3,7 +3,7 @@
 {% load helpers %}
 {% load helpers %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
-{% block title %}Connect {{ form.instance.termination_a.device }} {{ form.instance.termination_a }} to {{ termination_b_type|bettertitle }}{% endblock %}
+{% block title %}Connect Cable to {{ termination_b_type|bettertitle }}{% endblock %}
 
 
 {% block tabs %}
 {% block tabs %}
 <ul class="nav nav-tabs px-3">
 <ul class="nav nav-tabs px-3">
@@ -15,7 +15,7 @@
 
 
 {% block content-wrapper %}
 {% block content-wrapper %}
   <div class="tab-content">
   <div class="tab-content">
-    {% with termination_a=form.instance.termination_a %}
+    {% with termination_a=form.instance.termination_a.0 %}
       {% render_errors form %}
       {% render_errors form %}
       <form method="post">
       <form method="post">
       {% csrf_token %}
       {% csrf_token %}
@@ -92,7 +92,7 @@
                               </div>
                               </div>
                           </div>
                           </div>
                       {% endif %}
                       {% endif %}
-                      {% render_field form.termination_a_id %}
+                      {% render_field form.termination_a_ids %}
                   </div>
                   </div>
               </div>
               </div>
           </div>
           </div>
@@ -148,7 +148,7 @@
                               <input class="form-control" value="{{ termination_b_type|capfirst }}" disabled />
                               <input class="form-control" value="{{ termination_b_type|capfirst }}" disabled />
                           </div>
                           </div>
                       </div>
                       </div>
-                      {% render_field form.termination_b_id %}
+                      {% render_field form.termination_b_ids %}
                   </div>
                   </div>
               </div>
               </div>
           </div>
           </div>

+ 42 - 38
netbox/templates/dcim/inc/cable_termination.html

@@ -1,42 +1,46 @@
 {% load helpers %}
 {% load helpers %}
 <table class="table table-hover panel-body attr-table">
 <table class="table table-hover panel-body attr-table">
-    {% if termination.device %}
-        {# Device component #}
-        <tr>
-            <td>Device</td>
-            <td>{{ termination.device|linkify }}</td>
-        </tr>
-        <tr>
-            <td>Site</td>
-            <td>{{ termination.device.site|linkify }}</td>
-        </tr>
-        {% if termination.device.rack %}
-            <tr>
-                <td>Rack</td>
-                <td>{{ termination.device.rack|linkify }}</td>
-            </tr>
-        {% endif %}
-        <tr>
-            <td>Type</td>
-            <td>{{ termination|meta:"verbose_name"|capfirst }}</td>
-        </tr>
-        <tr>
-            <td>Component</td>
-            <td>{{ termination|linkify }}</td>
-        </tr>
-    {% else %}
-        {# Circuit termination #}
-        <tr>
-            <td>Provider</td>
-            <td>{{ termination.circuit.provider|linkify }}</td>
-        </tr>
-        <tr>
-            <td>Circuit</td>
-            <td>{{ termination.circuit|linkify }}</td>
-        </tr>
-        <tr>
-            <td>Termination</td>
-            <td>{{ termination }}</td>
-        </tr>
+  {% if termination.0.device %}
+    {# Device component #}
+    <tr>
+      <td>Device</td>
+      <td>{{ termination.0.device|linkify }}</td>
+    </tr>
+    <tr>
+      <td>Site</td>
+      <td>{{ termination.0.device.site|linkify }}</td>
+    </tr>
+    {% if termination.0.device.rack %}
+      <tr>
+        <td>Rack</td>
+        <td>{{ termination.0.device.rack|linkify }}</td>
+      </tr>
     {% endif %}
     {% endif %}
+    <tr>
+      <td>Type</td>
+      <td>{{ termination.0|meta:"verbose_name"|capfirst }}</td>
+    </tr>
+    <tr>
+      <td>Component(s)</td>
+      <td>
+        {% for term in termination %}
+          {{ term|linkify }}{% if not forloop.last %},{% endif %}
+        {% endfor %}
+      </td>
+    </tr>
+  {% else %}
+    {# Circuit termination #}
+    <tr>
+      <td>Provider</td>
+      <td>{{ termination.0.circuit.provider|linkify }}</td>
+    </tr>
+    <tr>
+      <td>Circuit</td>
+      <td>
+        {% for term in termination %}
+          {{ term.circuit|linkify }} ({{ term }}){% if not forloop.last %},{% endif %}
+        {% endfor %}
+      </td>
+    </tr>
+  {% endif %}
 </table>
 </table>

+ 1 - 0
netbox/utilities/utils.py

@@ -132,6 +132,7 @@ def serialize_object(obj, extra=None):
     implicitly excluded.
     implicitly excluded.
     """
     """
     json_str = serialize('json', [obj])
     json_str = serialize('json', [obj])
+    print(json_str)
     data = json.loads(json_str)[0]['fields']
     data = json.loads(json_str)[0]['fields']
 
 
     # Exclude any MPTTModel fields
     # Exclude any MPTTModel fields