From 4bb9b6ee2639db683b70d6ddbee055497e0a3647 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Apr 2022 17:10:15 -0400 Subject: [PATCH] Extend Cable model to support multiple A/B terminations --- netbox/dcim/api/serializers.py | 16 +- netbox/dcim/api/views.py | 4 +- netbox/dcim/filtersets.py | 10 +- netbox/dcim/forms/connections.py | 46 ++--- .../0154_cable_add_termination_id_arrays.py | 24 +++ .../0155_cable_copy_termination_ids.py | 36 ++++ .../0156_cable_delete_old_termination_ids.py | 25 +++ netbox/dcim/models/cables.py | 161 +++++++++--------- netbox/dcim/signals.py | 39 +++-- netbox/dcim/tables/cables.py | 8 +- netbox/dcim/tables/template_code.py | 20 ++- netbox/dcim/views.py | 25 ++- netbox/templates/dcim/cable.html | 148 ++++++++-------- netbox/templates/dcim/cable_connect.html | 8 +- .../templates/dcim/inc/cable_termination.html | 80 ++++----- netbox/utilities/utils.py | 1 + 16 files changed, 368 insertions(+), 283 deletions(-) create mode 100644 netbox/dcim/migrations/0154_cable_add_termination_id_arrays.py create mode 100644 netbox/dcim/migrations/0155_cable_copy_termination_ids.py create mode 100644 netbox/dcim/migrations/0156_cable_delete_old_termination_ids.py 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 %} -
-
-
-
- Cable -
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
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 %} -
-
-
-
- Termination A -
-
- {% include 'dcim/inc/cable_termination.html' with termination=object.termination_a %} -
-
-
-
- Termination B -
-
- {% include 'dcim/inc/cable_termination.html' with termination=object.termination_b %} -
-
- {% plugin_right_page object %} +
+
+
+
Cable
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
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 %} +
+
+
Termination A
+
+ {% include 'dcim/inc/cable_termination.html' with termination=object.termination_a %}
+
+
+
Termination B
+
+ {% 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 %}
{% 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 #} - - - - - - - - - {% if termination.device.rack %} - - - - - {% endif %} - - - - - - - - - {% else %} - {# Circuit termination #} - - - - - - - - - - - - + {% if termination.0.device %} + {# Device component #} + + + + + + + + + {% if termination.0.device.rack %} + + + + {% endif %} + + + + + + + + + {% else %} + {# Circuit termination #} + + + + + + + + + {% endif %}
Device{{ termination.device|linkify }}
Site{{ termination.device.site|linkify }}
Rack{{ termination.device.rack|linkify }}
Type{{ termination|meta:"verbose_name"|capfirst }}
Component{{ termination|linkify }}
Provider{{ termination.circuit.provider|linkify }}
Circuit{{ termination.circuit|linkify }}
Termination{{ termination }}
Device{{ termination.0.device|linkify }}
Site{{ termination.0.device.site|linkify }}
Rack{{ termination.0.device.rack|linkify }}
Type{{ termination.0|meta:"verbose_name"|capfirst }}
Component(s) + {% for term in termination %} + {{ term|linkify }}{% if not forloop.last %},{% endif %} + {% endfor %} +
Provider{{ termination.0.circuit.provider|linkify }}
Circuit + {% for term in termination %} + {{ term.circuit|linkify }} ({{ term }}){% if not forloop.last %},{% endif %} + {% endfor %} +
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