cables.py 19 KB

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