cables.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  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.utils 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 = 4 * 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. labels.append(location_label)
  132. elif instance._meta.model_name == 'circuit':
  133. labels[0] = f'Circuit {instance}'
  134. labels.append(instance.type)
  135. labels.append(instance.provider)
  136. if instance.description:
  137. labels.append(instance.description)
  138. elif instance._meta.model_name == 'circuittermination':
  139. if instance.xconnect_id:
  140. labels.append(f'{instance.xconnect_id}')
  141. elif instance._meta.model_name == 'providernetwork':
  142. labels.append(instance.provider)
  143. return labels
  144. @classmethod
  145. def _get_color(cls, instance):
  146. """
  147. Return the appropriate fill color for an object within a cable path.
  148. """
  149. if hasattr(instance, 'parent_object'):
  150. # Termination
  151. return getattr(instance, 'color', 'f0f0f0') or 'f0f0f0'
  152. if hasattr(instance, 'role'):
  153. # Device
  154. return instance.role.color
  155. elif instance._meta.model_name == 'circuit' and instance.type.color:
  156. return instance.type.color
  157. else:
  158. # Other parent object
  159. return 'e0e0e0'
  160. def draw_parent_objects(self, obj_list):
  161. """
  162. Draw a set of parent objects (eg hosts, switched, patchpanels) and return all created nodes
  163. """
  164. objects = []
  165. width = self.width / len(obj_list)
  166. for i, obj in enumerate(obj_list):
  167. node = Node(
  168. position=(i * width, self.cursor),
  169. width=width,
  170. url=f'{self.base_url}{obj.get_absolute_url()}',
  171. color=self._get_color(obj),
  172. labels=self._get_labels(obj),
  173. object=obj
  174. )
  175. objects.append(node)
  176. self.parent_objects.append(node)
  177. if i + 1 == len(obj_list):
  178. self.cursor += node.box['height']
  179. return objects
  180. def draw_object_terminations(self, terminations, offset_x, width):
  181. """
  182. Draw all terminations belonging to an object with specified offset and width
  183. Return all created nodes and their maximum height
  184. """
  185. nodes_height = 0
  186. nodes = []
  187. # Sort them by name to make renders more readable
  188. for i, term in enumerate(sorted(terminations, key=lambda x: x.name)):
  189. node = Node(
  190. position=(offset_x + i * width, self.cursor),
  191. width=width,
  192. url=f'{self.base_url}{term.get_absolute_url()}',
  193. color=self._get_color(term),
  194. labels=self._get_labels(term),
  195. radius=5,
  196. object=term
  197. )
  198. nodes_height = max(nodes_height, node.box['height'])
  199. nodes.append(node)
  200. return nodes, nodes_height
  201. def draw_terminations(self, terminations, parent_object_nodes):
  202. """
  203. Draw a row of terminating objects (e.g. interfaces) and return all created nodes
  204. Attach them to previously created parent objects
  205. """
  206. nodes = []
  207. nodes_height = 0
  208. # Draw terminations for each parent object
  209. for parent in parent_object_nodes:
  210. parent_terms = [term for term in terminations if term.parent_object == parent.object]
  211. # Width and offset(position) for each termination box
  212. width = parent.box['width'] / len(parent_terms)
  213. offset_x = parent.box['x']
  214. result, nodes_height = self.draw_object_terminations(parent_terms, offset_x, width)
  215. nodes.extend(result)
  216. self.cursor += nodes_height
  217. self.terminations.extend(nodes)
  218. return nodes
  219. def draw_far_objects(self, obj_list, terminations):
  220. """
  221. Draw the far-end objects and its terminations and return all created nodes
  222. """
  223. # Make sure elements are sorted by name for readability
  224. objects = sorted(obj_list, key=lambda x: x.name)
  225. width = self.width / len(objects)
  226. # Max-height of created terminations
  227. terms_height = 0
  228. term_nodes = []
  229. # Draw the terminations by per object first
  230. for i, obj in enumerate(objects):
  231. obj_terms = [term for term in terminations if term.parent_object == obj]
  232. obj_pos = i * width
  233. result, result_nodes_height = self.draw_object_terminations(obj_terms, obj_pos, width / len(obj_terms))
  234. terms_height = max(terms_height, result_nodes_height)
  235. term_nodes.extend(result)
  236. # Update cursor and draw the objects
  237. self.cursor += terms_height
  238. self.terminations.extend(term_nodes)
  239. object_nodes = self.draw_parent_objects(objects)
  240. return object_nodes, term_nodes
  241. def draw_fanin(self, target, terminations, color):
  242. """
  243. Draw the fan-in-lines from each of the terminations to the targetpoint
  244. """
  245. for term in terminations:
  246. points = (
  247. term.bottom_center,
  248. (term.bottom_center[0], term.bottom_center[1] + FANOUT_LEG_HEIGHT),
  249. target,
  250. )
  251. self.connectors.extend((
  252. Polyline(points=points, class_='cable-shadow'),
  253. Polyline(points=points, style=f'stroke: #{color}'),
  254. ))
  255. def draw_fanout(self, start, terminations, color):
  256. """
  257. Draw the fan-out-lines from the startpoint to each of the terminations
  258. """
  259. for term in terminations:
  260. points = (
  261. term.top_center,
  262. (term.top_center[0], term.top_center[1] - FANOUT_LEG_HEIGHT),
  263. start,
  264. )
  265. self.connectors.extend((
  266. Polyline(points=points, class_='cable-shadow'),
  267. Polyline(points=points, style=f'stroke: #{color}'),
  268. ))
  269. def draw_attachment(self):
  270. """
  271. Return an SVG group containing a line element and "Attachment" label.
  272. """
  273. group = Group(class_='connector')
  274. # Draw attachment (line)
  275. start = (OFFSET + self.center, OFFSET + self.cursor)
  276. height = PADDING * 2 + LINE_HEIGHT + PADDING * 2
  277. end = (start[0], start[1] + height)
  278. line = Line(start=start, end=end, class_='attachment')
  279. group.add(line)
  280. self.cursor += PADDING * 4
  281. return group
  282. def render(self):
  283. """
  284. Return an SVG document representing a cable trace.
  285. """
  286. from dcim.models import Cable
  287. from wireless.models import WirelessLink
  288. traced_path = self.origin.trace()
  289. parent_object_nodes = []
  290. # Iterate through each (terms, cable, terms) segment in the path
  291. for i, segment in enumerate(traced_path):
  292. near_ends, links, far_ends = segment
  293. # This is segment number one.
  294. if i == 0:
  295. # If this is the first segment, draw the originating termination's parent object
  296. parent_object_nodes = self.draw_parent_objects(set(end.parent_object for end in near_ends))
  297. # Else: No need to draw parent objects (parent objects are drawn in last "round" as the far-end!)
  298. near_terminations = self.draw_terminations(near_ends, parent_object_nodes)
  299. self.cursor += CABLE_HEIGHT
  300. # Connector (a Cable or WirelessLink)
  301. if links:
  302. parent_object_nodes, far_terminations = self.draw_far_objects(set(end.parent_object for end in far_ends), far_ends)
  303. for cable in links:
  304. # Fill in labels and description with all available data
  305. description = [
  306. f"Link {cable}",
  307. cable.get_status_display()
  308. ]
  309. near = []
  310. far = []
  311. color = '000000'
  312. if cable.description:
  313. description.append(f"{cable.description}")
  314. if isinstance(cable, Cable):
  315. labels = [f"{cable}"] if len(links) > 2 else [f"Cable {cable}", cable.get_status_display()]
  316. if cable.type:
  317. description.append(cable.get_type_display())
  318. if cable.length and cable.length_unit:
  319. description.append(f"{cable.length} {cable.get_length_unit_display()}")
  320. color = cable.color or '000000'
  321. # Collect all connected nodes to this cable
  322. near = [term for term in near_terminations if term.object in cable.a_terminations]
  323. far = [term for term in far_terminations if term.object in cable.b_terminations]
  324. if not (near and far):
  325. # a and b terminations may be swapped
  326. near = [term for term in near_terminations if term.object in cable.b_terminations]
  327. far = [term for term in far_terminations if term.object in cable.a_terminations]
  328. elif isinstance(cable, WirelessLink):
  329. labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()]
  330. if cable.ssid:
  331. description.append(f"{cable.ssid}")
  332. near = [term for term in near_terminations if term.object == cable.interface_a]
  333. far = [term for term in far_terminations if term.object == cable.interface_b]
  334. if not (near and far):
  335. # a and b terminations may be swapped
  336. near = [term for term in near_terminations if term.object == cable.interface_b]
  337. far = [term for term in far_terminations if term.object == cable.interface_a]
  338. # Select most-probable start and end position
  339. start = near[0].bottom_center
  340. end = far[0].top_center
  341. text_offset = 0
  342. if len(near) > 1:
  343. # Handle Fan-In - change start position to be directly below start
  344. start = (end[0], start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT)
  345. self.draw_fanin(start, near, color)
  346. text_offset -= FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
  347. elif len(far) > 1:
  348. # Handle Fan-Out - change end position to be directly above end
  349. end = (start[0], end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT)
  350. self.draw_fanout(end, far, color)
  351. text_offset -= FANOUT_HEIGHT
  352. # Create the connector
  353. connector = Connector(
  354. start=start,
  355. end=end,
  356. color=color,
  357. wireless=isinstance(cable, WirelessLink),
  358. url=f'{self.base_url}{cable.get_absolute_url()}',
  359. text_offset=text_offset,
  360. labels=labels,
  361. description=description
  362. )
  363. self.connectors.append(connector)
  364. # Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with
  365. # a CircuitTermination)
  366. elif far_ends:
  367. # Attachment
  368. attachment = self.draw_attachment()
  369. self.connectors.append(attachment)
  370. # Object
  371. parent_object_nodes = self.draw_parent_objects(far_ends)
  372. # Determine drawing size
  373. self.drawing = svgwrite.Drawing(
  374. size=(self.width, self.cursor + 2)
  375. )
  376. # Attach CSS stylesheet
  377. with open(f'{settings.STATIC_ROOT}/cable_trace.css') as css_file:
  378. self.drawing.defs.add(self.drawing.style(css_file.read()))
  379. # Add elements to the drawing in order of depth (Z axis)
  380. for element in self.connectors + self.parent_objects + self.terminations:
  381. self.drawing.add(element)
  382. return self.drawing