Introduce CableTermination model & migrate data

This commit is contained in:
jeremystretch 2022-04-29 13:13:01 -04:00
parent 907323d46f
commit 1f4ad444ae
8 changed files with 286 additions and 199 deletions

View File

@ -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
#

View File

@ -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():

View File

@ -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'),
),
]

View File

@ -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
),
]

View File

@ -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',
),
]

View File

@ -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

View File

@ -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'
# 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',
)

View File

@ -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