Просмотр исходного кода

Merge pull request #6755 from netbox-community/6000-cable-trace-svg

Closes #6000: SVG rendering for cable tracing
Jeremy Stretch 4 лет назад
Родитель
Сommit
2bfdaf08ee

+ 8 - 4
netbox/dcim/api/views.py

@@ -2,18 +2,15 @@ import socket
 from collections import OrderedDict
 from collections import OrderedDict
 
 
 from django.conf import settings
 from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
-from django.db.models import F
 from django.http import HttpResponseForbidden, HttpResponse
 from django.http import HttpResponseForbidden, HttpResponse
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 from drf_yasg import openapi
 from drf_yasg import openapi
 from drf_yasg.openapi import Parameter
 from drf_yasg.openapi import Parameter
 from drf_yasg.utils import swagger_auto_schema
 from drf_yasg.utils import swagger_auto_schema
 from rest_framework.decorators import action
 from rest_framework.decorators import action
-from rest_framework.mixins import ListModelMixin
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
 from rest_framework.routers import APIRootView
-from rest_framework.viewsets import GenericViewSet, ViewSet
+from rest_framework.viewsets import ViewSet
 
 
 from circuits.models import Circuit
 from circuits.models import Circuit
 from dcim import filtersets
 from dcim import filtersets
@@ -53,6 +50,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

+ 0 - 233
netbox/dcim/elevations.py

@@ -1,233 +0,0 @@
-import svgwrite
-
-from django.conf import settings
-from django.urls import reverse
-from django.utils.http import urlencode
-
-from utilities.utils import foreground_color
-from .choices import DeviceFaceChoices
-from .constants import RACK_ELEVATION_BORDER_WIDTH
-
-
-class RackElevationSVG:
-    """
-    Use this class to render a rack elevation as an SVG image.
-
-    :param rack: A NetBox Rack instance
-    :param user: User instance. If specified, only devices viewable by this user will be fully displayed.
-    :param include_images: If true, the SVG document will embed front/rear device face images, where available
-    :param base_url: Base URL for links within the SVG document. If none, links will be relative.
-    """
-    def __init__(self, rack, user=None, include_images=True, base_url=None):
-        self.rack = rack
-        self.include_images = include_images
-        if base_url is not None:
-            self.base_url = base_url.rstrip('/')
-        else:
-            self.base_url = ''
-
-        # Determine the subset of devices within this rack that are viewable by the user, if any
-        permitted_devices = self.rack.devices
-        if user is not None:
-            permitted_devices = permitted_devices.restrict(user, 'view')
-        self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
-
-    @staticmethod
-    def _get_device_description(device):
-        return '{} ({}) — {} {} ({}U) {} {}'.format(
-            device.name,
-            device.device_role,
-            device.device_type.manufacturer.name,
-            device.device_type.model,
-            device.device_type.u_height,
-            device.asset_tag or '',
-            device.serial or ''
-        )
-
-    @staticmethod
-    def _add_gradient(drawing, id_, color):
-        gradient = drawing.linearGradient(
-            start=(0, 0),
-            end=(0, 25),
-            spreadMethod='repeat',
-            id_=id_,
-            gradientTransform='rotate(45, 0, 0)',
-            gradientUnits='userSpaceOnUse'
-        )
-        gradient.add_stop_color(offset='0%', color='#f7f7f7')
-        gradient.add_stop_color(offset='50%', color='#f7f7f7')
-        gradient.add_stop_color(offset='50%', color=color)
-        gradient.add_stop_color(offset='100%', color=color)
-        drawing.defs.add(gradient)
-
-    @staticmethod
-    def _setup_drawing(width, height):
-        drawing = svgwrite.Drawing(size=(width, height))
-
-        # add the stylesheet
-        with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file:
-            drawing.defs.add(drawing.style(css_file.read()))
-
-        # add gradients
-        RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff')
-        RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
-        RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
-
-        return drawing
-
-    def _draw_device_front(self, drawing, device, start, end, text):
-        name = str(device)
-        if device.devicebay_count:
-            name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
-
-        color = device.device_role.color
-        link = drawing.add(
-            drawing.a(
-                href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
-                target='_top',
-                fill='black'
-            )
-        )
-        link.set_desc(self._get_device_description(device))
-        link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
-        hex_color = '#{}'.format(foreground_color(color))
-        link.add(drawing.text(str(name), insert=text, fill=hex_color))
-
-        # Embed front device type image if one exists
-        if self.include_images and device.device_type.front_image:
-            image = drawing.image(
-                href=device.device_type.front_image.url,
-                insert=start,
-                size=end,
-                class_='device-image'
-            )
-            image.fit(scale='slice')
-            link.add(image)
-
-    def _draw_device_rear(self, drawing, device, start, end, text):
-        rect = drawing.rect(start, end, class_="slot blocked")
-        rect.set_desc(self._get_device_description(device))
-        drawing.add(rect)
-        drawing.add(drawing.text(str(device), insert=text))
-
-        # Embed rear device type image if one exists
-        if self.include_images and device.device_type.rear_image:
-            image = drawing.image(
-                href=device.device_type.rear_image.url,
-                insert=start,
-                size=end,
-                class_='device-image'
-            )
-            image.fit(scale='slice')
-            drawing.add(image)
-
-    @staticmethod
-    def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
-        link = drawing.add(
-            drawing.a(
-                href='{}?{}'.format(
-                    reverse('dcim:device_add'),
-                    urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
-                ),
-                target='_top'
-            )
-        )
-        if reservation:
-            link.set_desc('{} — {} · {}'.format(
-                reservation.description, reservation.user, reservation.created
-            ))
-        link.add(drawing.rect(start, end, class_=class_))
-        link.add(drawing.text("add device", insert=text, class_='add-device'))
-
-    def merge_elevations(self, face):
-        elevation = self.rack.get_rack_units(face=face, expand_devices=False)
-        if face == DeviceFaceChoices.FACE_REAR:
-            other_face = DeviceFaceChoices.FACE_FRONT
-        else:
-            other_face = DeviceFaceChoices.FACE_REAR
-        other = self.rack.get_rack_units(face=other_face)
-
-        unit_cursor = 0
-        for u in elevation:
-            o = other[unit_cursor]
-            if not u['device'] and o['device'] and o['device'].device_type.is_full_depth:
-                u['device'] = o['device']
-                u['height'] = 1
-            unit_cursor += u.get('height', 1)
-
-        return elevation
-
-    def render(self, face, unit_width, unit_height, legend_width):
-        """
-        Return an SVG document representing a rack elevation.
-        """
-        drawing = self._setup_drawing(
-            unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2,
-            unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
-        )
-        reserved_units = self.rack.get_reserved_units()
-
-        unit_cursor = 0
-        for ru in range(0, self.rack.u_height):
-            start_y = ru * unit_height
-            position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
-            unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
-            drawing.add(
-                drawing.text(str(unit), position_coordinates, class_="unit")
-            )
-
-        for unit in self.merge_elevations(face):
-
-            # Loop through all units in the elevation
-            device = unit['device']
-            height = unit.get('height', 1)
-
-            # Setup drawing coordinates
-            x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH
-            y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH
-            end_y = unit_height * height
-            start_cordinates = (x_offset, y_offset)
-            end_cordinates = (unit_width, end_y)
-            text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2)
-
-            # Draw the device
-            if device and device.face == face and device.pk in self.permitted_device_ids:
-                self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
-            elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids:
-                self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
-            elif device:
-                # Devices which the user does not have permission to view are rendered only as unavailable space
-                drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked'))
-            else:
-                # Draw shallow devices, reservations, or empty units
-                class_ = 'slot'
-                reservation = reserved_units.get(unit["id"])
-                if device:
-                    class_ += ' occupied'
-                if reservation:
-                    class_ += ' reserved'
-                self._draw_empty(
-                    drawing,
-                    self.rack,
-                    start_cordinates,
-                    end_cordinates,
-                    text_cordinates,
-                    unit["id"],
-                    face,
-                    class_,
-                    reservation
-                )
-
-            unit_cursor += height
-
-        # Wrap the drawing with a border
-        border_width = RACK_ELEVATION_BORDER_WIDTH
-        border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
-        frame = drawing.rect(
-            insert=(legend_width + border_offset, border_offset),
-            size=(unit_width + border_width, self.rack.u_height * unit_height + border_width),
-            class_='rack'
-        )
-        drawing.add(frame)
-
-        return drawing

+ 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

+ 506 - 0
netbox/dcim/svg.py

@@ -0,0 +1,506 @@
+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.urls import reverse
+from django.utils.http import urlencode
+
+from utilities.utils import foreground_color
+from .choices import DeviceFaceChoices
+from .constants import RACK_ELEVATION_BORDER_WIDTH
+
+
+__all__ = (
+    'CableTraceSVG',
+    'RackElevationSVG',
+)
+
+
+class RackElevationSVG:
+    """
+    Use this class to render a rack elevation as an SVG image.
+
+    :param rack: A NetBox Rack instance
+    :param user: User instance. If specified, only devices viewable by this user will be fully displayed.
+    :param include_images: If true, the SVG document will embed front/rear device face images, where available
+    :param base_url: Base URL for links within the SVG document. If none, links will be relative.
+    """
+    def __init__(self, rack, user=None, include_images=True, base_url=None):
+        self.rack = rack
+        self.include_images = include_images
+        if base_url is not None:
+            self.base_url = base_url.rstrip('/')
+        else:
+            self.base_url = ''
+
+        # Determine the subset of devices within this rack that are viewable by the user, if any
+        permitted_devices = self.rack.devices
+        if user is not None:
+            permitted_devices = permitted_devices.restrict(user, 'view')
+        self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
+
+    @staticmethod
+    def _get_device_description(device):
+        return '{} ({}) — {} {} ({}U) {} {}'.format(
+            device.name,
+            device.device_role,
+            device.device_type.manufacturer.name,
+            device.device_type.model,
+            device.device_type.u_height,
+            device.asset_tag or '',
+            device.serial or ''
+        )
+
+    @staticmethod
+    def _add_gradient(drawing, id_, color):
+        gradient = drawing.linearGradient(
+            start=(0, 0),
+            end=(0, 25),
+            spreadMethod='repeat',
+            id_=id_,
+            gradientTransform='rotate(45, 0, 0)',
+            gradientUnits='userSpaceOnUse'
+        )
+        gradient.add_stop_color(offset='0%', color='#f7f7f7')
+        gradient.add_stop_color(offset='50%', color='#f7f7f7')
+        gradient.add_stop_color(offset='50%', color=color)
+        gradient.add_stop_color(offset='100%', color=color)
+        drawing.defs.add(gradient)
+
+    @staticmethod
+    def _setup_drawing(width, height):
+        drawing = svgwrite.Drawing(size=(width, height))
+
+        # add the stylesheet
+        with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file:
+            drawing.defs.add(drawing.style(css_file.read()))
+
+        # add gradients
+        RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff')
+        RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
+        RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
+
+        return drawing
+
+    def _draw_device_front(self, drawing, device, start, end, text):
+        name = str(device)
+        if device.devicebay_count:
+            name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
+
+        color = device.device_role.color
+        link = drawing.add(
+            drawing.a(
+                href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
+                target='_top',
+                fill='black'
+            )
+        )
+        link.set_desc(self._get_device_description(device))
+        link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
+        hex_color = '#{}'.format(foreground_color(color))
+        link.add(drawing.text(str(name), insert=text, fill=hex_color))
+
+        # Embed front device type image if one exists
+        if self.include_images and device.device_type.front_image:
+            image = drawing.image(
+                href=device.device_type.front_image.url,
+                insert=start,
+                size=end,
+                class_='device-image'
+            )
+            image.fit(scale='slice')
+            link.add(image)
+
+    def _draw_device_rear(self, drawing, device, start, end, text):
+        rect = drawing.rect(start, end, class_="slot blocked")
+        rect.set_desc(self._get_device_description(device))
+        drawing.add(rect)
+        drawing.add(drawing.text(str(device), insert=text))
+
+        # Embed rear device type image if one exists
+        if self.include_images and device.device_type.rear_image:
+            image = drawing.image(
+                href=device.device_type.rear_image.url,
+                insert=start,
+                size=end,
+                class_='device-image'
+            )
+            image.fit(scale='slice')
+            drawing.add(image)
+
+    @staticmethod
+    def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
+        link = drawing.add(
+            drawing.a(
+                href='{}?{}'.format(
+                    reverse('dcim:device_add'),
+                    urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
+                ),
+                target='_top'
+            )
+        )
+        if reservation:
+            link.set_desc('{} — {} · {}'.format(
+                reservation.description, reservation.user, reservation.created
+            ))
+        link.add(drawing.rect(start, end, class_=class_))
+        link.add(drawing.text("add device", insert=text, class_='add-device'))
+
+    def merge_elevations(self, face):
+        elevation = self.rack.get_rack_units(face=face, expand_devices=False)
+        if face == DeviceFaceChoices.FACE_REAR:
+            other_face = DeviceFaceChoices.FACE_FRONT
+        else:
+            other_face = DeviceFaceChoices.FACE_REAR
+        other = self.rack.get_rack_units(face=other_face)
+
+        unit_cursor = 0
+        for u in elevation:
+            o = other[unit_cursor]
+            if not u['device'] and o['device'] and o['device'].device_type.is_full_depth:
+                u['device'] = o['device']
+                u['height'] = 1
+            unit_cursor += u.get('height', 1)
+
+        return elevation
+
+    def render(self, face, unit_width, unit_height, legend_width):
+        """
+        Return an SVG document representing a rack elevation.
+        """
+        drawing = self._setup_drawing(
+            unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2,
+            unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
+        )
+        reserved_units = self.rack.get_reserved_units()
+
+        unit_cursor = 0
+        for ru in range(0, self.rack.u_height):
+            start_y = ru * unit_height
+            position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
+            unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
+            drawing.add(
+                drawing.text(str(unit), position_coordinates, class_="unit")
+            )
+
+        for unit in self.merge_elevations(face):
+
+            # Loop through all units in the elevation
+            device = unit['device']
+            height = unit.get('height', 1)
+
+            # Setup drawing coordinates
+            x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH
+            y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH
+            end_y = unit_height * height
+            start_cordinates = (x_offset, y_offset)
+            end_cordinates = (unit_width, end_y)
+            text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2)
+
+            # Draw the device
+            if device and device.face == face and device.pk in self.permitted_device_ids:
+                self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
+            elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids:
+                self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
+            elif device:
+                # Devices which the user does not have permission to view are rendered only as unavailable space
+                drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked'))
+            else:
+                # Draw shallow devices, reservations, or empty units
+                class_ = 'slot'
+                reservation = reserved_units.get(unit["id"])
+                if device:
+                    class_ += ' occupied'
+                if reservation:
+                    class_ += ' reserved'
+                self._draw_empty(
+                    drawing,
+                    self.rack,
+                    start_cordinates,
+                    end_cordinates,
+                    text_cordinates,
+                    unit["id"],
+                    face,
+                    class_,
+                    reservation
+                )
+
+            unit_cursor += height
+
+        # Wrap the drawing with a border
+        border_width = RACK_ELEVATION_BORDER_WIDTH
+        border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
+        frame = drawing.rect(
+            insert=(legend_width + border_offset, border_offset),
+            size=(unit_width + border_width, self.rack.u_height * unit_height + border_width),
+            class_='rack'
+        )
+        drawing.add(frame)
+
+        return drawing
+
+
+OFFSET = 0.5
+PADDING = 10
+LINE_HEIGHT = 20
+
+
+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.
+    """
+    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 instance._meta.model_name == 'device':
+            labels.append(f'{instance.device_type.manufacturer} {instance.device_type}')
+            location_label = f'{instance.site}'
+            if instance.location:
+                location_label += f' / {instance.location}'
+            if instance.rack:
+                location_label += f' / {instance.rack}'
+            labels.append(location_label)
+        elif instance._meta.model_name == 'circuit':
+            labels[0] = f'Circuit {instance}'
+            labels.append(instance.provider)
+        elif instance._meta.model_name == 'circuittermination':
+            if instance.xconnect_id:
+                labels.append(f'{instance.xconnect_id}')
+        elif instance._meta.model_name == 'providernetwork':
+            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 'f0f0f0'
+        if hasattr(instance, 'device_role'):
+            # Device
+            return instance.device_role.color
+        else:
+            # Other parent object
+            return 'e0e0e0'
+
+    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, dark="303030")}'
+            text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else [])
+            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(class_='connector')
+
+        # Draw a "shadow" line to give the cable a border
+        start = (OFFSET + self.center, self.cursor)
+        height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
+        end = (start[0], start[1] + height)
+        cable_shadow = Line(start=start, end=end, class_='cable-shadow')
+        group.add(cable_shadow)
+
+        # Draw the cable
+        cable = Line(start=start, end=end, style=f'stroke: #{color}')
+        group.add(cable)
+
+        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_='bold' if not i else [])
+            link.add(text)
+
+        group.add(link)
+        self.cursor += PADDING * 2
+
+        return group
+
+    def _draw_attachment(self):
+        """
+        Return an SVG group containing a line element and "Attachment" label.
+        """
+        group = Group(class_='connector')
+
+        # Draw attachment (line)
+        start = (OFFSET + self.center, OFFSET + self.cursor)
+        height = PADDING * 2 + LINE_HEIGHT + PADDING * 2
+        end = (start[0], start[1] + height)
+        line = Line(start=start, end=end, class_='attachment')
+        group.add(line)
+        self.cursor += PADDING * 4
+
+        return group
+
+    def render(self):
+        """
+        Return an SVG document representing a cable trace.
+        """
+        traced_path = self.origin.trace()
+
+        # Prep elements list
+        parent_objects = []
+        terminations = []
+        connectors = []
+
+        # Iterate through each (term, cable, term) segment in the path
+        for i, segment in enumerate(traced_path):
+            near_end, connector, 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=self._get_labels(near_end),
+                y_indent=PADDING,
+                radius=5
+            )
+            terminations.append(termination)
+
+            # Connector (either a Cable or attachment to a ProviderNetwork)
+            if connector is not None:
+
+                # Cable
+                cable = self._draw_cable(
+                    color=connector.color or '000000',
+                    url=connector.get_absolute_url(),
+                    labels=[f'Cable {connector}', connector.get_status_display()]
+                )
+                connectors.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=self._get_labels(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)
+
+            else:
+
+                # Attachment
+                attachment = self._draw_attachment()
+                connectors.append(attachment)
+
+                # ProviderNetwork
+                parent_object = self._draw_box(
+                    width=self.width,
+                    color=self._get_color(far_end),
+                    url=far_end.get_absolute_url(),
+                    labels=self._get_labels(far_end),
+                    padding_multiplier=2
+                )
+                parent_objects.append(parent_object)
+
+        # Determine drawing size
+        self.drawing = svgwrite.Drawing(
+            size=(self.width, self.cursor + 2)
+        )
+
+        # 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 connectors + parent_objects + terminations:
+            self.drawing.add(element)
+
+        return self.drawing

+ 8 - 4
netbox/dcim/views.py

@@ -1,15 +1,14 @@
 import logging
 import logging
-from copy import deepcopy
 from collections import OrderedDict
 from collections import OrderedDict
 
 
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.db import transaction
 from django.db import transaction
 from django.db.models import F, Prefetch
 from django.db.models import F, Prefetch
 from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
 from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
+from django.urls import reverse
 from django.utils.html import escape
 from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.views.generic import View
 from django.views.generic import View
@@ -23,7 +22,7 @@ from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.permissions import get_permission_for_model
 from utilities.permissions import get_permission_for_model
 from utilities.tables import paginate_table
 from utilities.tables import paginate_table
-from utilities.utils import csv_format, count_related
+from utilities.utils import count_related
 from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
 from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
@@ -2423,11 +2422,16 @@ class PathTraceView(generic.ObjectView):
         # Get the total length of the cable and whether the length is definitive (fully defined)
         # Get the total length of the cable and whether the length is definitive (fully defined)
         total_length, is_definitive = path.get_total_length() if path else (None, False)
         total_length, is_definitive = path.get_total_length() if path else (None, False)
 
 
+        # Determine the path to the SVG trace image
+        api_viewname = f"{path.origin._meta.app_label}-api:{path.origin._meta.model_name}-trace"
+        svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origin.pk})}?render=svg"
+
         return {
         return {
             'path': path,
             'path': path,
             'related_paths': related_paths,
             'related_paths': related_paths,
             'total_length': total_length,
             'total_length': total_length,
-            'is_definitive': is_definitive
+            'is_definitive': is_definitive,
+            'svg_url': svg_url,
         }
         }
 
 
 
 

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

@@ -31,6 +31,7 @@ const styles = [
   ['styles/_light.scss', 'netbox-light.css'],
   ['styles/_light.scss', 'netbox-light.css'],
   ['styles/_dark.scss', 'netbox-dark.css'],
   ['styles/_dark.scss', 'netbox-dark.css'],
   ['styles/_elevations.scss', 'rack_elevation.css'],
   ['styles/_elevations.scss', 'rack_elevation.css'],
+  ['styles/_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:14px}text{text-anchor:middle;dominant-baseline:middle}text.bold{font-weight:700}svg rect{fill:#e0e0e0;stroke:#606060;stroke-width:1}svg rect .termination{fill:#f0f0f0}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg line.cable-shadow{stroke:#303030;stroke-width:7px}svg line.attachment{stroke:silver;stroke-dasharray:5px,5px}
+/*# 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,yBAEJ,UACE,gBAKA,SACE,YAAA,CACA,cAAA,CACA,eACA,sBACE,aAKJ,oBACE,kBAEF,SACE,iBAEF,sBACE,cAAA,CACA,iBAEF,oBACE,aAAA,CACA","file":"cable_trace.css","sourceRoot":"../styles","sourcesContent":["* {\n    font-family: sans-serif;\n    font-size: 14px;\n}\ntext {\n    text-anchor: middle;\n    dominant-baseline: middle;\n}\ntext.bold {\n  font-weight: bold;\n}\n\nsvg {\n  /* Boxes */\n  rect {\n    fill: #e0e0e0;\n    stroke: #606060;\n    stroke-width: 1;\n    .termination {\n      fill: #f0f0f0;\n    }\n  }\n\n  /* Connectors */\n  .connector text {\n    text-anchor: start;\n  }\n  line {\n    stroke-width: 5px;\n  }\n  line.cable-shadow {\n    stroke: #303030;\n    stroke-width: 7px;\n  }\n  line.attachment {\n    stroke: #c0c0c0;\n    stroke-dasharray: 5px,5px;\n  }\n}\n"]}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-dark.css.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-light.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-light.css.map


+ 39 - 0
netbox/project-static/styles/_cable_trace.scss

@@ -0,0 +1,39 @@
+* {
+    font-family: sans-serif;
+    font-size: 14px;
+}
+text {
+    text-anchor: middle;
+    dominant-baseline: middle;
+}
+text.bold {
+  font-weight: bold;
+}
+
+svg {
+  /* Boxes */
+  rect {
+    fill: #e0e0e0;
+    stroke: #606060;
+    stroke-width: 1;
+    .termination {
+      fill: #f0f0f0;
+    }
+  }
+
+  /* Connectors */
+  .connector text {
+    text-anchor: start;
+  }
+  line {
+    stroke-width: 5px;
+  }
+  line.cable-shadow {
+    stroke: #303030;
+    stroke-width: 7px;
+  }
+  line.attachment {
+    stroke: #c0c0c0;
+    stroke-dasharray: 5px,5px;
+  }
+}

+ 0 - 41
netbox/project-static/styles/netbox.scss

@@ -824,47 +824,6 @@ table tbody {
   }
   }
 }
 }
 
 
-// Cable Tracing
-.cable-trace {
-  max-width: 38rem;
-  margin: 1rem auto;
-  text-align: center;
-}
-.cable-trace .node {
-  background-color: var(--nbx-cable-node-bg);
-  border: $border-width solid var(--nbx-cable-node-border-color);
-  border-radius: $border-radius;
-  padding: 1.5rem 1rem;
-  position: relative;
-  z-index: 1;
-}
-.cable-trace .termination {
-  background-color: var(--nbx-cable-termination-bg);
-  border: $border-width solid var(--nbx-cable-termination-border-color);
-  box-shadow: $box-shadow;
-  border-radius: $border-radius;
-  margin: -1rem auto;
-  padding: 0.5rem;
-  position: relative;
-  width: 60%;
-  z-index: 2;
-}
-.cable-trace .active {
-  border: 0.25rem solid $success;
-}
-.cable-trace .cable {
-  border-left-style: solid;
-  border-left-width: 0.25rem;
-  margin: 1rem 0 1rem 50%;
-  padding: 1.5rem;
-  text-align: left;
-  width: 50%;
-}
-.cable-trace .trace-end {
-  margin-top: 2rem;
-  text-align: center;
-}
-
 pre.change-data {
 pre.change-data {
   padding-left: 0;
   padding-left: 0;
   padding-right: 0;
   padding-right: 0;

+ 38 - 77
netbox/templates/dcim/cable_trace.html

@@ -1,89 +1,50 @@
 {% extends 'base/layout.html' %}
 {% extends 'base/layout.html' %}
 {% load helpers %}
 {% load helpers %}
 
 
-{% block header %}
-    <h1>{% block title %}Cable Trace for {{ object|meta:"verbose_name"|bettertitle }} {{ object }}{% endblock %}</h1>
-{% endblock %}
+{% block title %}Cable Trace for {{ object|meta:"verbose_name"|bettertitle }} {{ object }}{% endblock %}
 
 
 {% block content %}
 {% block content %}
     <div class="row">
     <div class="row">
         <div class="col col-md-5">
         <div class="col col-md-5">
+            <object data="{{ svg_url }}" class="rack_elevation"></object>
+            <div class="text-center mt-3">
+                <a class="btn btn-outline-primary btn-sm" href="{{ svg_url }}">
+                    <i class="mdi mdi-file-download"></i> Download SVG
+                </a>
+            </div>
             <div class="cable-trace">
             <div class="cable-trace">
                 {% with traced_path=path.origin.trace %}
                 {% with traced_path=path.origin.trace %}
-                    {% for near_end, cable, far_end in traced_path %}
-
-                        {# Near end #}
-                        {% if near_end.device %}
-                            {% include 'dcim/trace/device.html' with device=near_end.device %}
-                            {% include 'dcim/trace/termination.html' with termination=near_end %}
-                        {% elif near_end.power_panel %}
-                            {% include 'dcim/trace/powerpanel.html' with powerpanel=near_end.power_panel %}
-                            {% include 'dcim/trace/termination.html' with termination=far_end%}
-                        {% elif near_end.circuit %}
-                            {% include 'dcim/trace/circuit.html' with circuit=near_end.circuit %}
-                            {% include 'dcim/trace/termination.html' with termination=near_end %}
-                        {% endif %}
-
-                        {# Cable #}
-                        {% if cable %}
-                            {% include 'dcim/trace/cable.html' %}
-                        {% elif far_end %}
-                            {% include 'dcim/trace/attachment.html' %}
-                        {% endif %}
-
-                        {# Far end #}
-                        {% if far_end.device %}
-                            {% include 'dcim/trace/termination.html' with termination=far_end %}
-                            {% if forloop.last %}
-                                {% include 'dcim/trace/device.html' with device=far_end.device %}
-                            {% endif %}
-                        {% elif far_end.power_panel %}
-                            {% include 'dcim/trace/termination.html' with termination=far_end %}
-                            {% include 'dcim/trace/powerpanel.html' with powerpanel=far_end.power_panel %}
-                        {% elif far_end.circuit %}
-                            {% include 'dcim/trace/termination.html' with termination=far_end %}
-                            {% if forloop.last %}
-                                {% include 'dcim/trace/circuit.html' with circuit=far_end.circuit %}
-                            {% endif %}
-                        {% elif far_end %}
-                            {% include 'dcim/trace/object.html' with object=far_end %}
-                        {% endif %}
-
-                        {% if forloop.last %}
-                            {% 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-start">
-                                        {% 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" }}{% if not is_definitive %}+{% endif %} Meters /
-                                            {{ total_length|meters_to_feet|floatformat:"-2" }} Feet
-                                        {% else %}
-                                            <span class="text-muted">N/A</span>
-                                        {% endif %}
-                                    </h5>
-                                </div>
-                            {% endif %}
-                        {% endif %}
-
-                    {% endfor %}
+                    {% 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-start">
+                                {% 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 class="text-success">Trace Completed</h3>
+                            <h5>Total Segments: {{ traced_path|length }}</h5>
+                            <h5>Total Length:
+                                {% if total_length %}
+                                    {{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} Meters /
+                                    {{ total_length|meters_to_feet|floatformat:"-2" }} Feet
+                                {% else %}
+                                    <span class="text-muted">N/A</span>
+                                {% endif %}
+                            </h5>
+                        </div>
+                    {% endif %}
                 {% endwith %}
                 {% endwith %}
             </div>
             </div>
         </div>
         </div>

+ 7 - 4
netbox/utilities/utils.py

@@ -44,16 +44,19 @@ def csv_format(data):
     return ','.join(csv)
     return ','.join(csv)
 
 
 
 
-def foreground_color(bg_color):
+def foreground_color(bg_color, dark='000000', light='ffffff'):
     """
     """
-    Return the ideal foreground color (black or white) for a given background color in hexadecimal RGB format.
+    Return the ideal foreground color (dark or light) for a given background color in hexadecimal RGB format.
+
+    :param dark: RBG color code for dark text
+    :param light: RBG color code for light text
     """
     """
     bg_color = bg_color.strip('#')
     bg_color = bg_color.strip('#')
     r, g, b = [int(bg_color[c:c + 2], 16) for c in (0, 2, 4)]
     r, g, b = [int(bg_color[c:c + 2], 16) for c in (0, 2, 4)]
     if r * 0.299 + g * 0.587 + b * 0.114 > 186:
     if r * 0.299 + g * 0.587 + b * 0.114 > 186:
-        return '000000'
+        return dark
     else:
     else:
-        return 'ffffff'
+        return light
 
 
 
 
 def dynamic_import(name):
 def dynamic_import(name):

Некоторые файлы не были показаны из-за большого количества измененных файлов