Coverage for dcim/svg/cables.py: 12%
257 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import svgwrite
2from django.conf import settings
3from svgwrite.container import Group, Hyperlink
4from svgwrite.shapes import Line, Polyline, Rect
5from svgwrite.text import Text
7from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
8from utilities.html import foreground_color
10__all__ = (
11 'CableTraceSVG',
12)
14OFFSET = 0.5
15PADDING = 10
16LINE_HEIGHT = 20
17FANOUT_HEIGHT = 35
18FANOUT_LEG_HEIGHT = 15
19CABLE_HEIGHT = 5 * LINE_HEIGHT + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
22def _cable_side_nodes(term_nodes, cable_terminations):
23 """
24 Filter a list of termination nodes to those connected to the given side of a Cable. A channel subinterface does
25 not terminate the cable directly; it derives its connection from its (channelized) parent interface, which carries
26 the actual CableTermination, so it is matched via its parent.
27 """
28 def matches(obj):
29 if obj in cable_terminations:
30 return True
31 if getattr(obj, 'channel_id', None):
32 return obj.parent in cable_terminations
33 return False
35 return [node for node in term_nodes if matches(node.object)]
38class Node(Hyperlink):
39 """
40 Create a node to be represented in the SVG document as a rectangular box with a hyperlink.
42 Arguments:
43 position: (x, y) coordinates of the box's top left corner
44 width: Box width
45 url: Hyperlink URL
46 color: Box fill color (RRGGBB format)
47 labels: An iterable of text strings. Each label will render on a new line within the box.
48 radius: Box corner radius, for rounded corners (default: 10)
49 object: A copy of the object to allow reference when drawing cables to determine which cables are connected to
50 which terminations.
51 """
53 object = None
55 def __init__(self, position, width, url, color, labels, radius=10, object=object, **extra):
56 super().__init__(href=url, target='_parent', **extra)
58 # Save object for reference by cable systems
59 self.object = object
61 x, y = position
63 # Add the box
64 dimensions = (width - 2, PADDING + LINE_HEIGHT * len(labels) + PADDING)
65 box = Rect((x + OFFSET, y), dimensions, rx=radius, class_='parent-object', style=f'fill: #{color}')
66 self.add(box)
68 cursor = y + PADDING
70 # Add text label(s)
71 for i, label in enumerate(labels):
72 cursor += LINE_HEIGHT
73 text_coords = (x + width / 2, cursor - LINE_HEIGHT / 2)
74 text_color = f'#{foreground_color(color, dark="303030")}'
75 text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else [])
76 self.add(text)
78 @property
79 def box(self):
80 return self.elements[0] if self.elements else None
82 @property
83 def top_center(self):
84 return self.box['x'] + self.box['width'] / 2, self.box['y']
86 @property
87 def bottom_center(self):
88 return self.box['x'] + self.box['width'] / 2, self.box['y'] + self.box['height']
91class Connector(Group):
92 """
93 Return an SVG group containing a line element and text labels representing a Cable.
95 Arguments:
96 color: Cable (line) color
97 url: Hyperlink URL
98 labels: Iterable of text labels
99 """
101 def __init__(self, start, url, color, wireless, labels=[], description=[], end=None, text_offset=0, **extra):
102 super().__init__(class_="connector", **extra)
104 self.start = start
105 self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
106 # Allow to specify end-position or auto-calculate
107 self.end = end if end else (start[0], start[1] + self.height)
108 self.color = color or '000000'
110 if wireless:
111 # Draw the cable
112 cable = Line(start=self.start, end=self.end, class_="wireless-link")
113 self.add(cable)
114 else:
115 # Draw a "shadow" line to give the cable a border
116 cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow')
117 self.add(cable_shadow)
119 # Draw the cable
120 cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}')
121 self.add(cable)
123 # Add link
124 link = Hyperlink(href=url, target='_parent')
126 # Add text label(s)
127 cursor = start[1] + text_offset
128 cursor += PADDING * 2 + LINE_HEIGHT * 2
129 x_coord = (start[0] + end[0]) / 2 + PADDING
130 for i, label in enumerate(labels):
131 cursor += LINE_HEIGHT
132 text_coords = (x_coord, cursor - LINE_HEIGHT / 2)
133 text = Text(label, insert=text_coords, class_='bold' if not i else [])
134 link.add(text)
135 if len(description) > 0:
136 link.set_desc("\n".join(description))
138 self.add(link)
141class CableTraceSVG:
142 """
143 Generate a graphical representation of a CablePath in SVG format.
145 :param origin: The originating termination
146 :param width: Width of the generated image (in pixels)
147 :param base_url: Base URL for links within the SVG document. If none, links will be relative.
148 """
149 def __init__(self, origin, width=CABLE_TRACE_SVG_DEFAULT_WIDTH, base_url=None):
150 self.origin = origin
151 self.width = width
152 self.base_url = base_url.rstrip('/') if base_url is not None else ''
154 # Establish a cursor to track position on the y axis
155 # Center edges on pixels to render sharp borders
156 self.cursor = OFFSET
158 # Prep elements lists
159 self.parent_objects = []
160 self.terminations = []
161 self.connectors = []
163 @property
164 def center(self):
165 return self.width / 2
167 @classmethod
168 def _get_labels(cls, instance):
169 """
170 Return a list of text labels for the given instance based on model type.
171 """
172 labels = [str(instance)]
173 if instance._meta.model_name == 'device':
174 labels.append(f'{instance.device_type.manufacturer} {instance.device_type}')
175 location_label = f'{instance.site}'
176 if instance.location:
177 location_label += f' / {instance.location}'
178 if instance.rack:
179 location_label += f' / {instance.rack}'
180 if instance.position:
181 location_label += f' / {instance.get_face_display()}'
182 location_label += f' / U{instance.position}'
183 labels.append(location_label)
184 elif instance._meta.model_name == 'circuit':
185 labels[0] = f'Circuit {instance}'
186 labels.append(instance.type)
187 labels.append(instance.provider)
188 if instance.description:
189 labels.append(instance.description)
190 elif instance._meta.model_name == 'circuittermination':
191 if instance.xconnect_id:
192 labels.append(f'{instance.xconnect_id}')
193 elif instance._meta.model_name == 'providernetwork':
194 labels.append(instance.provider)
196 return labels
198 @classmethod
199 def _get_color(cls, instance):
200 """
201 Return the appropriate fill color for an object within a cable path.
202 """
203 if hasattr(instance, 'parent_object'):
204 # Termination
205 return getattr(instance, 'color', 'f0f0f0') or 'f0f0f0'
206 if hasattr(instance, 'role'):
207 # Device
208 return instance.role.color
209 if instance._meta.model_name == 'circuit' and instance.type.color:
210 return instance.type.color
211 # Other parent object
212 return 'e0e0e0'
214 def draw_parent_objects(self, obj_list):
215 """
216 Draw a set of parent objects (eg hosts, switched, patchpanels) and return all created nodes
217 """
218 objects = []
219 width = self.width / len(obj_list)
220 for i, obj in enumerate(obj_list):
221 node = Node(
222 position=(i * width, self.cursor),
223 width=width,
224 url=f'{self.base_url}{obj.get_absolute_url()}',
225 color=self._get_color(obj),
226 labels=self._get_labels(obj),
227 object=obj
228 )
229 objects.append(node)
230 self.parent_objects.append(node)
231 if i + 1 == len(obj_list):
232 self.cursor += node.box['height']
233 return objects
235 def draw_object_terminations(self, terminations, offset_x, width):
236 """
237 Draw all terminations belonging to an object with specified offset and width
238 Return all created nodes and their maximum height
239 """
240 nodes_height = 0
241 nodes = []
242 for i, term in enumerate(terminations):
243 node = Node(
244 position=(offset_x + i * width, self.cursor),
245 width=width,
246 url=f'{self.base_url}{term.get_absolute_url()}',
247 color=self._get_color(term),
248 labels=self._get_labels(term),
249 radius=5,
250 object=term
251 )
252 nodes_height = max(nodes_height, node.box['height'])
253 nodes.append(node)
254 return nodes, nodes_height
256 def draw_terminations(self, terminations, parent_object_nodes):
257 """
258 Draw a row of terminating objects (e.g. interfaces) and return all created nodes
259 Attach them to previously created parent objects
260 """
261 nodes = []
262 nodes_height = 0
264 # Draw terminations for each parent object
265 for parent in parent_object_nodes:
266 parent_terms = [term for term in terminations if term.parent_object == parent.object]
268 # Width and offset(position) for each termination box
269 width = parent.box['width'] / len(parent_terms)
270 offset_x = parent.box['x']
272 result, nodes_height = self.draw_object_terminations(parent_terms, offset_x, width)
273 nodes.extend(result)
275 self.cursor += nodes_height
276 self.terminations.extend(nodes)
278 return nodes
280 def draw_far_objects(self, obj_list, terminations):
281 """
282 Draw the far-end objects and its terminations and return all created nodes
283 """
284 # Make sure elements are sorted by name for readability
285 objects = sorted(obj_list, key=lambda x: str(x))
286 width = self.width / len(objects)
288 # Max-height of created terminations
289 terms_height = 0
290 term_nodes = []
292 # Draw the terminations by per object first
293 for i, obj in enumerate(objects):
294 obj_terms = [term for term in terminations if term.parent_object == obj]
295 obj_pos = i * width
296 result, result_nodes_height = self.draw_object_terminations(obj_terms, obj_pos, width / len(obj_terms))
298 terms_height = max(terms_height, result_nodes_height)
299 term_nodes.extend(result)
301 # Update cursor and draw the objects
302 self.cursor += terms_height
303 self.terminations.extend(term_nodes)
304 object_nodes = self.draw_parent_objects(objects)
306 return object_nodes, term_nodes
308 def draw_fanin(self, target, terminations, color):
309 """
310 Draw the fan-in-lines from each of the terminations to the targetpoint
311 """
312 for term in terminations:
313 points = (
314 term.bottom_center,
315 (term.bottom_center[0], term.bottom_center[1] + FANOUT_LEG_HEIGHT),
316 target,
317 )
318 self.connectors.extend((
319 Polyline(points=points, class_='cable-shadow'),
320 Polyline(points=points, style=f'stroke: #{color}'),
321 ))
323 def draw_fanout(self, start, terminations, color):
324 """
325 Draw the fan-out-lines from the startpoint to each of the terminations
326 """
327 for term in terminations:
328 points = (
329 term.top_center,
330 (term.top_center[0], term.top_center[1] - FANOUT_LEG_HEIGHT),
331 start,
332 )
333 self.connectors.extend((
334 Polyline(points=points, class_='cable-shadow'),
335 Polyline(points=points, style=f'stroke: #{color}'),
336 ))
338 def draw_attachment(self):
339 """
340 Return an SVG group containing a line element and "Attachment" label.
341 """
342 group = Group(class_='connector')
344 # Draw attachment (line)
345 start = (OFFSET + self.center, OFFSET + self.cursor)
346 end = (start[0], start[1] + CABLE_HEIGHT)
347 line = Line(start=start, end=end, class_='attachment')
348 group.add(line)
350 return group
352 def render(self):
353 """
354 Return an SVG document representing a cable trace.
355 """
356 from dcim.models import Cable
357 from wireless.models import WirelessLink
359 traced_path = self.origin.trace()
361 parent_object_nodes = []
362 # Iterate through each (terms, cable, terms) segment in the path
363 for i, segment in enumerate(traced_path):
364 near_ends, links, far_ends = segment
366 # This is segment number one.
367 if i == 0:
368 # If this is the first segment, draw the originating termination's parent object
369 parent_object_nodes = self.draw_parent_objects(set(end.parent_object for end in near_ends))
370 # Else: No need to draw parent objects (parent objects are drawn in last "round" as the far-end!)
372 near_terminations = self.draw_terminations(near_ends, parent_object_nodes)
374 # Connector (a Cable or WirelessLink)
375 if links and far_ends:
376 self.cursor += CABLE_HEIGHT
378 obj_list = {end.parent_object for end in far_ends}
379 parent_object_nodes, far_terminations = self.draw_far_objects(obj_list, far_ends)
380 for cable in links:
381 # Fill in labels and description with all available data
382 description = [
383 f"Link {cable}",
384 cable.get_status_display()
385 ]
386 near = []
387 far = []
388 color = '000000'
389 if cable.description:
390 description.append(f"{cable.description}")
391 if isinstance(cable, Cable):
392 labels = [f"{cable}"] if len(links) > 2 else [f"Cable {cable}", cable.get_status_display()]
393 if cable.type:
394 description.append(cable.get_type_display())
395 if cable.length and cable.length_unit:
396 description.append(f"{cable.length} {cable.get_length_unit_display()}")
397 color = cable.color or '000000'
399 # Collect all connected nodes to this cable. Channel subinterfaces are matched via their
400 # parent interface, which carries the actual cable termination.
401 near = _cable_side_nodes(near_terminations, cable.a_terminations)
402 far = _cable_side_nodes(far_terminations, cable.b_terminations)
403 if not (near and far):
404 # a and b terminations may be swapped
405 near = _cable_side_nodes(near_terminations, cable.b_terminations)
406 far = _cable_side_nodes(far_terminations, cable.a_terminations)
407 elif isinstance(cable, WirelessLink):
408 labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()]
409 if cable.ssid:
410 description.append(f"{cable.ssid}")
411 if cable.distance and cable.distance_unit:
412 description.append(f"{cable.distance} {cable.get_distance_unit_display()}")
413 near = [term for term in near_terminations if term.object == cable.interface_a]
414 far = [term for term in far_terminations if term.object == cable.interface_b]
415 if not (near and far):
416 # a and b terminations may be swapped
417 near = [term for term in near_terminations if term.object == cable.interface_b]
418 far = [term for term in far_terminations if term.object == cable.interface_a]
420 # Select most-probable start and end position
421 start = near[0].bottom_center
422 end = far[0].top_center
423 text_offset = 0
425 if len(near) > 1 and len(far) > 1:
426 start_center = sum([pos.bottom_center[0] for pos in near]) / len(near)
427 end_center = sum([pos.bottom_center[0] for pos in far]) / len(far)
428 center_x = (start_center + end_center) / 2
430 start = (center_x, start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT)
431 end = (center_x, end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT)
432 text_offset -= (FANOUT_HEIGHT + FANOUT_LEG_HEIGHT)
433 self.draw_fanin(start, near, color)
434 self.draw_fanout(end, far, color)
435 elif len(near) > 1:
436 # Handle Fan-In - change start position to be directly below start
437 start = (end[0], start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT)
438 self.draw_fanin(start, near, color)
439 text_offset -= FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
440 elif len(far) > 1:
441 # Handle Fan-Out - change end position to be directly above end
442 end = (start[0], end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT)
443 self.draw_fanout(end, far, color)
444 text_offset -= FANOUT_HEIGHT
446 # Create the connector
447 connector = Connector(
448 start=start,
449 end=end,
450 color=color,
451 wireless=isinstance(cable, WirelessLink),
452 url=f'{self.base_url}{cable.get_absolute_url()}',
453 text_offset=text_offset,
454 labels=labels,
455 description=description
456 )
457 self.connectors.append(connector)
459 # Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with
460 # a CircuitTermination)
461 elif far_ends:
462 # Attachment
463 attachment = self.draw_attachment()
464 self.connectors.append(attachment)
465 self.cursor += CABLE_HEIGHT
467 # Object
468 parent_object_nodes = self.draw_parent_objects(far_ends)
470 # Determine drawing size
471 self.drawing = svgwrite.Drawing(
472 size=(self.width, self.cursor + 2)
473 )
475 # Attach CSS stylesheet
476 with open(f'{settings.STATIC_ROOT}/cable_trace.css') as css_file:
477 self.drawing.defs.add(self.drawing.style(css_file.read()))
479 # Add elements to the drawing in order of depth (Z axis)
480 for element in self.connectors + self.parent_objects + self.terminations:
481 self.drawing.add(element)
483 return self.drawing