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

View File

@ -1498,10 +1498,10 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet):
class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
termination_a_type = ContentTypeFilter() # termination_a_type = ContentTypeFilter()
termination_a_id = MultiValueNumberFilter() # termination_a_id = MultiValueNumberFilter()
termination_b_type = ContentTypeFilter() # termination_b_type = ContentTypeFilter()
termination_b_id = MultiValueNumberFilter() # termination_b_id = MultiValueNumberFilter()
type = django_filters.MultipleChoiceFilter( type = django_filters.MultipleChoiceFilter(
choices=CableTypeChoices choices=CableTypeChoices
) )
@ -1537,7 +1537,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
class Meta: class Meta:
model = Cable 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): def search(self, queryset, name, value):
if not value.strip(): 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.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import models from django.db import models
from django.db.models import Sum from django.db.models import Sum
@ -22,6 +21,7 @@ from .device_components import FrontPort, RearPort
__all__ = ( __all__ = (
'Cable', 'Cable',
'CablePath', 'CablePath',
'CableTermination',
) )
@ -33,28 +33,6 @@ class Cable(NetBoxModel):
""" """
A physical connection between two endpoints. 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( type = models.CharField(
max_length=50, max_length=50,
choices=CableTypeChoices, choices=CableTypeChoices,
@ -115,11 +93,7 @@ class Cable(NetBoxModel):
) )
class Meta: class Meta:
ordering = ['pk'] ordering = ('pk',)
unique_together = (
('termination_a_type', 'termination_a_id'),
('termination_b_type', 'termination_b_id'),
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*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 # Cache the original status so we can check later if it's been changed
self._orig_status = self.status self._orig_status = self.status
@classmethod # @classmethod
def from_db(cls, db, field_names, values): # def from_db(cls, db, field_names, values):
""" # """
Cache the original A and B terminations of existing Cable instances for later reference inside clean(). # 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 = super().from_db(db, field_names, values)
#
instance._orig_termination_a_type_id = instance.termination_a_type_id # instance._orig_termination_a_type_id = instance.termination_a_type_id
instance._orig_termination_a_ids = instance.termination_a_ids # instance._orig_termination_a_ids = instance.termination_a_ids
instance._orig_termination_b_type_id = instance.termination_b_type_id # instance._orig_termination_b_type_id = instance.termination_b_type_id
instance._orig_termination_b_ids = instance.termination_b_ids # instance._orig_termination_b_ids = instance.termination_b_ids
#
return instance # return instance
def __str__(self): def __str__(self):
pk = self.pk or self._pk pk = self.pk or self._pk
@ -151,82 +125,9 @@ class Cable(NetBoxModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:cable', args=[self.pk]) 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): def clean(self):
from circuits.models import CircuitTermination
super().clean() 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? # TODO: Is this validation still necessary?
# # Check that two connected RearPorts have the same number of positions (if both are >1) # # 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 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)." # 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 # Validate length and length_unit
if self.length is not None and not self.length_unit: if self.length is not None and not self.length_unit:
raise ValidationError("Must specify a unit when setting a cable length") raise ValidationError("Must specify a unit when setting a cable length")
@ -284,11 +153,12 @@ class Cable(NetBoxModel):
else: else:
self._abs_length = None self._abs_length = None
# Store the parent Device for the A and B terminations (if applicable) to enable filtering # TODO: Move to CableTermination
if hasattr(self.termination_a[0], 'device'): # # Store the parent Device for the A and B terminations (if applicable) to enable filtering
self._termination_a_device = self.termination_a[0].device # if hasattr(self.termination_a[0], 'device'):
if hasattr(self.termination_b[0], 'device'): # self._termination_a_device = self.termination_a[0].device
self._termination_b_device = self.termination_b[0].device # if hasattr(self.termination_b[0], 'device'):
# self._termination_b_device = self.termination_b[0].device
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -299,6 +169,79 @@ class Cable(NetBoxModel):
return LinkStatusChoices.colors.get(self.status) 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): class CablePath(models.Model):
""" """
A CablePath instance represents the physical path from an origin to a destination, including all intermediate 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 dcim.models import Cable
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from .template_code import CABLE_LENGTH, CABLE_TERMINATION, CABLE_TERMINATION_PARENT from .template_code import CABLE_LENGTH
__all__ = ( __all__ = (
'CableTable', '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 # Cables
# #
class CableTable(NetBoxTable): class CableTable(NetBoxTable):
termination_a_parent = tables.TemplateColumn( # termination_a_parent = tables.TemplateColumn(
template_code=CABLE_TERMINATION_PARENT, # template_code=CABLE_TERMINATION_PARENT,
accessor=Accessor('termination_a'), # accessor=Accessor('termination_a'),
orderable=False, # orderable=False,
verbose_name='Side A' # 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( b_terminations = CableTerminationColumn(
accessor=Accessor('termination_a__device__rack'), cable_end='B',
orderable=False, accessor=Accessor('terminations'),
linkify=True, orderable=False
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'
) )
status = columns.ChoiceFieldColumn() status = columns.ChoiceFieldColumn()
tenant = TenantColumn() tenant = TenantColumn()
@ -68,10 +80,9 @@ class CableTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Cable model = Cable
fields = ( fields = (
'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b', 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type', 'tenant', 'color', 'length',
'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',
'status', 'type',
) )

View File

@ -12,7 +12,7 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.views.generic import View from django.views.generic import View
from circuits.models import Circuit, CircuitTermination from circuits.models import Circuit
from extras.views import ObjectConfigContextView from extras.views import ObjectConfigContextView
from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup
from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
@ -2738,7 +2738,7 @@ class DeviceBulkAddInventoryItemView(generic.BulkComponentCreateView):
# #
class CableListView(generic.ObjectListView): class CableListView(generic.ObjectListView):
queryset = Cable.objects.all() queryset = Cable.objects.prefetch_related('terminations__termination')
filterset = filtersets.CableFilterSet filterset = filtersets.CableFilterSet
filterset_form = forms.CableFilterForm filterset_form = forms.CableFilterForm
table = tables.CableTable table = tables.CableTable