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

Closes #6000: SVG rendering for cable tracing
This commit is contained in:
Jeremy Stretch 2021-07-16 16:56:17 -04:00 committed by GitHub
commit 2bfdaf08ee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 616 additions and 366 deletions

View File

@ -2,18 +2,15 @@ import socket
from collections import OrderedDict
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.shortcuts import get_object_or_404
from drf_yasg import openapi
from drf_yasg.openapi import Parameter
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.mixins import ListModelMixin
from rest_framework.response import Response
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 dcim import filtersets
@ -53,6 +50,13 @@ class PathEndpointMixin(object):
# Initialize the path array
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():
if near_end is None:
# Split paths

View File

@ -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

View File

@ -10,6 +10,7 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
from dcim.constants import *
from dcim.fields import MACAddressField
from dcim.svg import CableTraceSVG
from extras.utils import extras_features
from netbox.models import PrimaryModel
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 list(zip(*[iter(path)] * 3))
def get_trace_svg(self, base_url=None):
trace = CableTraceSVG(self, base_url=base_url)
return trace.render()
@property
def path(self):
return self._path

View File

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

506
netbox/dcim/svg.py Normal file
View File

@ -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

View File

@ -1,15 +1,14 @@
import logging
from copy import deepcopy
from collections import OrderedDict
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db import transaction
from django.db.models import F, Prefetch
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
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.permissions import get_permission_for_model
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 virtualization.models import VirtualMachine
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)
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 {
'path': path,
'related_paths': related_paths,
'total_length': total_length,
'is_definitive': is_definitive
'is_definitive': is_definitive,
'svg_url': svg_url,
}

View File

@ -31,6 +31,7 @@ const styles = [
['styles/_light.scss', 'netbox-light.css'],
['styles/_dark.scss', 'netbox-dark.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

Binary file not shown.

View File

@ -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"]}

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -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;
}
}

View File

@ -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 {
padding-left: 0;
padding-right: 0;

View File

@ -1,89 +1,50 @@
{% extends 'base/layout.html' %}
{% 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 %}
<div class="row">
<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">
{% 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 %}
</div>
</div>

View File

@ -44,16 +44,19 @@ def csv_format(data):
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('#')
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:
return '000000'
return dark
else:
return 'ffffff'
return light
def dynamic_import(name):