jeremystretch 3 سال پیش
والد
کامیت
9a7f3f8c1a

+ 2 - 1
netbox/circuits/graphql/types.py

@@ -1,4 +1,5 @@
 from circuits import filtersets, models
 from circuits import filtersets, models
+from dcim.graphql.mixins import CabledObjectMixin
 from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
 from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
 from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
 from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
 
 
@@ -11,7 +12,7 @@ __all__ = (
 )
 )
 
 
 
 
-class CircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
+class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
 
 
     class Meta:
     class Meta:
         model = models.CircuitTermination
         model = models.CircuitTermination

+ 15 - 20
netbox/dcim/api/serializers.py

@@ -52,16 +52,13 @@ class CabledObjectSerializer(serializers.ModelSerializer):
         """
         """
         Return the appropriate serializer for the link termination model.
         Return the appropriate serializer for the link termination model.
         """
         """
-        if not obj.cable:
+        if not obj.link_peers:
             return []
             return []
 
 
         # Return serialized peer termination objects
         # Return serialized peer termination objects
-        if obj.link_peers:
-            serializer = get_serializer_for_model(obj.link_peers[0], prefix='Nested')
-            context = {'request': self.context['request']}
-            return serializer(obj.link_peers, context=context, many=True).data
-
-        return []
+        serializer = get_serializer_for_model(obj.link_peers[0], prefix='Nested')
+        context = {'request': self.context['request']}
+        return serializer(obj.link_peers, context=context, many=True).data
 
 
     @swagger_serializer_method(serializer_or_field=serializers.BooleanField)
     @swagger_serializer_method(serializer_or_field=serializers.BooleanField)
     def get__occupied(self, obj):
     def get__occupied(self, obj):
@@ -77,8 +74,7 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
     connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True)
     connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True)
 
 
     def get_connected_endpoints_type(self, obj):
     def get_connected_endpoints_type(self, obj):
-        endpoints = obj.connected_endpoints
-        if endpoints:
+        if endpoints := obj.connected_endpoints:
             return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}'
             return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}'
 
 
     @swagger_serializer_method(serializer_or_field=serializers.ListField)
     @swagger_serializer_method(serializer_or_field=serializers.ListField)
@@ -86,8 +82,7 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
         """
         """
         Return the appropriate serializer for the type of connected object.
         Return the appropriate serializer for the type of connected object.
         """
         """
-        endpoints = obj.connected_endpoints
-        if endpoints:
+        if endpoints := obj.connected_endpoints:
             serializer = get_serializer_for_model(endpoints[0], prefix='Nested')
             serializer = get_serializer_for_model(endpoints[0], prefix='Nested')
             context = {'request': self.context['request']}
             context = {'request': self.context['request']}
             return serializer(endpoints, many=True, context=context).data
             return serializer(endpoints, many=True, context=context).data
@@ -1016,15 +1011,15 @@ class CableSerializer(NetBoxModelSerializer):
         ]
         ]
 
 
     def _get_terminations_type(self, obj, side):
     def _get_terminations_type(self, obj, side):
-        assert side.lower() in ('a', 'b')
-        terms = [t.termination for t in obj.terminations.all() if t.cable_end == side.upper()]
+        assert side in CableEndChoices.values()
+        terms = getattr(obj, f'get_{side.lower()}_terminations')()
         if terms:
         if terms:
             ct = ContentType.objects.get_for_model(terms[0])
             ct = ContentType.objects.get_for_model(terms[0])
             return f"{ct.app_label}.{ct.model}"
             return f"{ct.app_label}.{ct.model}"
 
 
     def _get_terminations(self, obj, side):
     def _get_terminations(self, obj, side):
-        assert side.lower() in ('a', 'b')
-        terms = [t.termination for t in obj.terminations.all() if t.cable_end == side.upper()]
+        assert side in CableEndChoices.values()
+        terms = getattr(obj, f'get_{side.lower()}_terminations')()
         if not terms:
         if not terms:
             return []
             return []
 
 
@@ -1037,19 +1032,19 @@ class CableSerializer(NetBoxModelSerializer):
 
 
     @swagger_serializer_method(serializer_or_field=serializers.CharField)
     @swagger_serializer_method(serializer_or_field=serializers.CharField)
     def get_a_terminations_type(self, obj):
     def get_a_terminations_type(self, obj):
-        return self._get_terminations_type(obj, 'a')
+        return self._get_terminations_type(obj, CableEndChoices.SIDE_A)
 
 
     @swagger_serializer_method(serializer_or_field=serializers.CharField)
     @swagger_serializer_method(serializer_or_field=serializers.CharField)
     def get_b_terminations_type(self, obj):
     def get_b_terminations_type(self, obj):
-        return self._get_terminations_type(obj, 'b')
+        return self._get_terminations_type(obj, CableEndChoices.SIDE_B)
 
 
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     def get_a_terminations(self, obj):
     def get_a_terminations(self, obj):
-        return self._get_terminations(obj, 'a')
+        return self._get_terminations(obj, CableEndChoices.SIDE_A)
 
 
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     def get_b_terminations(self, obj):
     def get_b_terminations(self, obj):
-        return self._get_terminations(obj, 'b')
+        return self._get_terminations(obj, CableEndChoices.SIDE_B)
 
 
 
 
 class TracedCableSerializer(serializers.ModelSerializer):
 class TracedCableSerializer(serializers.ModelSerializer):
@@ -1066,7 +1061,7 @@ class TracedCableSerializer(serializers.ModelSerializer):
 
 
 
 
 class CableTerminationSerializer(NetBoxModelSerializer):
 class CableTerminationSerializer(NetBoxModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cabletermination-detail')
     termination_type = ContentTypeField(
     termination_type = ContentTypeField(
         queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
         queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
     )
     )

+ 13 - 19
netbox/dcim/api/views.py

@@ -15,6 +15,7 @@ from circuits.models import Circuit
 from dcim import filtersets
 from dcim import filtersets
 from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from dcim.models import *
 from dcim.models import *
+from dcim.svg import CableTraceSVG
 from extras.api.views import ConfigContextQuerySetMixin
 from extras.api.views import ConfigContextQuerySetMixin
 from ipam.models import Prefix, VLAN
 from ipam.models import Prefix, VLAN
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@@ -52,37 +53,30 @@ class PathEndpointMixin(object):
         # Initialize the path array
         # Initialize the path array
         path = []
         path = []
 
 
+        # Render SVG image if requested
         if request.GET.get('render', None) == 'svg':
         if request.GET.get('render', None) == 'svg':
-            # Render SVG
             try:
             try:
                 width = int(request.GET.get('width', CABLE_TRACE_SVG_DEFAULT_WIDTH))
                 width = int(request.GET.get('width', CABLE_TRACE_SVG_DEFAULT_WIDTH))
             except (ValueError, TypeError):
             except (ValueError, TypeError):
                 width = CABLE_TRACE_SVG_DEFAULT_WIDTH
                 width = CABLE_TRACE_SVG_DEFAULT_WIDTH
-            drawing = obj.get_trace_svg(
-                base_url=request.build_absolute_uri('/'),
-                width=width
-            )
-            return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
+            drawing = CableTraceSVG(self, base_url=request.build_absolute_uri('/'), width=width)
+            return HttpResponse(drawing.render().tostring(), content_type='image/svg+xml')
 
 
+        # Serialize path objects, iterating over each three-tuple in the path
         for near_end, cable, far_end in obj.trace():
         for near_end, cable, far_end in obj.trace():
-            if near_end is None:
-                # Split paths
+            if near_end is not None:
+                serializer_a = get_serializer_for_model(near_end[0], prefix='Nested')
+                near_end = serializer_a(near_end, many=True, context={'request': request}).data
+            else:
+                # Path is split; stop here
                 break
                 break
-
-            # Serialize each object
-            serializer_a = get_serializer_for_model(near_end[0], prefix='Nested')
-            x = serializer_a(near_end, many=True, context={'request': request}).data
             if cable is not None:
             if cable is not None:
-                y = serializers.TracedCableSerializer(cable[0], context={'request': request}).data
-            else:
-                y = None
+                cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data
             if far_end is not None:
             if far_end is not None:
                 serializer_b = get_serializer_for_model(far_end[0], prefix='Nested')
                 serializer_b = get_serializer_for_model(far_end[0], prefix='Nested')
-                z = serializer_b(far_end, many=True, context={'request': request}).data
-            else:
-                z = None
+                far_end = serializer_b(far_end, many=True, context={'request': request}).data
 
 
-            path.append((x, y, z))
+            path.append((near_end, cable, far_end))
 
 
         return Response(path)
         return Response(path)
 
 

+ 1 - 1
netbox/dcim/choices.py

@@ -1294,7 +1294,7 @@ class CableEndChoices(ChoiceSet):
     CHOICES = (
     CHOICES = (
         (SIDE_A, 'A'),
         (SIDE_A, 'A'),
         (SIDE_B, 'B'),
         (SIDE_B, 'B'),
-        ('', ''),
+        # ('', ''),
     )
     )
 
 
 
 

+ 5 - 0
netbox/dcim/graphql/mixins.py

@@ -0,0 +1,5 @@
+class CabledObjectMixin:
+
+    def resolve_cable_end(self, info):
+        # Handle empty values
+        return self.cable_end or None

+ 9 - 8
netbox/dcim/graphql/types.py

@@ -7,6 +7,7 @@ from extras.graphql.mixins import (
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
+from .mixins import CabledObjectMixin
 
 
 __all__ = (
 __all__ = (
     'CableType',
     'CableType',
@@ -107,7 +108,7 @@ class CableTerminationType(NetBoxObjectType):
         filterset_class = filtersets.CableTerminationFilterSet
         filterset_class = filtersets.CableTerminationFilterSet
 
 
 
 
-class ConsolePortType(ComponentObjectType):
+class ConsolePortType(ComponentObjectType, CabledObjectMixin):
 
 
     class Meta:
     class Meta:
         model = models.ConsolePort
         model = models.ConsolePort
@@ -129,7 +130,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType):
         return self.type or None
         return self.type or None
 
 
 
 
-class ConsoleServerPortType(ComponentObjectType):
+class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin):
 
 
     class Meta:
     class Meta:
         model = models.ConsoleServerPort
         model = models.ConsoleServerPort
@@ -211,7 +212,7 @@ class DeviceTypeType(NetBoxObjectType):
         return self.airflow or None
         return self.airflow or None
 
 
 
 
-class FrontPortType(ComponentObjectType):
+class FrontPortType(ComponentObjectType, CabledObjectMixin):
 
 
     class Meta:
     class Meta:
         model = models.FrontPort
         model = models.FrontPort
@@ -227,7 +228,7 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
         filterset_class = filtersets.FrontPortTemplateFilterSet
         filterset_class = filtersets.FrontPortTemplateFilterSet
 
 
 
 
-class InterfaceType(IPAddressesMixin, ComponentObjectType):
+class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin):
 
 
     class Meta:
     class Meta:
         model = models.Interface
         model = models.Interface
@@ -330,7 +331,7 @@ class PlatformType(OrganizationalObjectType):
         filterset_class = filtersets.PlatformFilterSet
         filterset_class = filtersets.PlatformFilterSet
 
 
 
 
-class PowerFeedType(NetBoxObjectType):
+class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
 
 
     class Meta:
     class Meta:
         model = models.PowerFeed
         model = models.PowerFeed
@@ -338,7 +339,7 @@ class PowerFeedType(NetBoxObjectType):
         filterset_class = filtersets.PowerFeedFilterSet
         filterset_class = filtersets.PowerFeedFilterSet
 
 
 
 
-class PowerOutletType(ComponentObjectType):
+class PowerOutletType(ComponentObjectType, CabledObjectMixin):
 
 
     class Meta:
     class Meta:
         model = models.PowerOutlet
         model = models.PowerOutlet
@@ -374,7 +375,7 @@ class PowerPanelType(NetBoxObjectType):
         filterset_class = filtersets.PowerPanelFilterSet
         filterset_class = filtersets.PowerPanelFilterSet
 
 
 
 
-class PowerPortType(ComponentObjectType):
+class PowerPortType(ComponentObjectType, CabledObjectMixin):
 
 
     class Meta:
     class Meta:
         model = models.PowerPort
         model = models.PowerPort
@@ -426,7 +427,7 @@ class RackRoleType(OrganizationalObjectType):
         filterset_class = filtersets.RackRoleFilterSet
         filterset_class = filtersets.RackRoleFilterSet
 
 
 
 
-class RearPortType(ComponentObjectType):
+class RearPortType(ComponentObjectType, CabledObjectMixin):
 
 
     class Meta:
     class Meta:
         model = models.RearPort
         model = models.RearPort

+ 1 - 1
netbox/dcim/migrations/0157_new_cabling_models.py

@@ -27,7 +27,7 @@ class Migration(migrations.Migration):
         ),
         ),
         migrations.AddConstraint(
         migrations.AddConstraint(
             model_name='cabletermination',
             model_name='cabletermination',
-            constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='unique_termination'),
+            constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='dcim_cable_termination_unique_termination'),
         ),
         ),
 
 
         # Update CablePath model
         # Update CablePath model

+ 57 - 46
netbox/dcim/models/cables.py

@@ -1,3 +1,4 @@
+import itertools
 from collections import defaultdict
 from collections import defaultdict
 
 
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
@@ -11,15 +12,14 @@ from django.urls import reverse
 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, flatten_path, object_to_path_node, path_node_to_object
+from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
 from netbox.models import NetBoxModel
 from netbox.models import NetBoxModel
 from utilities.fields import ColorField
 from utilities.fields import ColorField
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from utilities.utils import to_meters
 from utilities.utils import to_meters
 from wireless.models import WirelessLink
 from wireless.models import WirelessLink
-from .devices import Device
 from .device_components import FrontPort, RearPort
 from .device_components import FrontPort, RearPort
-
+from .devices import Device
 
 
 __all__ = (
 __all__ = (
     'Cable',
     'Cable',
@@ -110,7 +110,8 @@ 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
 
 
-        # Assign associated CableTerminations (if any)
+        # Assign any *new* CableTerminations for the instance. These will replace any existing
+        # terminations on save().
         if a_terminations is not None:
         if a_terminations is not None:
             self.a_terminations = a_terminations
             self.a_terminations = a_terminations
         if b_terminations is not None:
         if b_terminations is not None:
@@ -133,28 +134,25 @@ class Cable(NetBoxModel):
             self.length_unit = ''
             self.length_unit = ''
 
 
         a_terminations = [
         a_terminations = [
-            CableTermination(cable=self, cable_end='A', termination=t) for t in getattr(self, 'a_terminations', [])
+            CableTermination(cable=self, cable_end='A', termination=t)
+            for t in getattr(self, 'a_terminations', [])
         ]
         ]
         b_terminations = [
         b_terminations = [
-            CableTermination(cable=self, cable_end='B', termination=t) for t in getattr(self, 'b_terminations', [])
+            CableTermination(cable=self, cable_end='B', termination=t)
+            for t in getattr(self, 'b_terminations', [])
         ]
         ]
 
 
         # Check that all termination objects for either end are of the same type
         # Check that all termination objects for either end are of the same type
         for terms in (a_terminations, b_terminations):
         for terms in (a_terminations, b_terminations):
-            if terms and len(terms) > 1:
-                if not all(t.termination_type == terms[0].termination_type for t in terms[1:]):
-                    raise ValidationError(
-                        "Cannot connect different termination types to same end of cable."
-                    )
+            if len(terms) > 1 and not all(t.termination_type == terms[0].termination_type for t in terms[1:]):
+                raise ValidationError("Cannot connect different termination types to same end of cable.")
 
 
         # Check that termination types are compatible
         # Check that termination types are compatible
         if a_terminations and b_terminations:
         if a_terminations and b_terminations:
             a_type = a_terminations[0].termination_type.model
             a_type = a_terminations[0].termination_type.model
             b_type = b_terminations[0].termination_type.model
             b_type = b_terminations[0].termination_type.model
             if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
             if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
-                raise ValidationError(
-                    f"Incompatible termination types: {a_type} and {b_type}"
-                )
+                raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
 
 
         # Run clean() on any new CableTerminations
         # Run clean() on any new CableTerminations
         for cabletermination in [*a_terminations, *b_terminations]:
         for cabletermination in [*a_terminations, *b_terminations]:
@@ -169,6 +167,7 @@ class Cable(NetBoxModel):
         else:
         else:
             self._abs_length = None
             self._abs_length = None
 
 
+        # TODO: Need to come with a proper solution for filtering by termination parent
         # Store the parent Device for the A and B terminations (if applicable) to enable filtering
         # Store the parent Device for the A and B terminations (if applicable) to enable filtering
         if hasattr(self, 'a_terminations'):
         if hasattr(self, 'a_terminations'):
             self._termination_a_device = getattr(self.a_terminations[0], 'device', None)
             self._termination_a_device = getattr(self.a_terminations[0], 'device', None)
@@ -210,13 +209,15 @@ class Cable(NetBoxModel):
         return LinkStatusChoices.colors.get(self.status)
         return LinkStatusChoices.colors.get(self.status)
 
 
     def get_a_terminations(self):
     def get_a_terminations(self):
+        # Query self.terminations.all() to leverage cached results
         return [
         return [
-            term.termination for term in CableTermination.objects.filter(cable=self, cable_end='A')
+            ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_A
         ]
         ]
 
 
     def get_b_terminations(self):
     def get_b_terminations(self):
+        # Query self.terminations.all() to leverage cached results
         return [
         return [
-            term.termination for term in CableTermination.objects.filter(cable=self, cable_end='B')
+            ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_B
         ]
         ]
 
 
 
 
@@ -253,7 +254,7 @@ class CableTermination(models.Model):
         constraints = (
         constraints = (
             models.UniqueConstraint(
             models.UniqueConstraint(
                 fields=('termination_type', 'termination_id'),
                 fields=('termination_type', 'termination_id'),
-                name='unique_termination'
+                name='dcim_cable_termination_unique_termination'
             ),
             ),
         )
         )
 
 
@@ -289,34 +290,48 @@ class CableTermination(models.Model):
 
 
         # Delete the cable association on the terminating object
         # Delete the cable association on the terminating object
         termination_model = self.termination._meta.model
         termination_model = self.termination._meta.model
-        termination_model.objects.filter(pk=self.termination_id).update(cable=None, cable_end='', _path=None)
+        termination_model.objects.filter(pk=self.termination_id).update(
+            cable=None,
+            cable_end=''
+        )
 
 
         super().delete(*args, **kwargs)
         super().delete(*args, **kwargs)
 
 
 
 
 class CablePath(models.Model):
 class CablePath(models.Model):
     """
     """
-    A CablePath instance represents the physical path from an origin to a destination, including all intermediate
-    elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do
-    not terminate on a PathEndpoint).
+    A CablePath instance represents the physical path from a set of origin nodes to a set of destination nodes,
+    including all intermediate elements.
 
 
-    `path` contains a list of nodes within the path, each represented by a tuple of (type, ID). The first element in the
-    path must be a Cable instance, followed by a pair of pass-through ports. For example, consider the following
+    `path` contains the ordered set of nodes, arranged in lists of (type, ID) tuples. (Each cable in the path can
+    terminate to one or more objects.)  For example, consider the following
     topology:
     topology:
 
 
-                     1                              2                              3
-        Interface A --- Front Port A | Rear Port A --- Rear Port B | Front Port B --- Interface B
+                     A                              B                              C
+        Interface 1 --- Front Port 1 | Rear Port 1 --- Rear Port 2 | Front Port 3 --- Interface 2
+                        Front Port 2                                 Front Port 4
 
 
     This path would be expressed as:
     This path would be expressed as:
 
 
     CablePath(
     CablePath(
-        origin = Interface A
-        destination = Interface B
-        path = [Cable 1, Front Port A, Rear Port A, Cable 2, Rear Port B, Front Port B, Cable 3]
+        path = [
+            [Interface 1],
+            [Cable A],
+            [Front Port 1, Front Port 2],
+            [Rear Port 1],
+            [Cable B],
+            [Rear Port 2],
+            [Front Port 3, Front Port 4],
+            [Cable C],
+            [Interface 2],
+        ]
     )
     )
 
 
-    `is_active` is set to True only if 1) `destination` is not null, and 2) every Cable within the path has a status of
-    "connected".
+    `is_active` is set to True only if every Cable within the path has a status of "connected". `is_complete` is True
+    if the instance represents a complete end-to-end path from origin(s) to destination(s). `is_split` is True if the
+    path diverges across multiple cables.
+
+    `_nodes` retains a flattened list of all nodes within the path to enable simple filtering.
     """
     """
     path = models.JSONField(
     path = models.JSONField(
         default=list
         default=list
@@ -332,36 +347,32 @@ class CablePath(models.Model):
     )
     )
     _nodes = PathField()
     _nodes = PathField()
 
 
-    class Meta:
-        pass
-
     def __str__(self):
     def __str__(self):
-        status = ' (active)' if self.is_active else ' (split)' if self.is_split else ''
-        return f"Path #{self.pk}: {len(self.path)} nodes{status}"
+        return f"Path #{self.pk}: {len(self.path)} hops"
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
 
 
         # Save the flattened nodes list
         # Save the flattened nodes list
-        self._nodes = flatten_path(self.path)
+        self._nodes = list(itertools.chain(*self.path))
 
 
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
         # Record a direct reference to this CablePath on its originating object(s)
         # Record a direct reference to this CablePath on its originating object(s)
-        origin_model = self.origins[0]._meta.model
-        origin_ids = [o.id for o in self.origins]
+        origin_model = self.origin_type.model_class()
+        origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
         origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk)
         origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk)
 
 
     @property
     @property
     def origin_type(self):
     def origin_type(self):
-        ct_id, _ = decompile_path_node(self.path[0][0])
-        return ContentType.objects.get_for_id(ct_id)
+        if self.path:
+            ct_id, _ = decompile_path_node(self.path[0][0])
+            return ContentType.objects.get_for_id(ct_id)
 
 
     @property
     @property
     def destination_type(self):
     def destination_type(self):
-        if not self.is_complete:
-            return None
-        ct_id, _ = decompile_path_node(self.path[-1][0])
-        return ContentType.objects.get_for_id(ct_id)
+        if self.is_complete:
+            ct_id, _ = decompile_path_node(self.path[-1][0])
+            return ContentType.objects.get_for_id(ct_id)
 
 
     @property
     @property
     def path_objects(self):
     def path_objects(self):
@@ -375,7 +386,7 @@ class CablePath(models.Model):
     @property
     @property
     def origins(self):
     def origins(self):
         """
         """
-        Return the list of originating objects (from cache, if available).
+        Return the list of originating objects.
         """
         """
         if hasattr(self, '_path_objects'):
         if hasattr(self, '_path_objects'):
             return self.path_objects[0]
             return self.path_objects[0]
@@ -386,7 +397,7 @@ class CablePath(models.Model):
     @property
     @property
     def destinations(self):
     def destinations(self):
         """
         """
-        Return the list of destination objects (from cache, if available), if the path is complete.
+        Return the list of destination objects, if the path is complete.
         """
         """
         if not self.is_complete:
         if not self.is_complete:
             return []
             return []

+ 18 - 14
netbox/dcim/models/device_components.py

@@ -10,7 +10,6 @@ from mptt.models import MPTTModel, TreeForeignKey
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import MACAddressField, WWNField
 from dcim.fields import MACAddressField, WWNField
-from dcim.svg import CableTraceSVG
 from netbox.models import OrganizationalModel, NetBoxModel
 from netbox.models import OrganizationalModel, NetBoxModel
 from utilities.choices import ColorChoices
 from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
@@ -105,7 +104,8 @@ class ModularComponentModel(ComponentModel):
 
 
 class CabledObjectModel(models.Model):
 class CabledObjectModel(models.Model):
     """
     """
-    An abstract model inherited by all models to which a Cable can terminate.
+    An abstract model inherited by all models to which a Cable can terminate. Provides the `cable` and `cable_end`
+    fields for caching cable associations, as well as `mark_connected` to designate "fake" connections.
     """
     """
     cable = models.ForeignKey(
     cable = models.ForeignKey(
         to='dcim.Cable',
         to='dcim.Cable',
@@ -134,8 +134,11 @@ class CabledObjectModel(models.Model):
             raise ValidationError({
             raise ValidationError({
                 "cable_end": "Must specify cable end (A or B) when attaching a cable."
                 "cable_end": "Must specify cable end (A or B) when attaching a cable."
             })
             })
-
-        if self.mark_connected and self.cable_id:
+        if self.cable_end and not self.cable:
+            raise ValidationError({
+                "cable_end": "Cable end must not be set without a cable."
+            })
+        if self.mark_connected and self.cable:
             raise ValidationError({
             raise ValidationError({
                 "mark_connected": "Cannot mark as connected with a cable attached."
                 "mark_connected": "Cannot mark as connected with a cable attached."
             })
             })
@@ -167,12 +170,13 @@ class CabledObjectModel(models.Model):
         """
         """
         Generic wrapper for a Cable, WirelessLink, or some other relation to a connected termination.
         Generic wrapper for a Cable, WirelessLink, or some other relation to a connected termination.
         """
         """
+        # TODO: Support WirelessLinks
         return self.cable
         return self.cable
 
 
 
 
 class PathEndpoint(models.Model):
 class PathEndpoint(models.Model):
     """
     """
-    An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically,
+    An abstract model inherited by any CabledObjectModel subclass which represents the end of a CablePath; specifically,
     these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed.
     these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed.
 
 
     `_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in
     `_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in
@@ -215,14 +219,6 @@ class PathEndpoint(models.Model):
         # Return the path as a list of three-tuples (A termination(s), cable(s), B termination(s))
         # Return the path as a list of three-tuples (A termination(s), cable(s), B termination(s))
         return list(zip(*[iter(path)] * 3))
         return list(zip(*[iter(path)] * 3))
 
 
-    def get_trace_svg(self, base_url=None, width=CABLE_TRACE_SVG_DEFAULT_WIDTH):
-        trace = CableTraceSVG(self, base_url=base_url, width=width)
-        return trace.render()
-
-    @property
-    def path(self):
-        return self._path
-
     @property
     @property
     def connected_endpoints(self):
     def connected_endpoints(self):
         """
         """
@@ -338,7 +334,15 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
 
 
     def get_downstream_powerports(self, leg=None):
     def get_downstream_powerports(self, leg=None):
         """
         """
-        Return a queryset of all PowerPorts connected via cable to a child PowerOutlet.
+        Return a queryset of all PowerPorts connected via cable to a child PowerOutlet. For example, in the topology
+        below, PP1.get_downstream_powerports() would return PP2-4.
+
+               ---- PO1 <---> PP2
+             /
+        PP1 ------- PO2 <---> PP3
+             \
+               ---- PO3 <---> PP4
+
         """
         """
         poweroutlets = self.poweroutlets.filter(cable__isnull=False)
         poweroutlets = self.poweroutlets.filter(cable__isnull=False)
         if leg:
         if leg:

+ 3 - 3
netbox/dcim/models/racks.py

@@ -438,9 +438,9 @@ class Rack(NetBoxModel):
                 peer for peer in powerfeed.link_peers if isinstance(peer, PowerPort)
                 peer for peer in powerfeed.link_peers if isinstance(peer, PowerPort)
             ])
             ])
 
 
-        allocated_draw = 0
-        for powerport in powerports:
-            allocated_draw += powerport.get_power_draw()['allocated']
+        allocated_draw = sum([
+            powerport.get_power_draw()['allocated'] for powerport in powerports
+        ])
 
 
         return int(allocated_draw / available_power_total * 100)
         return int(allocated_draw / available_power_total * 100)
 
 

+ 2 - 2
netbox/dcim/signals.py

@@ -3,7 +3,7 @@ import logging
 from django.db.models.signals import post_save, post_delete, pre_delete
 from django.db.models.signals import post_save, post_delete, pre_delete
 from django.dispatch import receiver
 from django.dispatch import receiver
 
 
-from .choices import LinkStatusChoices
+from .choices import CableEndChoices, LinkStatusChoices
 from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
 from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
 from .models.cables import trace_paths
 from .models.cables import trace_paths
 from .utils import create_cablepath, rebuild_paths
 from .utils import create_cablepath, rebuild_paths
@@ -83,7 +83,7 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs):
         a_terminations = []
         a_terminations = []
         b_terminations = []
         b_terminations = []
         for t in instance.terminations.all():
         for t in instance.terminations.all():
-            if t.cable_end == 'A':
+            if t.cable_end == CableEndChoices.SIDE_A:
                 a_terminations.append(t.termination)
                 a_terminations.append(t.termination)
             else:
             else:
                 b_terminations.append(t.termination)
                 b_terminations.append(t.termination)

+ 11 - 15
netbox/dcim/tables/template_code.py

@@ -13,21 +13,17 @@ CABLE_LENGTH = """
 {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
 {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
 """
 """
 
 
-CABLE_TERMINATION = """
-{{ value|join:", " }}
-"""
-
-CABLE_TERMINATION_PARENT = """
-{% with value.0 as termination %}
-  {% if termination.device %}
-    <a href="{{ termination.device.get_absolute_url }}">{{ termination.device }}</a>
-  {% elif termination.circuit %}
-    <a href="{{ termination.circuit.get_absolute_url }}">{{ termination.circuit }}</a>
-  {% elif termination.power_panel %}
-    <a href="{{ termination.power_panel.get_absolute_url }}">{{ termination.power_panel }}</a>
-  {% endif %}
-{% endwith %}
-"""
+# CABLE_TERMINATION_PARENT = """
+# {% with value.0 as termination %}
+#   {% if termination.device %}
+#     <a href="{{ termination.device.get_absolute_url }}">{{ termination.device }}</a>
+#   {% elif termination.circuit %}
+#     <a href="{{ termination.circuit.get_absolute_url }}">{{ termination.circuit }}</a>
+#   {% elif termination.power_panel %}
+#     <a href="{{ termination.power_panel.get_absolute_url }}">{{ termination.power_panel }}</a>
+#   {% endif %}
+# {% endwith %}
+# """
 
 
 DEVICE_LINK = """
 DEVICE_LINK = """
 <a href="{% url 'dcim:device' pk=record.pk %}">
 <a href="{% url 'dcim:device' pk=record.pk %}">

+ 7 - 6
netbox/dcim/tests/test_cablepaths.py

@@ -3,6 +3,7 @@ from django.test import TestCase
 from circuits.models import *
 from circuits.models import *
 from dcim.choices import LinkStatusChoices
 from dcim.choices import LinkStatusChoices
 from dcim.models import *
 from dcim.models import *
+from dcim.svg import CableTraceSVG
 from dcim.utils import object_to_path_node
 from dcim.utils import object_to_path_node
 
 
 
 
@@ -107,7 +108,7 @@ class CablePathTestCase(TestCase):
         self.assertPathIsSet(interface2, path2)
         self.assertPathIsSet(interface2, path2)
 
 
         # Test SVG generation
         # Test SVG generation
-        interface1.get_trace_svg()
+        CableTraceSVG(interface1).render()
 
 
         # Delete cable 1
         # Delete cable 1
         cable1.delete()
         cable1.delete()
@@ -146,7 +147,7 @@ class CablePathTestCase(TestCase):
         self.assertPathIsSet(consoleserverport1, path2)
         self.assertPathIsSet(consoleserverport1, path2)
 
 
         # Test SVG generation
         # Test SVG generation
-        consoleport1.get_trace_svg()
+        CableTraceSVG(consoleport1).render()
 
 
         # Delete cable 1
         # Delete cable 1
         cable1.delete()
         cable1.delete()
@@ -185,7 +186,7 @@ class CablePathTestCase(TestCase):
         self.assertPathIsSet(poweroutlet1, path2)
         self.assertPathIsSet(poweroutlet1, path2)
 
 
         # Test SVG generation
         # Test SVG generation
-        powerport1.get_trace_svg()
+        CableTraceSVG(powerport1).render()
 
 
         # Delete cable 1
         # Delete cable 1
         cable1.delete()
         cable1.delete()
@@ -224,7 +225,7 @@ class CablePathTestCase(TestCase):
         self.assertPathIsSet(powerfeed1, path2)
         self.assertPathIsSet(powerfeed1, path2)
 
 
         # Test SVG generation
         # Test SVG generation
-        powerport1.get_trace_svg()
+        CableTraceSVG(powerport1).render()
 
 
         # Delete cable 1
         # Delete cable 1
         cable1.delete()
         cable1.delete()
@@ -267,7 +268,7 @@ class CablePathTestCase(TestCase):
         self.assertPathIsSet(interface3, path2)
         self.assertPathIsSet(interface3, path2)
 
 
         # Test SVG generation
         # Test SVG generation
-        interface1.get_trace_svg()
+        CableTraceSVG(interface1).render()
 
 
         # Delete cable 1
         # Delete cable 1
         cable1.delete()
         cable1.delete()
@@ -319,7 +320,7 @@ class CablePathTestCase(TestCase):
         self.assertPathIsSet(interface4, path2)
         self.assertPathIsSet(interface4, path2)
 
 
         # Test SVG generation
         # Test SVG generation
-        interface1.get_trace_svg()
+        CableTraceSVG(interface1).render()
 
 
         # Delete cable 1
         # Delete cable 1
         cable1.delete()
         cable1.delete()

+ 3 - 11
netbox/dcim/utils.py

@@ -1,3 +1,5 @@
+import itertools
+
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db import transaction
 from django.db import transaction
 
 
@@ -29,16 +31,6 @@ def path_node_to_object(repr):
     return ct.model_class().objects.get(pk=object_id)
     return ct.model_class().objects.get(pk=object_id)
 
 
 
 
-def flatten_path(path):
-    """
-    Flatten a two-dimensional array (list of lists) into a flat list.
-    """
-    ret = []
-    for step in path:
-        ret.extend(step)
-    return ret
-
-
 def create_cablepath(terminations):
 def create_cablepath(terminations):
     """
     """
     Create CablePaths for all paths originating from the specified set of nodes.
     Create CablePaths for all paths originating from the specified set of nodes.
@@ -54,7 +46,7 @@ def create_cablepath(terminations):
 
 
 def rebuild_paths(terminations):
 def rebuild_paths(terminations):
     """
     """
-    Rebuild all CablePaths which traverse the specified node
+    Rebuild all CablePaths which traverse the specified nodes.
     """
     """
     from dcim.models import CablePath
     from dcim.models import CablePath
 
 

+ 16 - 16
netbox/dcim/views.py

@@ -28,6 +28,18 @@ from .choices import DeviceFaceChoices
 from .constants import NONCONNECTABLE_IFACE_TYPES
 from .constants import NONCONNECTABLE_IFACE_TYPES
 from .models import *
 from .models import *
 
 
+CABLE_TERMINATION_TYPES = {
+    'dcim.consoleport': ConsolePort,
+    'dcim.consoleserverport': ConsoleServerPort,
+    'dcim.powerport': PowerPort,
+    'dcim.poweroutlet': PowerOutlet,
+    'dcim.interface': Interface,
+    'dcim.frontport': FrontPort,
+    'dcim.rearport': RearPort,
+    'dcim.powerfeed': PowerFeed,
+    'circuits.circuittermination': CircuitTermination,
+}
+
 
 
 class DeviceComponentsView(generic.ObjectChildrenView):
 class DeviceComponentsView(generic.ObjectChildrenView):
     queryset = Device.objects.all()
     queryset = Device.objects.all()
@@ -2818,22 +2830,10 @@ class CableEditView(generic.ObjectEditView):
 
 
         # If creating a new Cable, initialize the form class using URL query params
         # If creating a new Cable, initialize the form class using URL query params
         if 'pk' not in kwargs:
         if 'pk' not in kwargs:
-            termination_types = {
-                'dcim.consoleport': ConsolePort,
-                'dcim.consoleserverport': ConsoleServerPort,
-                'dcim.powerport': PowerPort,
-                'dcim.poweroutlet': PowerOutlet,
-                'dcim.interface': Interface,
-                'dcim.frontport': FrontPort,
-                'dcim.rearport': RearPort,
-                'dcim.powerfeed': PowerFeed,
-                'circuits.circuittermination': CircuitTermination,
-            }
-
-            a_type = termination_types.get(request.GET.get('a_terminations_type'))
-            b_type = termination_types.get(request.GET.get('b_terminations_type'))
-
-            self.form = forms.get_cable_form(a_type, b_type)
+            self.form = forms.get_cable_form(
+                a_type=CABLE_TERMINATION_TYPES.get(request.GET.get('a_terminations_type')),
+                b_type=CABLE_TERMINATION_TYPES.get(request.GET.get('b_terminations_type'))
+            )
 
 
         return super().dispatch(request, *args, **kwargs)
         return super().dispatch(request, *args, **kwargs)
 
 

+ 2 - 2
netbox/netbox/middleware.py

@@ -179,8 +179,8 @@ class ExceptionHandlingMiddleware:
     def process_exception(self, request, exception):
     def process_exception(self, request, exception):
 
 
         # Handle exceptions that occur from REST API requests
         # Handle exceptions that occur from REST API requests
-        if is_api_request(request):
-            return rest_api_server_error(request)
+        # if is_api_request(request):
+        #     return rest_api_server_error(request)
 
 
         # Don't catch exceptions when in debug mode
         # Don't catch exceptions when in debug mode
         if settings.DEBUG:
         if settings.DEBUG:

+ 3 - 4
netbox/netbox/views/__init__.py

@@ -3,7 +3,6 @@ import sys
 
 
 from django.conf import settings
 from django.conf import settings
 from django.core.cache import cache
 from django.core.cache import cache
-from django.db.models import F
 from django.http import HttpResponseServerError
 from django.http import HttpResponseServerError
 from django.shortcuts import redirect, render
 from django.shortcuts import redirect, render
 from django.template import loader
 from django.template import loader
@@ -37,13 +36,13 @@ class HomeView(View):
             return redirect("login")
             return redirect("login")
 
 
         connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
         connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
-            _path__is_active=True
+            _path__is_complete=True
         )
         )
         connected_powerports = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
         connected_powerports = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
-            _path__is_active=True
+            _path__is_complete=True
         )
         )
         connected_interfaces = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
         connected_interfaces = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
-            _path__is_active=True
+            _path__is_complete=True
         )
         )
 
 
         def build_stats():
         def build_stats():