cables.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  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. for i, term in enumerate(terminations):
  191. node = Node(
  192. position=(offset_x + i * width, self.cursor),
  193. width=width,
  194. url=f'{self.base_url}{term.get_absolute_url()}',
  195. color=self._get_color(term),
  196. labels=self._get_labels(term),
  197. radius=5,
  198. object=term
  199. )
  200. nodes_height = max(nodes_height, node.box['height'])
  201. nodes.append(node)
  202. return nodes, nodes_height
  203. def draw_terminations(self, terminations, parent_object_nodes):
  204. """
  205. Draw a row of terminating objects (e.g. interfaces) and return all created nodes
  206. Attach them to previously created parent objects
  207. """
  208. nodes = []
  209. nodes_height = 0
  210. # Draw terminations for each parent object
  211. for parent in parent_object_nodes:
  212. parent_terms = [term for term in terminations if term.parent_object == parent.object]
  213. # Width and offset(position) for each termination box
  214. width = parent.box['width'] / len(parent_terms)
  215. offset_x = parent.box['x']
  216. result, nodes_height = self.draw_object_terminations(parent_terms, offset_x, width)
  217. nodes.extend(result)
  218. self.cursor += nodes_height
  219. self.terminations.extend(nodes)
  220. return nodes
  221. def draw_far_objects(self, obj_list, terminations):
  222. """
  223. Draw the far-end objects and its terminations and return all created nodes
  224. """
  225. # Make sure elements are sorted by name for readability
  226. objects = sorted(obj_list, key=lambda x: str(x))
  227. width = self.width / len(objects)
  228. # Max-height of created terminations
  229. terms_height = 0
  230. term_nodes = []
  231. # Draw the terminations by per object first
  232. for i, obj in enumerate(objects):
  233. obj_terms = [term for term in terminations if term.parent_object == obj]
  234. obj_pos = i * width
  235. result, result_nodes_height = self.draw_object_terminations(obj_terms, obj_pos, width / len(obj_terms))
  236. terms_height = max(terms_height, result_nodes_height)
  237. term_nodes.extend(result)
  238. # Update cursor and draw the objects
  239. self.cursor += terms_height
  240. self.terminations.extend(term_nodes)
  241. object_nodes = self.draw_parent_objects(objects)
  242. return object_nodes, term_nodes
  243. def draw_fanin(self, target, terminations, color):
  244. """
  245. Draw the fan-in-lines from each of the terminations to the targetpoint
  246. """
  247. for term in terminations:
  248. points = (
  249. term.bottom_center,
  250. (term.bottom_center[0], term.bottom_center[1] + FANOUT_LEG_HEIGHT),
  251. target,
  252. )
  253. self.connectors.extend((
  254. Polyline(points=points, class_='cable-shadow'),
  255. Polyline(points=points, style=f'stroke: #{color}'),
  256. ))
  257. def draw_fanout(self, start, terminations, color):
  258. """
  259. Draw the fan-out-lines from the startpoint to each of the terminations
  260. """
  261. for term in terminations:
  262. points = (
  263. term.top_center,
  264. (term.top_center[0], term.top_center[1] - FANOUT_LEG_HEIGHT),
  265. start,
  266. )
  267. self.connectors.extend((
  268. Polyline(points=points, class_='cable-shadow'),
  269. Polyline(points=points, style=f'stroke: #{color}'),
  270. ))
  271. def draw_attachment(self):
  272. """
  273. Return an SVG group containing a line element and "Attachment" label.
  274. """
  275. group = Group(class_='connector')
  276. # Draw attachment (line)
  277. start = (OFFSET + self.center, OFFSET + self.cursor)
  278. height = PADDING * 2 + LINE_HEIGHT + PADDING * 2
  279. end = (start[0], start[1] + height)
  280. line = Line(start=start, end=end, class_='attachment')
  281. group.add(line)
  282. self.cursor += PADDING * 4
  283. return group
  284. def render(self):
  285. """
  286. Return an SVG document representing a cable trace.
  287. """
  288. from dcim.models import Cable
  289. from wireless.models import WirelessLink
  290. traced_path = self.origin.trace()
  291. parent_object_nodes = []
  292. # Iterate through each (terms, cable, terms) segment in the path
  293. for i, segment in enumerate(traced_path):
  294. near_ends, links, far_ends = segment
  295. # This is segment number one.
  296. if i == 0:
  297. # If this is the first segment, draw the originating termination's parent object
  298. parent_object_nodes = self.draw_parent_objects(set(end.parent_object for end in near_ends))
  299. # Else: No need to draw parent objects (parent objects are drawn in last "round" as the far-end!)
  300. near_terminations = self.draw_terminations(near_ends, parent_object_nodes)
  301. self.cursor += CABLE_HEIGHT
  302. # Connector (a Cable or WirelessLink)
  303. if links and far_ends:
  304. obj_list = {end.parent_object for end in far_ends}
  305. parent_object_nodes, far_terminations = self.draw_far_objects(obj_list, far_ends)
  306. for cable in links:
  307. # Fill in labels and description with all available data
  308. description = [
  309. f"Link {cable}",
  310. cable.get_status_display()
  311. ]
  312. near = []
  313. far = []
  314. color = '000000'
  315. if cable.description:
  316. description.append(f"{cable.description}")
  317. if isinstance(cable, Cable):
  318. labels = [f"{cable}"] if len(links) > 2 else [f"Cable {cable}", cable.get_status_display()]
  319. if cable.type:
  320. description.append(cable.get_type_display())
  321. if cable.length and cable.length_unit:
  322. description.append(f"{cable.length} {cable.get_length_unit_display()}")
  323. color = cable.color or '000000'
  324. # Collect all connected nodes to this cable
  325. near = [term for term in near_terminations if term.object in cable.a_terminations]
  326. far = [term for term in far_terminations if term.object in cable.b_terminations]
  327. if not (near and far):
  328. # a and b terminations may be swapped
  329. near = [term for term in near_terminations if term.object in cable.b_terminations]
  330. far = [term for term in far_terminations if term.object in cable.a_terminations]
  331. elif isinstance(cable, WirelessLink):
  332. labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()]
  333. if cable.ssid:
  334. description.append(f"{cable.ssid}")
  335. if cable.distance and cable.distance_unit:
  336. description.append(f"{cable.distance} {cable.get_distance_unit_display()}")
  337. near = [term for term in near_terminations if term.object == cable.interface_a]
  338. far = [term for term in far_terminations if term.object == cable.interface_b]
  339. if not (near and far):
  340. # a and b terminations may be swapped
  341. near = [term for term in near_terminations if term.object == cable.interface_b]
  342. far = [term for term in far_terminations if term.object == cable.interface_a]
  343. # Select most-probable start and end position
  344. start = near[0].bottom_center
  345. end = far[0].top_center
  346. text_offset = 0
  347. if len(near) > 1 and len(far) > 1:
  348. start_center = sum([pos.bottom_center[0] for pos in near]) / len(near)
  349. end_center = sum([pos.bottom_center[0] for pos in far]) / len(far)
  350. center_x = (start_center + end_center) / 2
  351. start = (center_x, start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT)
  352. end = (center_x, end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT)
  353. text_offset -= (FANOUT_HEIGHT + FANOUT_LEG_HEIGHT)
  354. self.draw_fanin(start, near, color)
  355. self.draw_fanout(end, far, color)
  356. elif len(near) > 1:
  357. # Handle Fan-In - change start position to be directly below start
  358. start = (end[0], start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT)
  359. self.draw_fanin(start, near, color)
  360. text_offset -= FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
  361. elif len(far) > 1:
  362. # Handle Fan-Out - change end position to be directly above end
  363. end = (start[0], end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT)
  364. self.draw_fanout(end, far, color)
  365. text_offset -= FANOUT_HEIGHT
  366. # Create the connector
  367. connector = Connector(
  368. start=start,
  369. end=end,
  370. color=color,
  371. wireless=isinstance(cable, WirelessLink),
  372. url=f'{self.base_url}{cable.get_absolute_url()}',
  373. text_offset=text_offset,
  374. labels=labels,
  375. description=description
  376. )
  377. self.connectors.append(connector)
  378. # Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with
  379. # a CircuitTermination)
  380. elif far_ends:
  381. # Attachment
  382. attachment = self.draw_attachment()
  383. self.connectors.append(attachment)
  384. # Object
  385. parent_object_nodes = self.draw_parent_objects(far_ends)
  386. # Determine drawing size
  387. self.drawing = svgwrite.Drawing(
  388. size=(self.width, self.cursor + 2)
  389. )
  390. # Attach CSS stylesheet
  391. with open(f'{settings.STATIC_ROOT}/cable_trace.css') as css_file:
  392. self.drawing.defs.add(self.drawing.style(css_file.read()))
  393. # Add elements to the drawing in order of depth (Z axis)
  394. for element in self.connectors + self.parent_objects + self.terminations:
  395. self.drawing.add(element)
  396. return self.drawing