diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index 7fcab6ba3..9368fa6ee 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -977,8 +977,8 @@ class CableSerializer(NetBoxModelSerializer):
class Meta:
model = Cable
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',
]
@@ -986,14 +986,12 @@ class CableSerializer(NetBoxModelSerializer):
"""
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']}
- data = serializer(termination, context=context).data
+ data = serializer(termination, context=context, many=True).data
return data
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index e99ef333a..b3bda01cd 100644
--- a/netbox/dcim/api/views.py
+++ b/netbox/dcim/api/views.py
@@ -647,9 +647,7 @@ class InventoryItemRoleViewSet(NetBoxModelViewSet):
class CableViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
- queryset = Cable.objects.prefetch_related(
- 'termination_a', 'termination_b'
- )
+ queryset = Cable.objects.all()
serializer_class = serializers.CableSerializer
filterset_class = filtersets.CableFilterSet
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index d57d0a59b..563c5d4ce 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -1499,9 +1499,9 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet):
class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
termination_a_type = ContentTypeFilter()
- termination_a_id = MultiValueNumberFilter()
+ termination_a_ids = MultiValueNumberFilter()
termination_b_type = ContentTypeFilter()
- termination_b_id = MultiValueNumberFilter()
+ termination_b_ids = MultiValueNumberFilter()
type = django_filters.MultipleChoiceFilter(
choices=CableTypeChoices
)
@@ -1537,7 +1537,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
class Meta:
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):
if not value.strip():
@@ -1546,8 +1546,8 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
def filter_device(self, queryset, name, value):
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
diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py
index aae33e621..a56099b3e 100644
--- a/netbox/dcim/forms/connections.py
+++ b/netbox/dcim/forms/connections.py
@@ -2,7 +2,7 @@ from circuits.models import Circuit, CircuitTermination, Provider
from dcim.models import *
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
-from utilities.forms import DynamicModelChoiceField, StaticSelect
+from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
__all__ = (
'ConnectCableToCircuitTerminationForm',
@@ -22,7 +22,7 @@ class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm):
Base form for connecting a Cable to a Device component
"""
# Termination A
- termination_a_id = DynamicModelChoiceField(
+ termination_a_ids = DynamicModelMultipleChoiceField(
queryset=Interface.objects.all(),
label='Name',
disabled_indicator='_occupied'
@@ -87,8 +87,8 @@ class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm):
class Meta:
model = Cable
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',
]
widgets = {
@@ -97,17 +97,17 @@ class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm):
'length_unit': StaticSelect,
}
- def clean_termination_a_id(self):
+ def clean_termination_a_ids(self):
# 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 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):
- termination_b_id = DynamicModelChoiceField(
+ termination_b_ids = DynamicModelMultipleChoiceField(
queryset=ConsolePort.objects.all(),
label='Name',
disabled_indicator='_occupied',
@@ -118,7 +118,7 @@ class ConnectCableToConsolePortForm(ConnectCableToDeviceForm):
class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm):
- termination_b_id = DynamicModelChoiceField(
+ termination_b_ids = DynamicModelMultipleChoiceField(
queryset=ConsoleServerPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
@@ -129,7 +129,7 @@ class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm):
class ConnectCableToPowerPortForm(ConnectCableToDeviceForm):
- termination_b_id = DynamicModelChoiceField(
+ termination_b_ids = DynamicModelMultipleChoiceField(
queryset=PowerPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
@@ -140,7 +140,7 @@ class ConnectCableToPowerPortForm(ConnectCableToDeviceForm):
class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm):
- termination_b_id = DynamicModelChoiceField(
+ termination_b_ids = DynamicModelMultipleChoiceField(
queryset=PowerOutlet.objects.all(),
label='Name',
disabled_indicator='_occupied',
@@ -151,7 +151,7 @@ class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm):
class ConnectCableToInterfaceForm(ConnectCableToDeviceForm):
- termination_b_id = DynamicModelChoiceField(
+ termination_b_ids = DynamicModelMultipleChoiceField(
queryset=Interface.objects.all(),
label='Name',
disabled_indicator='_occupied',
@@ -163,7 +163,7 @@ class ConnectCableToInterfaceForm(ConnectCableToDeviceForm):
class ConnectCableToFrontPortForm(ConnectCableToDeviceForm):
- termination_b_id = DynamicModelChoiceField(
+ termination_b_ids = DynamicModelMultipleChoiceField(
queryset=FrontPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
@@ -174,7 +174,7 @@ class ConnectCableToFrontPortForm(ConnectCableToDeviceForm):
class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
- termination_b_id = DynamicModelChoiceField(
+ termination_b_ids = DynamicModelMultipleChoiceField(
queryset=RearPort.objects.all(),
label='Name',
disabled_indicator='_occupied',
@@ -186,7 +186,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm):
# Termination A
- termination_a_id = DynamicModelChoiceField(
+ termination_a_ids = DynamicModelMultipleChoiceField(
queryset=Interface.objects.all(),
label='Side',
disabled_indicator='_occupied'
@@ -231,7 +231,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm):
'site_id': '$termination_b_site',
}
)
- termination_b_id = DynamicModelChoiceField(
+ termination_b_ids = DynamicModelMultipleChoiceField(
queryset=CircuitTermination.objects.all(),
label='Side',
disabled_indicator='_occupied',
@@ -242,8 +242,8 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm):
class Meta(ConnectCableToDeviceForm.Meta):
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',
]
@@ -258,7 +258,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm):
class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm):
# Termination A
- termination_a_id = DynamicModelChoiceField(
+ termination_a_ids = DynamicModelMultipleChoiceField(
queryset=Interface.objects.all(),
label='Name',
disabled_indicator='_occupied'
@@ -307,7 +307,7 @@ class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm):
'location_id': '$termination_b_location',
}
)
- termination_b_id = DynamicModelChoiceField(
+ termination_b_ids = DynamicModelMultipleChoiceField(
queryset=PowerFeed.objects.all(),
label='Name',
disabled_indicator='_occupied',
@@ -318,8 +318,8 @@ class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm):
class Meta(ConnectCableToDeviceForm.Meta):
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',
]
diff --git a/netbox/dcim/migrations/0154_cable_add_termination_id_arrays.py b/netbox/dcim/migrations/0154_cable_add_termination_id_arrays.py
new file mode 100644
index 000000000..40e215a3d
--- /dev/null
+++ b/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),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0155_cable_copy_termination_ids.py b/netbox/dcim/migrations/0155_cable_copy_termination_ids.py
new file mode 100644
index 000000000..5e1effa51
--- /dev/null
+++ b/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
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0156_cable_delete_old_termination_ids.py b/netbox/dcim/migrations/0156_cable_delete_old_termination_ids.py
new file mode 100644
index 000000000..942a27df6
--- /dev/null
+++ b/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',
+ ),
+ ]
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index dcc564717..0edda0f5e 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -2,6 +2,7 @@ from collections import defaultdict
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
+from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import models
from django.db.models import Sum
@@ -38,10 +39,9 @@ class Cable(NetBoxModel):
on_delete=models.PROTECT,
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(
to=ContentType,
@@ -49,10 +49,9 @@ class Cable(NetBoxModel):
on_delete=models.PROTECT,
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(
max_length=50,
@@ -115,10 +114,6 @@ class Cable(NetBoxModel):
class Meta:
ordering = ['pk']
- unique_together = (
- ('termination_a_type', 'termination_a_id'),
- ('termination_b_type', 'termination_b_id'),
- )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -137,9 +132,9 @@ class Cable(NetBoxModel):
instance = super().from_db(db, field_names, values)
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_id = instance.termination_b_id
+ instance._orig_termination_b_ids = instance.termination_b_ids
return instance
@@ -150,6 +145,18 @@ class Cable(NetBoxModel):
def get_absolute_url(self):
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):
from circuits.models import CircuitTermination
@@ -158,9 +165,8 @@ class Cable(NetBoxModel):
# Validate that termination A exists
if not hasattr(self, 'termination_a_type'):
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({
'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
})
@@ -168,9 +174,8 @@ class Cable(NetBoxModel):
# Validate that termination B exists
if not hasattr(self, 'termination_b_type'):
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({
'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.'
if (
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({
'termination_a': err_msg
})
if (
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({
'termination_b': err_msg
@@ -197,18 +202,18 @@ class Cable(NetBoxModel):
type_b = self.termination_b_type.model
# 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
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}"
)
- # 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
- 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")
- # 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 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 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
- 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
if self.length is not None and not self.length_unit:
@@ -276,10 +279,10 @@ class Cable(NetBoxModel):
self._abs_length = None
# 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)
@@ -289,14 +292,6 @@ class Cable(NetBoxModel):
def get_status_color(self):
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):
"""
diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py
index 79e9c6687..fa75cf992 100644
--- a/netbox/dcim/signals.py
+++ b/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}")
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
if created:
- for termination in (instance.termination_a, instance.termination_b):
+ for termination in [*instance.termination_a, *instance.termination_b]:
if isinstance(termination, PathEndpoint):
create_cablepath(termination)
else:
@@ -116,14 +119,14 @@ def nullify_connected_endpoints(instance, **kwargs):
logger = logging.getLogger('netbox.dcim.cable')
# 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}")
- 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}")
- 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
for cablepath in CablePath.objects.filter(path__contains=instance):
diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py
index 4b062ad48..8abdb54d2 100644
--- a/netbox/dcim/tables/cables.py
+++ b/netbox/dcim/tables/cables.py
@@ -4,7 +4,7 @@ from django_tables2.utils import Accessor
from dcim.models import Cable
from netbox.tables import NetBoxTable, columns
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__ = (
'CableTable',
@@ -28,7 +28,8 @@ class CableTable(NetBoxTable):
linkify=True,
verbose_name='Rack A'
)
- termination_a = tables.Column(
+ termination_a = tables.TemplateColumn(
+ template_code=CABLE_TERMINATION,
accessor=Accessor('termination_a'),
orderable=False,
linkify=True,
@@ -46,7 +47,8 @@ class CableTable(NetBoxTable):
linkify=True,
verbose_name='Rack B'
)
- termination_b = tables.Column(
+ termination_b = tables.TemplateColumn(
+ template_code=CABLE_TERMINATION,
accessor=Accessor('termination_b'),
orderable=False,
linkify=True,
diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py
index 0c1e0ed9e..9911a938d 100644
--- a/netbox/dcim/tables/template_code.py
+++ b/netbox/dcim/tables/template_code.py
@@ -13,14 +13,20 @@ CABLE_LENGTH = """
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
"""
+CABLE_TERMINATION = """
+{{ value|join:", " }}
+"""
+
CABLE_TERMINATION_PARENT = """
-{% if value.device %}
- {{ value.device }}
-{% elif value.circuit %}
- {{ value.circuit }}
-{% elif value.power_panel %}
- {{ value.power_panel }}
-{% endif %}
+{% with value.0 as termination %}
+ {% if termination.device %}
+ {{ termination.device }}
+ {% elif termination.circuit %}
+ {{ termination.circuit }}
+ {% elif termination.power_panel %}
+ {{ termination.power_panel }}
+ {% endif %}
+{% endwith %}
"""
DEVICE_LINK = """
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 204862548..d9803f2be 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -2831,12 +2831,13 @@ class CableCreateView(generic.ObjectEditView):
def alter_object(self, obj, request, url_args, url_kwargs):
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('.')
self.termination_b_type = ContentType.objects.get(app_label=app_label, model=model)
# 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
return obj
@@ -2844,21 +2845,19 @@ class CableCreateView(generic.ObjectEditView):
def get(self, request, *args, **kwargs):
obj = self.get_object(**kwargs)
obj = self.alter_object(obj, request, args, kwargs)
+ initial_data = request.GET
- # 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)
-
+ # 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)
# 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, {
'obj': obj,
diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html
index f1cf986e6..712704f67 100644
--- a/netbox/templates/dcim/cable.html
+++ b/netbox/templates/dcim/cable.html
@@ -5,85 +5,79 @@
{% load plugins %}
{% block content %}
-
-
-
-
-
-
-
- Type |
- {{ object.get_type_display|placeholder }} |
-
-
- Status |
- {% badge object.get_status_display bg_color=object.get_status_color %} |
-
-
- Tenant |
-
- {% if object.tenant.group %}
- {{ object.tenant.group|linkify }} /
- {% endif %}
- {{ object.tenant|linkify|placeholder }}
- |
-
-
- Label |
- {{ object.label|placeholder }} |
-
-
- Color |
-
- {% if object.color %}
-
- {% else %}
- —
- {% endif %}
- |
-
-
- Length |
-
- {% if object.length %}
- {{ object.length|floatformat }} {{ object.get_length_unit_display }}
- {% else %}
- —
- {% endif %}
- |
-
-
-
-
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'inc/panels/tags.html' %}
- {% plugin_left_page object %}
-
-
-
-
-
- {% include 'dcim/inc/cable_termination.html' with termination=object.termination_a %}
-
-
-
-
-
- {% include 'dcim/inc/cable_termination.html' with termination=object.termination_b %}
-
-
- {% plugin_right_page object %}
+
+
+
+
+
+
+
+ Type |
+ {{ object.get_type_display|placeholder }} |
+
+
+ Status |
+ {% badge object.get_status_display bg_color=object.get_status_color %} |
+
+
+ Tenant |
+
+ {% if object.tenant.group %}
+ {{ object.tenant.group|linkify }} /
+ {% endif %}
+ {{ object.tenant|linkify|placeholder }}
+ |
+
+
+ Label |
+ {{ object.label|placeholder }} |
+
+
+ Color |
+
+ {% if object.color %}
+
+ {% else %}
+ —
+ {% endif %}
+ |
+
+
+ Length |
+
+ {% if object.length %}
+ {{ object.length|floatformat }} {{ object.get_length_unit_display }}
+ {% else %}
+ —
+ {% endif %}
+ |
+
+
+
+ {% include 'inc/panels/custom_fields.html' %}
+ {% include 'inc/panels/tags.html' %}
+ {% plugin_left_page object %}
-
-
- {% plugin_full_width_page object %}
+
+
+
+
+ {% include 'dcim/inc/cable_termination.html' with termination=object.termination_a %}
+
+
+
+
+ {% include 'dcim/inc/cable_termination.html' with termination=object.termination_b %}
+
+
+ {% plugin_right_page object %}
+
+
+
+ {% plugin_full_width_page object %}
+
+
{% endblock %}
diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html
index 4edb55bcc..bba9609b4 100644
--- a/netbox/templates/dcim/cable_connect.html
+++ b/netbox/templates/dcim/cable_connect.html
@@ -3,7 +3,7 @@
{% load 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 %}
@@ -15,7 +15,7 @@
{% block content-wrapper %}
- {% with termination_a=form.instance.termination_a %}
+ {% with termination_a=form.instance.termination_a.0 %}
{% render_errors form %}
{% endif %}
- {% render_field form.termination_a_id %}
+ {% render_field form.termination_a_ids %}
@@ -148,7 +148,7 @@
- {% render_field form.termination_b_id %}
+ {% render_field form.termination_b_ids %}
diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html
index 6d75aee85..4a0a25372 100644
--- a/netbox/templates/dcim/inc/cable_termination.html
+++ b/netbox/templates/dcim/inc/cable_termination.html
@@ -1,42 +1,46 @@
{% load helpers %}
- {% if termination.device %}
- {# Device component #}
-
- Device |
- {{ termination.device|linkify }} |
-
-
- Site |
- {{ termination.device.site|linkify }} |
-
- {% if termination.device.rack %}
-
- Rack |
- {{ termination.device.rack|linkify }} |
-
- {% endif %}
-
- Type |
- {{ termination|meta:"verbose_name"|capfirst }} |
-
-
- Component |
- {{ termination|linkify }} |
-
- {% else %}
- {# Circuit termination #}
-
- Provider |
- {{ termination.circuit.provider|linkify }} |
-
-
- Circuit |
- {{ termination.circuit|linkify }} |
-
-
- Termination |
- {{ termination }} |
-
+ {% if termination.0.device %}
+ {# Device component #}
+
+ Device |
+ {{ termination.0.device|linkify }} |
+
+
+ Site |
+ {{ termination.0.device.site|linkify }} |
+
+ {% if termination.0.device.rack %}
+
+ Rack |
+ {{ termination.0.device.rack|linkify }} |
+
{% endif %}
+
+ Type |
+ {{ termination.0|meta:"verbose_name"|capfirst }} |
+
+
+ Component(s) |
+
+ {% for term in termination %}
+ {{ term|linkify }}{% if not forloop.last %},{% endif %}
+ {% endfor %}
+ |
+
+ {% else %}
+ {# Circuit termination #}
+
+ Provider |
+ {{ termination.0.circuit.provider|linkify }} |
+
+
+ Circuit |
+
+ {% for term in termination %}
+ {{ term.circuit|linkify }} ({{ term }}){% if not forloop.last %},{% endif %}
+ {% endfor %}
+ |
+
+ {% endif %}
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py
index 7b37c0b70..28218fc74 100644
--- a/netbox/utilities/utils.py
+++ b/netbox/utilities/utils.py
@@ -132,6 +132,7 @@ def serialize_object(obj, extra=None):
implicitly excluded.
"""
json_str = serialize('json', [obj])
+ print(json_str)
data = json.loads(json_str)[0]['fields']
# Exclude any MPTTModel fields