Add support for tracing split paths

This commit is contained in:
Jeremy Stretch 2020-11-16 15:49:07 -05:00
parent ba2ff0acb8
commit c559775135
6 changed files with 137 additions and 97 deletions

View File

@ -747,7 +747,7 @@ class CablePathSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = CablePath model = CablePath
fields = [ fields = [
'id', 'origin_type', 'origin', 'destination_type', 'destination', 'path', 'is_active', 'id', 'origin_type', 'origin', 'destination_type', 'destination', 'path', 'is_active', 'is_split',
] ]
@swagger_serializer_method(serializer_or_field=serializers.DictField) @swagger_serializer_method(serializer_or_field=serializers.DictField)

View File

@ -19,6 +19,7 @@ class Migration(migrations.Migration):
('destination_id', models.PositiveIntegerField(blank=True, null=True)), ('destination_id', models.PositiveIntegerField(blank=True, null=True)),
('path', dcim.fields.PathField(base_field=models.CharField(max_length=40), size=None)), ('path', dcim.fields.PathField(base_field=models.CharField(max_length=40), size=None)),
('is_active', models.BooleanField(default=False)), ('is_active', models.BooleanField(default=False)),
('is_split', models.BooleanField(default=False)),
('destination_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), ('destination_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
('origin_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), ('origin_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
], ],

View File

@ -11,7 +11,7 @@ from taggit.managers import TaggableManager
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.fields import PathField from dcim.fields import PathField
from dcim.utils import decompile_path_node from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
from utilities.fields import ColorField from utilities.fields import ColorField
@ -218,23 +218,14 @@ class Cable(ChangeLoggedModel, CustomFieldModel):
f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}" f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
) )
# Check that a RearPort with multiple positions isn't connected to an endpoint # Check that two connected RearPorts have the same number of positions
# or a RearPort with a different number of positions. if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
for term_a, term_b in [ if self.termination_a.positions != self.termination_b.positions:
(self.termination_a, self.termination_b), raise ValidationError(
(self.termination_b, self.termination_a) f"{self.termination_a} has {self.termination_a.positions} position(s) but "
]: f"{self.termination_b} has {self.termination_b.positions}. "
if isinstance(term_a, RearPort) and term_a.positions > 1: f"Both terminations must have the same number of positions."
if not isinstance(term_b, (FrontPort, RearPort, CircuitTermination)): )
raise ValidationError(
"Rear ports with multiple positions may only be connected to other pass-through ports"
)
if isinstance(term_b, RearPort) and term_b.positions > 1 and term_a.positions != term_b.positions:
raise ValidationError(
f"{term_a} of {term_a.device} has {term_a.positions} position(s) but "
f"{term_b} of {term_b.device} has {term_b.positions}. "
f"Both terminations must have the same number of positions."
)
# A termination point cannot be connected to itself # A termination point cannot be connected to itself
if self.termination_a == self.termination_b: if self.termination_a == self.termination_b:
@ -365,12 +356,16 @@ class CablePath(models.Model):
is_active = models.BooleanField( is_active = models.BooleanField(
default=False default=False
) )
is_split = models.BooleanField(
default=False
)
class Meta: class Meta:
unique_together = ('origin_type', 'origin_id') unique_together = ('origin_type', 'origin_id')
def __str__(self): def __str__(self):
return f"Path #{self.pk}: {self.origin} to {self.destination} ({len(self.path)} nodes)" status = ' (active)' if self.is_active else ' (split)' if self.is_split else ''
return f"Path #{self.pk}: {self.origin} to {self.destination} via {len(self.path)} nodes{status}"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -384,6 +379,68 @@ class CablePath(models.Model):
total_length = 1 + len(self.path) + (1 if self.destination else 0) total_length = 1 + len(self.path) + (1 if self.destination else 0)
return int(total_length / 3) return int(total_length / 3)
@classmethod
def from_origin(cls, origin):
"""
Create a new CablePath instance as traced from the given path origin.
"""
if origin is None or origin.cable is None:
return None
destination = None
path = []
position_stack = []
is_active = True
is_split = False
node = origin
while node.cable is not None:
if node.cable.status != CableStatusChoices.STATUS_CONNECTED:
is_active = False
# Follow the cable to its far-end termination
path.append(object_to_path_node(node.cable))
peer_termination = node.get_cable_peer()
# Follow a FrontPort to its corresponding RearPort
if isinstance(peer_termination, FrontPort):
path.append(object_to_path_node(peer_termination))
node = peer_termination.rear_port
if node.positions > 1:
position_stack.append(peer_termination.rear_port_position)
path.append(object_to_path_node(node))
# Follow a RearPort to its corresponding FrontPort
elif isinstance(peer_termination, RearPort):
path.append(object_to_path_node(peer_termination))
if peer_termination.positions == 1:
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=1)
path.append(object_to_path_node(node))
elif position_stack:
position = position_stack.pop()
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
path.append(object_to_path_node(node))
else:
# No position indicated: path has split, so we stop at the RearPort
is_split = True
break
# Anything else marks the end of the path
else:
destination = peer_termination
break
if destination is None:
is_active = False
return cls(
origin=origin,
destination=destination,
path=path,
is_active=is_active,
is_split=is_split
)
def get_path(self): def get_path(self):
""" """
Return the path as a list of prefetched objects. Return the path as a list of prefetched objects.
@ -422,3 +479,11 @@ class CablePath(models.Model):
decompile_path_node(self.path[i])[1] for i in range(0, len(self.path), 3) decompile_path_node(self.path[i])[1] for i in range(0, len(self.path), 3)
] ]
return Cable.objects.filter(id__in=cable_ids).aggregate(total=Sum('_abs_length'))['total'] return Cable.objects.filter(id__in=cable_ids).aggregate(total=Sum('_abs_length'))['total']
def get_split_nodes(self):
"""
:return:
"""
rearport = path_node_to_object(self.path[-1])
return FrontPort.objects.filter(rear_port=rearport)

View File

@ -7,17 +7,19 @@ from django.dispatch import receiver
from .choices import CableStatusChoices from .choices import CableStatusChoices
from .models import Cable, CablePath, Device, PathEndpoint, VirtualChassis from .models import Cable, CablePath, Device, PathEndpoint, VirtualChassis
from .utils import trace_path
def create_cablepath(node): def create_cablepath(node):
""" """
Create CablePaths for all paths originating from the specified node. Create CablePaths for all paths originating from the specified node.
""" """
path, destination, is_active = trace_path(node) cp = CablePath.from_origin(node)
if path: if cp:
cp = CablePath(origin=node, path=path, destination=destination, is_active=is_active) try:
cp.save() cp.save()
except Exception as e:
print(node, node.pk)
raise e
def rebuild_paths(obj): def rebuild_paths(obj):
@ -116,13 +118,14 @@ def nullify_connected_endpoints(instance, **kwargs):
# Delete and retrace any dependent cable paths # Delete and retrace any dependent cable paths
for cablepath in CablePath.objects.filter(path__contains=instance): for cablepath in CablePath.objects.filter(path__contains=instance):
path, destination, is_active = trace_path(cablepath.origin) cp = CablePath.from_origin(cablepath.origin)
if path: if cp:
CablePath.objects.filter(pk=cablepath.pk).update( CablePath.objects.filter(pk=cablepath.pk).update(
path=path, path=cp.path,
destination_type=ContentType.objects.get_for_model(destination) if destination else None, destination_type=ContentType.objects.get_for_model(cp.destination) if cp.destination else None,
destination_id=destination.pk if destination else None, destination_id=cp.destination.pk if cp.destination else None,
is_active=is_active is_active=cp.is_active,
is_split=cp.is_split
) )
else: else:
cablepath.delete() cablepath.delete()

View File

@ -1,7 +1,5 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from .choices import CableStatusChoices
def compile_path_node(ct_id, object_id): def compile_path_node(ct_id, object_id):
return f'{ct_id}:{object_id}' return f'{ct_id}:{object_id}'
@ -21,53 +19,10 @@ def object_to_path_node(obj):
return compile_path_node(ct.pk, obj.pk) return compile_path_node(ct.pk, obj.pk)
def trace_path(node): def path_node_to_object(repr):
from .models import FrontPort, RearPort """
Given the string representation of a path node, return the corresponding instance.
destination = None """
path = [] ct_id, object_id = decompile_path_node(repr)
position_stack = [] ct = ContentType.objects.get_for_id(ct_id)
is_active = True return ct.model_class().objects.get(pk=object_id)
if node is None or node.cable is None:
return [], None, False
while node.cable is not None:
if node.cable.status != CableStatusChoices.STATUS_CONNECTED:
is_active = False
# Follow the cable to its far-end termination
path.append(object_to_path_node(node.cable))
peer_termination = node.get_cable_peer()
# Follow a FrontPort to its corresponding RearPort
if isinstance(peer_termination, FrontPort):
path.append(object_to_path_node(peer_termination))
node = peer_termination.rear_port
if node.positions > 1:
position_stack.append(peer_termination.rear_port_position)
path.append(object_to_path_node(node))
# Follow a RearPort to its corresponding FrontPort
elif isinstance(peer_termination, RearPort):
path.append(object_to_path_node(peer_termination))
if peer_termination.positions == 1:
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=1)
path.append(object_to_path_node(node))
elif position_stack:
position = position_stack.pop()
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
path.append(object_to_path_node(node))
else:
# No position indicated: path has split, so we stop at the RearPort
break
# Anything else marks the end of the path
else:
destination = peer_termination
break
if destination is None:
is_active = False
return path, destination, is_active

View File

@ -22,9 +22,6 @@
{% elif near_end.circuit %} {% elif near_end.circuit %}
{% include 'dcim/trace/circuit.html' with circuit=near_end.circuit %} {% include 'dcim/trace/circuit.html' with circuit=near_end.circuit %}
{% include 'dcim/trace/termination.html' with termination=near_end %} {% include 'dcim/trace/termination.html' with termination=near_end %}
{% else %}
<h3 class="text-danger text-center">Split Paths!</h3>
{# TODO: Present the user with successive paths to choose from #}
{% endif %} {% endif %}
{# Cable #} {# Cable #}
@ -49,17 +46,36 @@
{% endif %} {% endif %}
{% if forloop.last %} {% if forloop.last %}
<div class="trace-end"> {% if path.is_split %}
<h3{% if far_end %} class="text-success"{% endif %}>Trace completed</h3> <div class="trace-end">
<h5>Total segments: {{ traced_path|length }}</h5> <h3 class="text-danger">Path split!</h3>
<h5>Total length: <p>Select a node below to continue:</p>
{% if total_length %} <ul class="text-left">
{{ total_length|floatformat:"-2" }} Meters {% for next_node in path.get_split_nodes %}
{% else %} {% if next_node.cable %}
<span class="text-muted">N/A</span> <li>
{% endif %} <a href="{% url 'dcim:frontport_trace' pk=next_node.pk %}">{{ next_node }}</a>
</h5> (Cable <a href="{{ next_node.cable.get_absolute_url }}">{{ next_node.cable }}</a>)
</div> </li>
{% else %}
<li class="text-muted">{{ next_node }}</li>
{% endif %}
{% endfor %}
</ul>
</div>
{% else %}
<div class="trace-end">
<h3{% if far_end %} class="text-success"{% endif %}>Trace completed</h3>
<h5>Total segments: {{ traced_path|length }}</h5>
<h5>Total length:
{% if total_length %}
{{ total_length|floatformat:"-2" }} Meters
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</h5>
</div>
{% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}