From c5597751355bf73744535fb58a425615d3612917 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 16 Nov 2020 15:49:07 -0500 Subject: [PATCH] Add support for tracing split paths --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/migrations/0121_cablepath.py | 1 + netbox/dcim/models/cables.py | 103 ++++++++++++++++++----- netbox/dcim/signals.py | 25 +++--- netbox/dcim/utils.py | 59 ++----------- netbox/templates/dcim/cable_trace.html | 44 +++++++--- 6 files changed, 137 insertions(+), 97 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 6008188bb..979b580a0 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -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) diff --git a/netbox/dcim/migrations/0121_cablepath.py b/netbox/dcim/migrations/0121_cablepath.py index 737e59b32..69411415d 100644 --- a/netbox/dcim/migrations/0121_cablepath.py +++ b/netbox/dcim/migrations/0121_cablepath.py @@ -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')), ], diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 031a62f31..189153dd0 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -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) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 184b7a955..4a5340748 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -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() diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 3d5a50b32..91c5c7c77 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -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) diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index da56e48c1..73003954d 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -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 %} -

Split Paths!

- {# TODO: Present the user with successive paths to choose from #} {% endif %} {# Cable #} @@ -49,17 +46,36 @@ {% endif %} {% if forloop.last %} -
- Trace completed -
Total segments: {{ traced_path|length }}
-
Total length: - {% if total_length %} - {{ total_length|floatformat:"-2" }} Meters - {% else %} - N/A - {% endif %} -
-
+ {% if path.is_split %} +
+

Path split!

+

Select a node below to continue:

+ +
+ {% else %} +
+ Trace completed +
Total segments: {{ traced_path|length }}
+
Total length: + {% if total_length %} + {{ total_length|floatformat:"-2" }} Meters + {% else %} + N/A + {% endif %} +
+
+ {% endif %} {% endif %} {% endfor %}