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:
model = CablePath
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)

View File

@ -19,6 +19,7 @@ class Migration(migrations.Migration):
('destination_id', models.PositiveIntegerField(blank=True, null=True)),
('path', dcim.fields.PathField(base_field=models.CharField(max_length=40), size=None)),
('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')),
('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.constants import *
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.utils import extras_features
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}"
)
# Check that a RearPort with multiple positions isn't connected to an endpoint
# or a RearPort with a different number of positions.
for term_a, term_b in [
(self.termination_a, self.termination_b),
(self.termination_b, self.termination_a)
]:
if isinstance(term_a, RearPort) and term_a.positions > 1:
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."
)
# Check that two connected RearPorts have the same number of positions
if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
if self.termination_a.positions != self.termination_b.positions:
raise ValidationError(
f"{self.termination_a} has {self.termination_a.positions} position(s) but "
f"{self.termination_b} has {self.termination_b.positions}. "
f"Both terminations must have the same number of positions."
)
# A termination point cannot be connected to itself
if self.termination_a == self.termination_b:
@ -365,12 +356,16 @@ class CablePath(models.Model):
is_active = models.BooleanField(
default=False
)
is_split = models.BooleanField(
default=False
)
class Meta:
unique_together = ('origin_type', 'origin_id')
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):
super().save(*args, **kwargs)
@ -384,6 +379,68 @@ class CablePath(models.Model):
total_length = 1 + len(self.path) + (1 if self.destination else 0)
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):
"""
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)
]
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 .models import Cable, CablePath, Device, PathEndpoint, VirtualChassis
from .utils import trace_path
def create_cablepath(node):
"""
Create CablePaths for all paths originating from the specified node.
"""
path, destination, is_active = trace_path(node)
if path:
cp = CablePath(origin=node, path=path, destination=destination, is_active=is_active)
cp.save()
cp = CablePath.from_origin(node)
if cp:
try:
cp.save()
except Exception as e:
print(node, node.pk)
raise e
def rebuild_paths(obj):
@ -116,13 +118,14 @@ def nullify_connected_endpoints(instance, **kwargs):
# Delete and retrace any dependent cable paths
for cablepath in CablePath.objects.filter(path__contains=instance):
path, destination, is_active = trace_path(cablepath.origin)
if path:
cp = CablePath.from_origin(cablepath.origin)
if cp:
CablePath.objects.filter(pk=cablepath.pk).update(
path=path,
destination_type=ContentType.objects.get_for_model(destination) if destination else None,
destination_id=destination.pk if destination else None,
is_active=is_active
path=cp.path,
destination_type=ContentType.objects.get_for_model(cp.destination) if cp.destination else None,
destination_id=cp.destination.pk if cp.destination else None,
is_active=cp.is_active,
is_split=cp.is_split
)
else:
cablepath.delete()

View File

@ -1,7 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from .choices import CableStatusChoices
def compile_path_node(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)
def trace_path(node):
from .models import FrontPort, RearPort
destination = None
path = []
position_stack = []
is_active = True
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
def path_node_to_object(repr):
"""
Given the string representation of a path node, return the corresponding instance.
"""
ct_id, object_id = decompile_path_node(repr)
ct = ContentType.objects.get_for_id(ct_id)
return ct.model_class().objects.get(pk=object_id)

View File

@ -22,9 +22,6 @@
{% elif near_end.circuit %}
{% include 'dcim/trace/circuit.html' with circuit=near_end.circuit %}
{% 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 %}
{# Cable #}
@ -49,17 +46,36 @@
{% endif %}
{% if forloop.last %}
<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>
{% if path.is_split %}
<div class="trace-end">
<h3 class="text-danger">Path split!</h3>
<p>Select a node below to continue:</p>
<ul class="text-left">
{% for next_node in path.get_split_nodes %}
{% if next_node.cable %}
<li>
<a href="{% url 'dcim:frontport_trace' pk=next_node.pk %}">{{ next_node }}</a>
(Cable <a href="{{ next_node.cable.get_absolute_url }}">{{ next_node.cable }}</a>)
</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 %}
{% endfor %}