瀏覽代碼

Add support for tracing split paths

Jeremy Stretch 5 年之前
父節點
當前提交
c559775135

+ 1 - 1
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)

+ 1 - 0
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')),
             ],

+ 84 - 19
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)

+ 14 - 11
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()

+ 7 - 52
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)

+ 30 - 14
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 %}
-                            <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 %}