diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index a89960457..2354ef6c8 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1204,6 +1204,21 @@ class CableLengthUnitChoices(ChoiceSet): ) +# +# CableTerminations +# + +class CableEndChoices(ChoiceSet): + + SIDE_A = 'A' + SIDE_B = 'B' + + CHOICES = ( + (SIDE_A, 'A'), + (SIDE_B, 'B') + ) + + # # PowerFeeds # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 19c3a414e..6fefe4ac0 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1498,10 +1498,10 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet): class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): - termination_a_type = ContentTypeFilter() - termination_a_id = MultiValueNumberFilter() - termination_b_type = ContentTypeFilter() - termination_b_id = MultiValueNumberFilter() + # termination_a_type = ContentTypeFilter() + # termination_a_id = MultiValueNumberFilter() + # termination_b_type = ContentTypeFilter() + # termination_b_id = 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'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/migrations/0154_cabletermination.py b/netbox/dcim/migrations/0154_cabletermination.py new file mode 100644 index 000000000..5a3a335fe --- /dev/null +++ b/netbox/dcim/migrations/0154_cabletermination.py @@ -0,0 +1,30 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('dcim', '0153_created_datetimefield'), + ] + + operations = [ + migrations.CreateModel( + name='CableTermination', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('cable_end', models.CharField(max_length=1)), + ('termination_id', models.PositiveBigIntegerField()), + ('cable', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='dcim.cable')), + ('termination_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'powerfeed', 'poweroutlet', 'powerport', 'rearport'))), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ], + options={ + 'ordering': ['pk'], + }, + ), + migrations.AddConstraint( + model_name='cabletermination', + constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='unique_termination'), + ), + ] diff --git a/netbox/dcim/migrations/0155_populate_cable_terminations.py b/netbox/dcim/migrations/0155_populate_cable_terminations.py new file mode 100644 index 000000000..c570f240b --- /dev/null +++ b/netbox/dcim/migrations/0155_populate_cable_terminations.py @@ -0,0 +1,51 @@ +from django.db import migrations + + +def populate_cable_terminations(apps, schema_editor): + """ + Replicate terminations from the Cable model into CableTermination instances. + """ + Cable = apps.get_model('dcim', 'Cable') + CableTermination = apps.get_model('dcim', 'CableTermination') + + # Retrieve the necessary data from Cable objects + cables = Cable.objects.values( + 'id', 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id' + ) + + # Queue CableTerminations to be created + cable_terminations = [] + for i, cable in enumerate(cables, start=1): + cable_terminations.append( + CableTermination( + cable_id=cable['id'], + cable_end='A', + termination_type_id=cable['termination_a_type'], + termination_id=cable['termination_a_id'] + ) + ) + cable_terminations.append( + CableTermination( + cable_id=cable['id'], + cable_end='B', + termination_type_id=cable['termination_b_type'], + termination_id=cable['termination_b_id'] + ) + ) + + # Bulk create the termination objects + CableTermination.objects.bulk_create(cable_terminations, batch_size=100) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0154_cabletermination'), + ] + + operations = [ + migrations.RunPython( + code=populate_cable_terminations, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/migrations/0156_cable_remove_terminations.py b/netbox/dcim/migrations/0156_cable_remove_terminations.py new file mode 100644 index 000000000..268224717 --- /dev/null +++ b/netbox/dcim/migrations/0156_cable_remove_terminations.py @@ -0,0 +1,37 @@ +# Generated by Django 4.0.4 on 2022-04-29 14:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0155_populate_cable_terminations'), + ] + + operations = [ + migrations.AlterModelOptions( + name='cable', + options={'ordering': ('pk',)}, + ), + migrations.AlterUniqueTogether( + name='cable', + unique_together=set(), + ), + migrations.RemoveField( + model_name='cable', + name='termination_a_id', + ), + migrations.RemoveField( + model_name='cable', + name='termination_a_type', + ), + migrations.RemoveField( + model_name='cable', + name='termination_b_id', + ), + migrations.RemoveField( + model_name='cable', + name='termination_b_type', + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 680ea78a8..18de8cb58 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -2,7 +2,6 @@ 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 @@ -22,6 +21,7 @@ from .device_components import FrontPort, RearPort __all__ = ( 'Cable', 'CablePath', + 'CableTermination', ) @@ -33,28 +33,6 @@ class Cable(NetBoxModel): """ A physical connection between two endpoints. """ - termination_a_type = models.ForeignKey( - to=ContentType, - limit_choices_to=CABLE_TERMINATION_MODELS, - 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_b_type = models.ForeignKey( - to=ContentType, - limit_choices_to=CABLE_TERMINATION_MODELS, - on_delete=models.PROTECT, - related_name='+' - ) - termination_b_id = models.PositiveBigIntegerField() - termination_b = GenericForeignKey( - ct_field='termination_b_type', - fk_field='termination_b_id' - ) type = models.CharField( max_length=50, choices=CableTypeChoices, @@ -115,11 +93,7 @@ class Cable(NetBoxModel): ) class Meta: - ordering = ['pk'] - unique_together = ( - ('termination_a_type', 'termination_a_id'), - ('termination_b_type', 'termination_b_id'), - ) + ordering = ('pk',) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -130,19 +104,19 @@ class Cable(NetBoxModel): # Cache the original status so we can check later if it's been changed self._orig_status = self.status - @classmethod - def from_db(cls, db, field_names, values): - """ - Cache the original A and B terminations of existing Cable instances for later reference inside clean(). - """ - instance = super().from_db(db, field_names, values) - - instance._orig_termination_a_type_id = instance.termination_a_type_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_ids = instance.termination_b_ids - - return instance + # @classmethod + # def from_db(cls, db, field_names, values): + # """ + # Cache the original A and B terminations of existing Cable instances for later reference inside clean(). + # """ + # instance = super().from_db(db, field_names, values) + # + # instance._orig_termination_a_type_id = instance.termination_a_type_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_ids = instance.termination_b_ids + # + # return instance def __str__(self): pk = self.pk or self._pk @@ -151,82 +125,9 @@ 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 - super().clean() - # Validate that termination A exists - if not hasattr(self, 'termination_a_type'): - raise ValidationError('Termination A type has not been specified') - 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) - }) - - # Validate that termination B exists - if not hasattr(self, 'termination_b_type'): - raise ValidationError('Termination B type has not been specified') - 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) - }) - - # If editing an existing Cable instance, check that neither termination has been modified. - if self.pk: - 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 - 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 - set(self.termination_b_ids) != set(self._orig_termination_b_ids) - ): - raise ValidationError({ - 'termination_b': err_msg - }) - - type_a = self.termination_a_type.model - type_b = self.termination_b_type.model - - # Validate interface types - 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): - raise ValidationError( - f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}" - ) - # 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): @@ -238,38 +139,6 @@ class Cable(NetBoxModel): # f"Both terminations must have the same number of positions (if greater than one)." # ) - # A termination point cannot be connected to itself - if set(self.termination_a).intersection(self.termination_b): - raise ValidationError(f"Cannot connect {self.termination_a_type} to itself") - - # 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 - 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: raise ValidationError("Must specify a unit when setting a cable length") @@ -284,11 +153,12 @@ class Cable(NetBoxModel): else: self._abs_length = None - # Store the parent Device for the A and B terminations (if applicable) to enable filtering - 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 + # TODO: Move to CableTermination + # # Store the parent Device for the A and B terminations (if applicable) to enable filtering + # 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) @@ -299,6 +169,79 @@ class Cable(NetBoxModel): return LinkStatusChoices.colors.get(self.status) +class CableTermination(models.Model): + """ + A mapping between side A or B of a Cable and a terminating object (e.g. an Interface or CircuitTermination). + """ + cable = models.ForeignKey( + to='dcim.Cable', + on_delete=models.CASCADE, + related_name='terminations' + ) + cable_end = models.CharField( + max_length=1, + choices=CableEndChoices, + verbose_name='End' + ) + termination_type = models.ForeignKey( + to=ContentType, + limit_choices_to=CABLE_TERMINATION_MODELS, + on_delete=models.PROTECT, + related_name='+' + ) + termination_id = models.PositiveBigIntegerField() + termination = GenericForeignKey( + ct_field='termination_type', + fk_field='termination_id' + ) + + class Meta: + ordering = ['pk'] + constraints = ( + models.UniqueConstraint( + fields=('termination_type', 'termination_id'), + name='unique_termination' + ), + ) + + def __str__(self): + return f'Cable {self.cable} to {self.termination}' + + def clean(self): + super().clean() + + # Validate interface type (if applicable) + if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES: + raise ValidationError({ + 'termination': f'Cables cannot be terminated to {self.termination.get_type_display()} interfaces' + }) + + # A CircuitTermination attached to a ProviderNetwork cannot have a Cable + if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None: + raise ValidationError({ + 'termination': "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 + # # Check that termination types are compatible + # if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a): + # raise ValidationError( + # f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}" + # ) + + class CablePath(models.Model): """ A CablePath instance represents the physical path from an origin to a destination, including all intermediate diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index 8abdb54d2..b17cf628e 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -4,55 +4,67 @@ 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, CABLE_TERMINATION_PARENT +from .template_code import CABLE_LENGTH __all__ = ( 'CableTable', ) +class CableTerminationColumn(tables.TemplateColumn): + + def __init__(self, cable_end, *args, **kwargs): + template_code = """ + {% for term in value.all %} + {% if term.cable_end == '""" + cable_end + """' %} + {{ term.termination }} + {% endif %} + {% endfor %} + """ + super().__init__(template_code=template_code, *args, **kwargs) + + def value(self, value): + return ', '.join(value.all()) + + # # Cables # class CableTable(NetBoxTable): - termination_a_parent = tables.TemplateColumn( - template_code=CABLE_TERMINATION_PARENT, - accessor=Accessor('termination_a'), - orderable=False, - verbose_name='Side A' + # termination_a_parent = tables.TemplateColumn( + # template_code=CABLE_TERMINATION_PARENT, + # accessor=Accessor('termination_a'), + # orderable=False, + # verbose_name='Side A' + # ) + # rack_a = tables.Column( + # accessor=Accessor('termination_a__device__rack'), + # orderable=False, + # linkify=True, + # verbose_name='Rack A' + # ) + # termination_b_parent = tables.TemplateColumn( + # template_code=CABLE_TERMINATION_PARENT, + # accessor=Accessor('termination_b'), + # orderable=False, + # verbose_name='Side B' + # ) + # rack_b = tables.Column( + # accessor=Accessor('termination_b__device__rack'), + # orderable=False, + # linkify=True, + # verbose_name='Rack B' + # ) + a_terminations = CableTerminationColumn( + cable_end='A', + accessor=Accessor('terminations'), + orderable=False ) - rack_a = tables.Column( - accessor=Accessor('termination_a__device__rack'), - orderable=False, - linkify=True, - verbose_name='Rack A' - ) - termination_a = tables.TemplateColumn( - template_code=CABLE_TERMINATION, - accessor=Accessor('termination_a'), - orderable=False, - linkify=True, - verbose_name='Termination A' - ) - termination_b_parent = tables.TemplateColumn( - template_code=CABLE_TERMINATION_PARENT, - accessor=Accessor('termination_b'), - orderable=False, - verbose_name='Side B' - ) - rack_b = tables.Column( - accessor=Accessor('termination_b__device__rack'), - orderable=False, - linkify=True, - verbose_name='Rack B' - ) - termination_b = tables.TemplateColumn( - template_code=CABLE_TERMINATION, - accessor=Accessor('termination_b'), - orderable=False, - linkify=True, - verbose_name='Termination B' + b_terminations = CableTerminationColumn( + cable_end='B', + accessor=Accessor('terminations'), + orderable=False ) status = columns.ChoiceFieldColumn() tenant = TenantColumn() @@ -68,10 +80,9 @@ class CableTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Cable fields = ( - 'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b', - 'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated', + 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type', 'tenant', 'color', 'length', + 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', - 'status', 'type', + 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type', ) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d9803f2be..43085702c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -12,7 +12,7 @@ from django.utils.html import escape from django.utils.safestring import mark_safe from django.views.generic import View -from circuits.models import Circuit, CircuitTermination +from circuits.models import Circuit from extras.views import ObjectConfigContextView from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable @@ -2738,7 +2738,7 @@ class DeviceBulkAddInventoryItemView(generic.BulkComponentCreateView): # class CableListView(generic.ObjectListView): - queryset = Cable.objects.all() + queryset = Cable.objects.prefetch_related('terminations__termination') filterset = filtersets.CableFilterSet filterset_form = forms.CableFilterForm table = tables.CableTable