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

Introduce CableTermination model & migrate data

jeremystretch 3 лет назад
Родитель
Сommit
1f4ad444ae

+ 15 - 0
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
 #

+ 5 - 5
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():

+ 30 - 0
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'),
+        ),
+    ]

+ 51 - 0
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
+        ),
+    ]

+ 37 - 0
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',
+        ),
+    ]

+ 94 - 151
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

+ 52 - 41
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 + """' %}
+            <a href="{{ term.termination.get_absolute_url }}">{{ term.termination }}</a>
+          {% 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'
-    )
-    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_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
     )
-    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',
         )

+ 2 - 2
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