Răsfoiți Sursa

Initial work on SVG support for cable tracing

jeremystretch 4 ani în urmă
părinte
comite
ce7fa95546

+ 7 - 0
netbox/dcim/api/views.py

@@ -53,6 +53,13 @@ class PathEndpointMixin(object):
         # Initialize the path array
         # Initialize the path array
         path = []
         path = []
 
 
+        if request.GET.get('render', None) == 'svg':
+            # Render SVG
+            drawing = obj.get_trace_svg(
+                base_url=request.build_absolute_uri('/')
+            )
+            return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
+
         for near_end, cable, far_end in obj.trace():
         for near_end, cable, far_end in obj.trace():
             if near_end is None:
             if near_end is None:
                 # Split paths
                 # Split paths

+ 5 - 0
netbox/dcim/models/device_components.py

@@ -10,6 +10,7 @@ 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
 from dcim.fields import MACAddressField
+from dcim.svg import CableTraceSVG
 from extras.utils import extras_features
 from extras.utils import extras_features
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
@@ -193,6 +194,10 @@ class PathEndpoint(models.Model):
         # Return the path as a list of three-tuples (A termination, cable, B termination)
         # Return the path as a list of three-tuples (A termination, cable, B termination)
         return list(zip(*[iter(path)] * 3))
         return list(zip(*[iter(path)] * 3))
 
 
+    def get_trace_svg(self, base_url=None):
+        trace = CableTraceSVG(self, base_url=base_url)
+        return trace.render()
+
     @property
     @property
     def path(self):
     def path(self):
         return self._path
         return self._path

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

@@ -13,7 +13,7 @@ from django.urls import reverse
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
-from dcim.elevations import RackElevationSVG
+from dcim.svg import RackElevationSVG
 from extras.utils import extras_features
 from extras.utils import extras_features
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models import OrganizationalModel, PrimaryModel
 from utilities.choices import ColorChoices
 from utilities.choices import ColorChoices

+ 224 - 0
netbox/dcim/elevations.py → netbox/dcim/svg.py

@@ -1,4 +1,7 @@
 import svgwrite
 import svgwrite
+from svgwrite.container import Group, Hyperlink
+from svgwrite.shapes import Line, Rect
+from svgwrite.text import Text
 
 
 from django.conf import settings
 from django.conf import settings
 from django.urls import reverse
 from django.urls import reverse
@@ -9,6 +12,12 @@ from .choices import DeviceFaceChoices
 from .constants import RACK_ELEVATION_BORDER_WIDTH
 from .constants import RACK_ELEVATION_BORDER_WIDTH
 
 
 
 
+__all__ = (
+    'CableTraceSVG',
+    'RackElevationSVG',
+)
+
+
 class RackElevationSVG:
 class RackElevationSVG:
     """
     """
     Use this class to render a rack elevation as an SVG image.
     Use this class to render a rack elevation as an SVG image.
@@ -231,3 +240,218 @@ class RackElevationSVG:
         drawing.add(frame)
         drawing.add(frame)
 
 
         return drawing
         return drawing
+
+
+OFFSET = 0.5
+PADDING = 10
+LINE_HEIGHT = 15
+
+
+class CableTraceSVG:
+    """
+    Generate a graphical representation of a CablePath in SVG format.
+
+    :param origin: The originating termination
+    :param width: Width of the generated image (in pixels)
+    :param base_url: Base URL for links within the SVG document. If none, links will be relative.
+    """
+    PARENT_OBJECT_DEFAULT_COLOR = 'd0d0d0'
+    TERMINATION_DEFAULT_COLOR = 'c0c0c0'
+
+    def __init__(self, origin, width=400, base_url=None):
+        self.origin = origin
+        self.width = width
+        self.base_url = base_url.rstrip('/') if base_url is not None else ''
+
+        # Establish a cursor to track position on the y axis
+        # Center edges on pixels to render sharp borders
+        self.cursor = OFFSET
+
+    @property
+    def center(self):
+        return self.width / 2
+
+    @classmethod
+    def _get_labels(cls, instance):
+        """
+        Return a list of text labels for the given instance based on model type.
+        """
+        labels = [str(instance)]
+        if hasattr(instance, 'device_type'):
+            labels.append(str(instance.device_type))
+        elif hasattr(instance, 'provider'):
+            labels.append(instance.provider)
+
+        return labels
+
+    @classmethod
+    def _get_color(cls, instance):
+        """
+        Return the appropriate fill color for an object within a cable path.
+        """
+        if hasattr(instance, 'parent_object'):
+            # Termination
+            return cls.TERMINATION_DEFAULT_COLOR
+        if hasattr(instance, 'device_role'):
+            # Device
+            return instance.device_role.color
+        else:
+            # Other parent object
+            return cls.PARENT_OBJECT_DEFAULT_COLOR
+
+    def _draw_box(self, width, color, url, labels, y_indent=0, padding_multiplier=1, radius=10):
+        """
+        Return an SVG Link element containing a Rect and one or more text labels representing a
+        parent object or cable termination point.
+
+        :param width: Box width
+        :param color: Box fill color
+        :param url: Hyperlink URL
+        :param labels: Iterable of text labels
+        :param y_indent: Vertical indent (for overlapping other boxes) (default: 0)
+        :param padding_multiplier: Add extra vertical padding (default: 1)
+        :param radius: Box corner radius (default: 10)
+        """
+        self.cursor -= y_indent
+
+        # Create a hyperlink
+        link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
+
+        # Add the box
+        position = (
+            OFFSET + (self.width - width) / 2,
+            self.cursor
+        )
+        height = PADDING * padding_multiplier \
+            + LINE_HEIGHT * len(labels) \
+            + PADDING * padding_multiplier
+        box = Rect(position, (width - 2, height), rx=radius, class_='parent-object', style=f'fill: #{color}')
+        link.add(box)
+        self.cursor += PADDING * padding_multiplier
+
+        # Add text label(s)
+        for i, label in enumerate(labels):
+            self.cursor += LINE_HEIGHT
+            text_coords = (self.center, self.cursor - LINE_HEIGHT / 2)
+            text_color = f'#{foreground_color(color)}'
+            text = Text(label, insert=text_coords, fill=text_color)
+            link.add(text)
+
+        self.cursor += PADDING * padding_multiplier
+
+        return link
+
+    def _draw_cable(self, color, url, labels):
+        """
+        Return an SVG group containing a line element and text labels representing a Cable.
+
+        :param color: Cable (line) color
+        :param url: Hyperlink URL
+        :param labels: Iterable of text labels
+        """
+        group = Group()
+
+        # Draw cable (line)
+        start = (OFFSET + self.center, self.cursor)
+        height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
+        end = (start[0], start[1] + height)
+        line = Line(start=start, end=end, style=f'stroke: #{color}')
+        group.add(line)
+        self.cursor += PADDING * 2
+
+        # Add link
+        link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
+
+        # Add text label(s)
+        for i, label in enumerate(labels):
+            self.cursor += LINE_HEIGHT
+            text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
+            text = Text(label, insert=text_coords, class_='cable')
+            link.add(text)
+
+        group.add(link)
+        self.cursor += PADDING * 2
+
+        return group
+
+    def render(self):
+        """
+        Return an SVG document representing a cable trace.
+        """
+        traced_path = self.origin.trace()
+
+        # Prep elements list
+        parent_objects = []
+        terminations = []
+        cables = []
+
+        # Iterate through each (term, cable, term) segment in the path
+        for i, segment in enumerate(traced_path):
+            near_end, cable, far_end = segment
+
+            # Near end parent
+            if i == 0:
+                # If this is the first segment, draw the originating termination's parent object
+                parent_object = self._draw_box(
+                    width=self.width,
+                    color=self._get_color(near_end.parent_object),
+                    url=near_end.parent_object.get_absolute_url(),
+                    labels=self._get_labels(near_end.parent_object),
+                    padding_multiplier=2
+                )
+                parent_objects.append(parent_object)
+
+            # Near end termination
+            termination = self._draw_box(
+                width=self.width * .8,
+                color=self._get_color(near_end),
+                url=near_end.get_absolute_url(),
+                labels=[str(near_end)],
+                y_indent=PADDING,
+                radius=5
+            )
+            terminations.append(termination)
+
+            # Cable
+            cable = self._draw_cable(
+                color=cable.color or '000000',
+                url=cable.get_absolute_url(),
+                labels=[f'Cable {cable}', cable.get_status_display()]
+            )
+            cables.append(cable)
+
+            # Far end termination
+            termination = self._draw_box(
+                width=self.width * .8,
+                color=self._get_color(far_end),
+                url=far_end.get_absolute_url(),
+                labels=[str(far_end)],
+                radius=5
+            )
+            terminations.append(termination)
+
+            # Far end parent
+            parent_object = self._draw_box(
+                width=self.width,
+                color=self._get_color(far_end.parent_object),
+                url=far_end.parent_object.get_absolute_url(),
+                labels=self._get_labels(far_end.parent_object),
+                y_indent=PADDING,
+                padding_multiplier=2
+            )
+            parent_objects.append(parent_object)
+
+        # Determine drawing size
+        self.drawing = svgwrite.Drawing(
+            size=(self.width, self.cursor)
+        )
+
+        # Attach CSS stylesheet
+        with open(f'{settings.STATIC_ROOT}/cable_trace.css') as css_file:
+            self.drawing.defs.add(self.drawing.style(css_file.read()))
+
+        # Add elements to the drawing in order of depth (Z axis)
+        for element in parent_objects + terminations + cables:
+            self.drawing.add(element)
+
+        return self.drawing

+ 25 - 0
netbox/project-static/_cable_trace.scss

@@ -0,0 +1,25 @@
+* {
+    font-family: sans-serif;
+    font-size: 13px;
+}
+text {
+    text-anchor: middle;
+    dominant-baseline: middle;
+}
+
+svg {
+  /* Boxes */
+  rect {
+    stroke: #303030;
+    stroke-width: 1;
+  }
+
+  /* Cables */
+  line {
+    stroke-width: 3;
+  }
+
+  text.cable {
+    text-anchor: start;
+  }
+}

+ 1 - 0
netbox/project-static/bundle.js

@@ -31,6 +31,7 @@ const styles = [
   ['_light.scss', 'netbox-light.css'],
   ['_light.scss', 'netbox-light.css'],
   ['_dark.scss', 'netbox-dark.css'],
   ['_dark.scss', 'netbox-dark.css'],
   ['_elevations.scss', 'rack_elevation.css'],
   ['_elevations.scss', 'rack_elevation.css'],
+  ['_cable_trace.scss', 'cable_trace.css'],
 ];
 ];
 
 
 // Script (JavaScript) bundle jobs. Generally, everything should be bundled into netbox.js from
 // Script (JavaScript) bundle jobs. Generally, everything should be bundled into netbox.js from

+ 2 - 0
netbox/project-static/dist/cable_trace.css

@@ -0,0 +1,2 @@
+*{font-family:sans-serif;font-size:13px}text{text-anchor:middle;dominant-baseline:middle}svg rect{stroke:#303030;stroke-width:1}svg line{stroke-width:3}svg text.cable{text-anchor:start}
+/*# sourceMappingURL=/static/cable_trace.css.map */

+ 1 - 0
netbox/project-static/dist/cable_trace.css.map

@@ -0,0 +1 @@
+{"version":3,"sources":["_cable_trace.scss"],"names":[],"mappings":"AAAA,EACI,sBAAA,CACA,eAEJ,KACI,kBAAA,CACA,yBAKF,SACE,cAAA,CACA,eAIF,SACE,eAGF,eACE","file":"cable_trace.css","sourceRoot":"..","sourcesContent":["* {\n    font-family: sans-serif;\n    font-size: 13px;\n}\ntext {\n    text-anchor: middle;\n    dominant-baseline: middle;\n}\n\nsvg {\n  /* Boxes */\n  rect {\n    stroke: #303030;\n    stroke-width: 1;\n  }\n\n  /* Cables */\n  line {\n    stroke-width: 3;\n  }\n\n  text.cable {\n    text-anchor: start;\n  }\n}\n"]}