cables.py 17 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.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. class Node(Hyperlink):
  17. """
  18. Create a node to be represented in the SVG document as a rectangular box with a hyperlink.
  19. Arguments:
  20. position: (x, y) coordinates of the box's top left corner
  21. width: Box width
  22. url: Hyperlink URL
  23. color: Box fill color (RRGGBB format)
  24. labels: An iterable of text strings. Each label will render on a new line within the box.
  25. radius: Box corner radius, for rounded corners (default: 10)
  26. object: A copy of the object to allow reference when drawing cables to determine which cables are connected to
  27. which terminations.
  28. """
  29. object = None
  30. def __init__(self, position, width, url, color, labels, radius=10, object=object, **extra):
  31. super(Node, self).__init__(href=url, target='_parent', **extra)
  32. # Save object for reference by cable systems
  33. self.object = object
  34. x, y = position
  35. # Add the box
  36. dimensions = (width - 2, PADDING + LINE_HEIGHT * len(labels) + PADDING)
  37. box = Rect((x + OFFSET, y), dimensions, rx=radius, class_='parent-object', style=f'fill: #{color}')
  38. self.add(box)
  39. cursor = y + PADDING
  40. # Add text label(s)
  41. for i, label in enumerate(labels):
  42. cursor += LINE_HEIGHT
  43. text_coords = (x + width / 2, cursor - LINE_HEIGHT / 2)
  44. text_color = f'#{foreground_color(color, dark="303030")}'
  45. text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else [])
  46. self.add(text)
  47. @property
  48. def box(self):
  49. return self.elements[0] if self.elements else None
  50. @property
  51. def top_center(self):
  52. return self.box['x'] + self.box['width'] / 2, self.box['y']
  53. @property
  54. def bottom_center(self):
  55. return self.box['x'] + self.box['width'] / 2, self.box['y'] + self.box['height']
  56. class Connector(Group):
  57. """
  58. Return an SVG group containing a line element and text labels representing a Cable.
  59. Arguments:
  60. color: Cable (line) color
  61. url: Hyperlink URL
  62. labels: Iterable of text labels
  63. """
  64. def __init__(self, start, url, color, labels=[], description=[], **extra):
  65. super().__init__(class_='connector', **extra)
  66. self.start = start
  67. self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
  68. self.end = (start[0], start[1] + self.height)
  69. self.color = color or '000000'
  70. # Draw a "shadow" line to give the cable a border
  71. cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow')
  72. self.add(cable_shadow)
  73. # Draw the cable
  74. cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}')
  75. self.add(cable)
  76. # Add link
  77. link = Hyperlink(href=url, target='_parent')
  78. # Add text label(s)
  79. cursor = start[1]
  80. cursor += PADDING * 2
  81. for i, label in enumerate(labels):
  82. cursor += LINE_HEIGHT
  83. text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
  84. text = Text(label, insert=text_coords, class_='bold' if not i else [])
  85. link.add(text)
  86. if len(description) > 0:
  87. link.set_desc("\n".join(description))
  88. self.add(link)
  89. class CableTraceSVG:
  90. """
  91. Generate a graphical representation of a CablePath in SVG format.
  92. :param origin: The originating termination
  93. :param width: Width of the generated image (in pixels)
  94. :param base_url: Base URL for links within the SVG document. If none, links will be relative.
  95. """
  96. def __init__(self, origin, width=CABLE_TRACE_SVG_DEFAULT_WIDTH, base_url=None):
  97. self.origin = origin
  98. self.width = width
  99. self.base_url = base_url.rstrip('/') if base_url is not None else ''
  100. # Establish a cursor to track position on the y axis
  101. # Center edges on pixels to render sharp borders
  102. self.cursor = OFFSET
  103. # Prep elements lists
  104. self.parent_objects = []
  105. self.terminations = []
  106. self.connectors = []
  107. @property
  108. def center(self):
  109. return self.width / 2
  110. @classmethod
  111. def _get_labels(cls, instance):
  112. """
  113. Return a list of text labels for the given instance based on model type.
  114. """
  115. labels = [str(instance)]
  116. if instance._meta.model_name == 'device':
  117. labels.append(f'{instance.device_type.manufacturer} {instance.device_type}')
  118. location_label = f'{instance.site}'
  119. if instance.location:
  120. location_label += f' / {instance.location}'
  121. if instance.rack:
  122. location_label += f' / {instance.rack}'
  123. labels.append(location_label)
  124. elif instance._meta.model_name == 'circuit':
  125. labels[0] = f'Circuit {instance}'
  126. labels.append(instance.provider)
  127. elif instance._meta.model_name == 'circuittermination':
  128. if instance.xconnect_id:
  129. labels.append(f'{instance.xconnect_id}')
  130. elif instance._meta.model_name == 'providernetwork':
  131. labels.append(instance.provider)
  132. return labels
  133. @classmethod
  134. def _get_color(cls, instance):
  135. """
  136. Return the appropriate fill color for an object within a cable path.
  137. """
  138. if hasattr(instance, 'parent_object'):
  139. # Termination
  140. return getattr(instance, 'color', 'f0f0f0') or 'f0f0f0'
  141. if hasattr(instance, 'role'):
  142. # Device
  143. return instance.role.color
  144. else:
  145. # Other parent object
  146. return 'e0e0e0'
  147. def draw_parent_objects(self, obj_list):
  148. """
  149. Draw a set of parent objects.
  150. """
  151. width = self.width / len(obj_list)
  152. for i, obj in enumerate(obj_list):
  153. node = Node(
  154. position=(i * width, self.cursor),
  155. width=width,
  156. url=f'{self.base_url}{obj.get_absolute_url()}',
  157. color=self._get_color(obj),
  158. labels=self._get_labels(obj)
  159. )
  160. self.parent_objects.append(node)
  161. if i + 1 == len(obj_list):
  162. self.cursor += node.box['height']
  163. def draw_terminations(self, terminations):
  164. """
  165. Draw a row of terminating objects (e.g. interfaces), all of which are attached to the same end of a cable.
  166. """
  167. nodes = []
  168. nodes_height = 0
  169. width = self.width / len(terminations)
  170. for i, term in enumerate(terminations):
  171. node = Node(
  172. position=(i * width, self.cursor),
  173. width=width,
  174. url=f'{self.base_url}{term.get_absolute_url()}',
  175. color=self._get_color(term),
  176. labels=self._get_labels(term),
  177. radius=5,
  178. object=term
  179. )
  180. nodes_height = max(nodes_height, node.box['height'])
  181. nodes.append(node)
  182. self.cursor += nodes_height
  183. self.terminations.extend(nodes)
  184. return nodes
  185. def draw_fanin(self, node, connector):
  186. points = (
  187. node.bottom_center,
  188. (node.bottom_center[0], node.bottom_center[1] + FANOUT_LEG_HEIGHT),
  189. connector.start,
  190. )
  191. self.connectors.extend((
  192. Polyline(points=points, class_='cable-shadow'),
  193. Polyline(points=points, style=f'stroke: #{connector.color}'),
  194. ))
  195. def draw_fanout(self, node, connector):
  196. points = (
  197. connector.end,
  198. (node.top_center[0], node.top_center[1] - FANOUT_LEG_HEIGHT),
  199. node.top_center,
  200. )
  201. self.connectors.extend((
  202. Polyline(points=points, class_='cable-shadow'),
  203. Polyline(points=points, style=f'stroke: #{connector.color}'),
  204. ))
  205. def draw_cable(self, cable, terminations, cable_count=0):
  206. """
  207. Draw a single cable. Terminations and cable count are passed for determining position and padding
  208. :param cable: The cable to draw
  209. :param terminations: List of terminations to build positioning data off of
  210. :param cable_count: Count of all cables on this layer for determining whether to collapse description into a
  211. tooltip.
  212. """
  213. # If the cable count is higher than 2, collapse the description into a tooltip
  214. if cable_count > 2:
  215. # Use the cable __str__ function to denote the cable
  216. labels = [f'{cable}']
  217. # Include the label and the status description in the tooltip
  218. description = [
  219. f'Cable {cable}',
  220. cable.get_status_display()
  221. ]
  222. if cable.type:
  223. # Include the cable type in the tooltip
  224. description.append(cable.get_type_display())
  225. if cable.length and cable.length_unit:
  226. # Include the cable length in the tooltip
  227. description.append(f'{cable.length} {cable.get_length_unit_display()}')
  228. else:
  229. labels = [
  230. f'Cable {cable}',
  231. cable.get_status_display()
  232. ]
  233. description = []
  234. if cable.type:
  235. labels.append(cable.get_type_display())
  236. if cable.length and cable.length_unit:
  237. # Include the cable length in the tooltip
  238. labels.append(f'{cable.length} {cable.get_length_unit_display()}')
  239. # If there is only one termination, center on that termination
  240. # Otherwise average the center across the terminations
  241. if len(terminations) == 1:
  242. center = terminations[0].bottom_center[0]
  243. else:
  244. # Get a list of termination centers
  245. termination_centers = [term.bottom_center[0] for term in terminations]
  246. # Average the centers
  247. center = sum(termination_centers) / len(termination_centers)
  248. # Create the connector
  249. connector = Connector(
  250. start=(center, self.cursor),
  251. color=cable.color or '000000',
  252. url=f'{self.base_url}{cable.get_absolute_url()}',
  253. labels=labels,
  254. description=description
  255. )
  256. # Set the cursor position
  257. self.cursor += connector.height
  258. return connector
  259. def draw_wirelesslink(self, wirelesslink):
  260. """
  261. Draw a line with labels representing a WirelessLink.
  262. """
  263. group = Group(class_='connector')
  264. labels = [
  265. f'Wireless link {wirelesslink}',
  266. wirelesslink.get_status_display()
  267. ]
  268. if wirelesslink.ssid:
  269. labels.append(wirelesslink.ssid)
  270. # Draw the wireless link
  271. start = (OFFSET + self.center, self.cursor)
  272. height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
  273. end = (start[0], start[1] + height)
  274. line = Line(start=start, end=end, class_='wireless-link')
  275. group.add(line)
  276. self.cursor += PADDING * 2
  277. # Add link
  278. link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_parent')
  279. # Add text label(s)
  280. for i, label in enumerate(labels):
  281. self.cursor += LINE_HEIGHT
  282. text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
  283. text = Text(label, insert=text_coords, class_='bold' if not i else [])
  284. link.add(text)
  285. group.add(link)
  286. self.cursor += PADDING * 2
  287. return group
  288. def draw_attachment(self):
  289. """
  290. Return an SVG group containing a line element and "Attachment" label.
  291. """
  292. group = Group(class_='connector')
  293. # Draw attachment (line)
  294. start = (OFFSET + self.center, OFFSET + self.cursor)
  295. height = PADDING * 2 + LINE_HEIGHT + PADDING * 2
  296. end = (start[0], start[1] + height)
  297. line = Line(start=start, end=end, class_='attachment')
  298. group.add(line)
  299. self.cursor += PADDING * 4
  300. return group
  301. def render(self):
  302. """
  303. Return an SVG document representing a cable trace.
  304. """
  305. from dcim.models import Cable
  306. from wireless.models import WirelessLink
  307. traced_path = self.origin.trace()
  308. # Iterate through each (terms, cable, terms) segment in the path
  309. for i, segment in enumerate(traced_path):
  310. near_ends, links, far_ends = segment
  311. # Near end parent
  312. if i == 0:
  313. # If this is the first segment, draw the originating termination's parent object
  314. self.draw_parent_objects(set(end.parent_object for end in near_ends))
  315. # Near end termination(s)
  316. terminations = self.draw_terminations(near_ends)
  317. # Connector (a Cable or WirelessLink)
  318. if links:
  319. link_cables = {}
  320. fanin = False
  321. fanout = False
  322. # Determine if we have fanins or fanouts
  323. if len(near_ends) > len(set(links)):
  324. self.cursor += FANOUT_HEIGHT
  325. fanin = True
  326. if len(far_ends) > len(set(links)):
  327. fanout = True
  328. cursor = self.cursor
  329. for link in links:
  330. # Cable
  331. if type(link) is Cable and not link_cables.get(link.pk):
  332. # Reset cursor
  333. self.cursor = cursor
  334. # Generate a list of terminations connected to this cable
  335. near_end_link_terminations = [term for term in terminations if term.object.cable == link]
  336. # Draw the cable
  337. cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links))
  338. # Add cable to the list of cables
  339. link_cables.update({link.pk: cable})
  340. # Add cable to drawing
  341. self.connectors.append(cable)
  342. # Draw fan-ins
  343. if len(near_ends) > 1 and fanin:
  344. for term in terminations:
  345. if term.object.cable == link:
  346. self.draw_fanin(term, cable)
  347. # WirelessLink
  348. elif type(link) is WirelessLink:
  349. wirelesslink = self.draw_wirelesslink(link)
  350. self.connectors.append(wirelesslink)
  351. # Far end termination(s)
  352. if len(far_ends) > 1:
  353. if fanout:
  354. self.cursor += FANOUT_HEIGHT
  355. terminations = self.draw_terminations(far_ends)
  356. for term in terminations:
  357. if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk):
  358. self.draw_fanout(term, link_cables.get(term.object.cable.pk))
  359. else:
  360. self.draw_terminations(far_ends)
  361. elif far_ends:
  362. self.draw_terminations(far_ends)
  363. else:
  364. # Link is not connected to anything
  365. break
  366. # Far end parent
  367. parent_objects = set(end.parent_object for end in far_ends)
  368. self.draw_parent_objects(parent_objects)
  369. # Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with
  370. # a CircuitTermination)
  371. elif far_ends:
  372. # Attachment
  373. attachment = self.draw_attachment()
  374. self.connectors.append(attachment)
  375. # Object
  376. self.draw_parent_objects(far_ends)
  377. # Determine drawing size
  378. self.drawing = svgwrite.Drawing(
  379. size=(self.width, self.cursor + 2)
  380. )
  381. # Attach CSS stylesheet
  382. with open(f'{settings.STATIC_ROOT}/cable_trace.css') as css_file:
  383. self.drawing.defs.add(self.drawing.style(css_file.read()))
  384. # Add elements to the drawing in order of depth (Z axis)
  385. for element in self.connectors + self.parent_objects + self.terminations:
  386. self.drawing.add(element)
  387. return self.drawing