svg.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. import svgwrite
  2. from svgwrite.container import Group, Hyperlink
  3. from svgwrite.shapes import Line, Rect
  4. from svgwrite.text import Text
  5. from django.conf import settings
  6. from django.urls import reverse
  7. from django.utils.http import urlencode
  8. from utilities.utils import foreground_color
  9. from .choices import DeviceFaceChoices
  10. from .constants import RACK_ELEVATION_BORDER_WIDTH
  11. __all__ = (
  12. 'CableTraceSVG',
  13. 'RackElevationSVG',
  14. )
  15. def get_device_name(device):
  16. if device.virtual_chassis:
  17. return f'{device.virtual_chassis.name}:{device.vc_position}'
  18. elif device.name:
  19. return device.name
  20. else:
  21. return str(device.device_type)
  22. class RackElevationSVG:
  23. """
  24. Use this class to render a rack elevation as an SVG image.
  25. :param rack: A NetBox Rack instance
  26. :param user: User instance. If specified, only devices viewable by this user will be fully displayed.
  27. :param include_images: If true, the SVG document will embed front/rear device face images, where available
  28. :param base_url: Base URL for links within the SVG document. If none, links will be relative.
  29. """
  30. def __init__(self, rack, user=None, include_images=True, base_url=None):
  31. self.rack = rack
  32. self.include_images = include_images
  33. if base_url is not None:
  34. self.base_url = base_url.rstrip('/')
  35. else:
  36. self.base_url = ''
  37. # Determine the subset of devices within this rack that are viewable by the user, if any
  38. permitted_devices = self.rack.devices
  39. if user is not None:
  40. permitted_devices = permitted_devices.restrict(user, 'view')
  41. self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
  42. @staticmethod
  43. def _get_device_description(device):
  44. return '{} ({}) — {} {} ({}U) {} {}'.format(
  45. device.name,
  46. device.device_role,
  47. device.device_type.manufacturer.name,
  48. device.device_type.model,
  49. device.device_type.u_height,
  50. device.asset_tag or '',
  51. device.serial or ''
  52. )
  53. @staticmethod
  54. def _add_gradient(drawing, id_, color):
  55. gradient = drawing.linearGradient(
  56. start=(0, 0),
  57. end=(0, 25),
  58. spreadMethod='repeat',
  59. id_=id_,
  60. gradientTransform='rotate(45, 0, 0)',
  61. gradientUnits='userSpaceOnUse'
  62. )
  63. gradient.add_stop_color(offset='0%', color='#f7f7f7')
  64. gradient.add_stop_color(offset='50%', color='#f7f7f7')
  65. gradient.add_stop_color(offset='50%', color=color)
  66. gradient.add_stop_color(offset='100%', color=color)
  67. drawing.defs.add(gradient)
  68. @staticmethod
  69. def _setup_drawing(width, height):
  70. drawing = svgwrite.Drawing(size=(width, height))
  71. # add the stylesheet
  72. with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file:
  73. drawing.defs.add(drawing.style(css_file.read()))
  74. # add gradients
  75. RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff')
  76. RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
  77. RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
  78. return drawing
  79. def _draw_device_front(self, drawing, device, start, end, text):
  80. name = get_device_name(device)
  81. if device.devicebay_count:
  82. name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
  83. color = device.device_role.color
  84. link = drawing.add(
  85. drawing.a(
  86. href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
  87. target='_top',
  88. fill='black'
  89. )
  90. )
  91. link.set_desc(self._get_device_description(device))
  92. link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
  93. hex_color = '#{}'.format(foreground_color(color))
  94. link.add(drawing.text(str(name), insert=text, fill=hex_color))
  95. # Embed front device type image if one exists
  96. if self.include_images and device.device_type.front_image:
  97. image = drawing.image(
  98. href=device.device_type.front_image.url,
  99. insert=start,
  100. size=end,
  101. class_='device-image'
  102. )
  103. image.fit(scale='slice')
  104. link.add(image)
  105. link.add(drawing.text(str(name), insert=text, stroke='black',
  106. stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
  107. link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
  108. def _draw_device_rear(self, drawing, device, start, end, text):
  109. link = drawing.add(
  110. drawing.a(
  111. href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
  112. target='_top',
  113. fill='black'
  114. )
  115. )
  116. link.set_desc(self._get_device_description(device))
  117. link.add(drawing.rect(start, end, class_="slot blocked"))
  118. link.add(drawing.text(get_device_name(device), insert=text))
  119. # Embed rear device type image if one exists
  120. if self.include_images and device.device_type.rear_image:
  121. image = drawing.image(
  122. href=device.device_type.rear_image.url,
  123. insert=start,
  124. size=end,
  125. class_='device-image'
  126. )
  127. image.fit(scale='slice')
  128. drawing.add(image)
  129. drawing.add(drawing.text(get_device_name(device), insert=text, stroke='black',
  130. stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
  131. drawing.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
  132. @staticmethod
  133. def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
  134. link_url = '{}?{}'.format(
  135. reverse('dcim:device_add'),
  136. urlencode({
  137. 'site': rack.site.pk,
  138. 'location': rack.location.pk if rack.location else '',
  139. 'rack': rack.pk,
  140. 'face': face_id,
  141. 'position': id_
  142. })
  143. )
  144. link = drawing.add(
  145. drawing.a(href=link_url, target='_top')
  146. )
  147. if reservation:
  148. link.set_desc('{} — {} · {}'.format(
  149. reservation.description, reservation.user, reservation.created
  150. ))
  151. link.add(drawing.rect(start, end, class_=class_))
  152. link.add(drawing.text("add device", insert=text, class_='add-device'))
  153. def merge_elevations(self, face):
  154. elevation = self.rack.get_rack_units(face=face, expand_devices=False)
  155. if face == DeviceFaceChoices.FACE_REAR:
  156. other_face = DeviceFaceChoices.FACE_FRONT
  157. else:
  158. other_face = DeviceFaceChoices.FACE_REAR
  159. other = self.rack.get_rack_units(face=other_face)
  160. unit_cursor = 0
  161. for u in elevation:
  162. o = other[unit_cursor]
  163. if not u['device'] and o['device'] and o['device'].device_type.is_full_depth:
  164. u['device'] = o['device']
  165. u['height'] = 1
  166. unit_cursor += u.get('height', 1)
  167. return elevation
  168. def render(self, face, unit_width, unit_height, legend_width):
  169. """
  170. Return an SVG document representing a rack elevation.
  171. """
  172. drawing = self._setup_drawing(
  173. unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2,
  174. unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
  175. )
  176. reserved_units = self.rack.get_reserved_units()
  177. unit_cursor = 0
  178. for ru in range(0, self.rack.u_height):
  179. start_y = ru * unit_height
  180. position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
  181. unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
  182. drawing.add(
  183. drawing.text(str(unit), position_coordinates, class_="unit")
  184. )
  185. for unit in self.merge_elevations(face):
  186. # Loop through all units in the elevation
  187. device = unit['device']
  188. height = unit.get('height', 1)
  189. # Setup drawing coordinates
  190. x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH
  191. y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH
  192. end_y = unit_height * height
  193. start_cordinates = (x_offset, y_offset)
  194. end_cordinates = (unit_width, end_y)
  195. text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2)
  196. # Draw the device
  197. if device and device.face == face and device.pk in self.permitted_device_ids:
  198. self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
  199. elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids:
  200. self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
  201. elif device:
  202. # Devices which the user does not have permission to view are rendered only as unavailable space
  203. drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked'))
  204. else:
  205. # Draw shallow devices, reservations, or empty units
  206. class_ = 'slot'
  207. reservation = reserved_units.get(unit["id"])
  208. if device:
  209. class_ += ' occupied'
  210. if reservation:
  211. class_ += ' reserved'
  212. self._draw_empty(
  213. drawing,
  214. self.rack,
  215. start_cordinates,
  216. end_cordinates,
  217. text_cordinates,
  218. unit["id"],
  219. face,
  220. class_,
  221. reservation
  222. )
  223. unit_cursor += height
  224. # Wrap the drawing with a border
  225. border_width = RACK_ELEVATION_BORDER_WIDTH
  226. border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
  227. frame = drawing.rect(
  228. insert=(legend_width + border_offset, border_offset),
  229. size=(unit_width + border_width, self.rack.u_height * unit_height + border_width),
  230. class_='rack'
  231. )
  232. drawing.add(frame)
  233. return drawing
  234. OFFSET = 0.5
  235. PADDING = 10
  236. LINE_HEIGHT = 20
  237. class CableTraceSVG:
  238. """
  239. Generate a graphical representation of a CablePath in SVG format.
  240. :param origin: The originating termination
  241. :param width: Width of the generated image (in pixels)
  242. :param base_url: Base URL for links within the SVG document. If none, links will be relative.
  243. """
  244. def __init__(self, origin, width=400, base_url=None):
  245. self.origin = origin
  246. self.width = width
  247. self.base_url = base_url.rstrip('/') if base_url is not None else ''
  248. # Establish a cursor to track position on the y axis
  249. # Center edges on pixels to render sharp borders
  250. self.cursor = OFFSET
  251. @property
  252. def center(self):
  253. return self.width / 2
  254. @classmethod
  255. def _get_labels(cls, instance):
  256. """
  257. Return a list of text labels for the given instance based on model type.
  258. """
  259. labels = [str(instance)]
  260. if instance._meta.model_name == 'device':
  261. labels.append(f'{instance.device_type.manufacturer} {instance.device_type}')
  262. location_label = f'{instance.site}'
  263. if instance.location:
  264. location_label += f' / {instance.location}'
  265. if instance.rack:
  266. location_label += f' / {instance.rack}'
  267. labels.append(location_label)
  268. elif instance._meta.model_name == 'circuit':
  269. labels[0] = f'Circuit {instance}'
  270. labels.append(instance.provider)
  271. elif instance._meta.model_name == 'circuittermination':
  272. if instance.xconnect_id:
  273. labels.append(f'{instance.xconnect_id}')
  274. elif instance._meta.model_name == 'providernetwork':
  275. labels.append(instance.provider)
  276. return labels
  277. @classmethod
  278. def _get_color(cls, instance):
  279. """
  280. Return the appropriate fill color for an object within a cable path.
  281. """
  282. if hasattr(instance, 'parent_object'):
  283. # Termination
  284. return 'f0f0f0'
  285. if hasattr(instance, 'device_role'):
  286. # Device
  287. return instance.device_role.color
  288. else:
  289. # Other parent object
  290. return 'e0e0e0'
  291. def _draw_box(self, width, color, url, labels, y_indent=0, padding_multiplier=1, radius=10):
  292. """
  293. Return an SVG Link element containing a Rect and one or more text labels representing a
  294. parent object or cable termination point.
  295. :param width: Box width
  296. :param color: Box fill color
  297. :param url: Hyperlink URL
  298. :param labels: Iterable of text labels
  299. :param y_indent: Vertical indent (for overlapping other boxes) (default: 0)
  300. :param padding_multiplier: Add extra vertical padding (default: 1)
  301. :param radius: Box corner radius (default: 10)
  302. """
  303. self.cursor -= y_indent
  304. # Create a hyperlink
  305. link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
  306. # Add the box
  307. position = (
  308. OFFSET + (self.width - width) / 2,
  309. self.cursor
  310. )
  311. height = PADDING * padding_multiplier \
  312. + LINE_HEIGHT * len(labels) \
  313. + PADDING * padding_multiplier
  314. box = Rect(position, (width - 2, height), rx=radius, class_='parent-object', style=f'fill: #{color}')
  315. link.add(box)
  316. self.cursor += PADDING * padding_multiplier
  317. # Add text label(s)
  318. for i, label in enumerate(labels):
  319. self.cursor += LINE_HEIGHT
  320. text_coords = (self.center, self.cursor - LINE_HEIGHT / 2)
  321. text_color = f'#{foreground_color(color, dark="303030")}'
  322. text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else [])
  323. link.add(text)
  324. self.cursor += PADDING * padding_multiplier
  325. return link
  326. def _draw_cable(self, color, url, labels):
  327. """
  328. Return an SVG group containing a line element and text labels representing a Cable.
  329. :param color: Cable (line) color
  330. :param url: Hyperlink URL
  331. :param labels: Iterable of text labels
  332. """
  333. group = Group(class_='connector')
  334. # Draw a "shadow" line to give the cable a border
  335. start = (OFFSET + self.center, self.cursor)
  336. height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
  337. end = (start[0], start[1] + height)
  338. cable_shadow = Line(start=start, end=end, class_='cable-shadow')
  339. group.add(cable_shadow)
  340. # Draw the cable
  341. cable = Line(start=start, end=end, style=f'stroke: #{color}')
  342. group.add(cable)
  343. self.cursor += PADDING * 2
  344. # Add link
  345. link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
  346. # Add text label(s)
  347. for i, label in enumerate(labels):
  348. self.cursor += LINE_HEIGHT
  349. text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
  350. text = Text(label, insert=text_coords, class_='bold' if not i else [])
  351. link.add(text)
  352. group.add(link)
  353. self.cursor += PADDING * 2
  354. return group
  355. def _draw_wirelesslink(self, url, labels):
  356. """
  357. Draw a line with labels representing a WirelessLink.
  358. :param url: Hyperlink URL
  359. :param labels: Iterable of text labels
  360. """
  361. group = Group(class_='connector')
  362. # Draw the wireless link
  363. start = (OFFSET + self.center, self.cursor)
  364. height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
  365. end = (start[0], start[1] + height)
  366. line = Line(start=start, end=end, class_='wireless-link')
  367. group.add(line)
  368. self.cursor += PADDING * 2
  369. # Add link
  370. link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
  371. # Add text label(s)
  372. for i, label in enumerate(labels):
  373. self.cursor += LINE_HEIGHT
  374. text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
  375. text = Text(label, insert=text_coords, class_='bold' if not i else [])
  376. link.add(text)
  377. group.add(link)
  378. self.cursor += PADDING * 2
  379. return group
  380. def _draw_attachment(self):
  381. """
  382. Return an SVG group containing a line element and "Attachment" label.
  383. """
  384. group = Group(class_='connector')
  385. # Draw attachment (line)
  386. start = (OFFSET + self.center, OFFSET + self.cursor)
  387. height = PADDING * 2 + LINE_HEIGHT + PADDING * 2
  388. end = (start[0], start[1] + height)
  389. line = Line(start=start, end=end, class_='attachment')
  390. group.add(line)
  391. self.cursor += PADDING * 4
  392. return group
  393. def render(self):
  394. """
  395. Return an SVG document representing a cable trace.
  396. """
  397. from dcim.models import Cable
  398. from wireless.models import WirelessLink
  399. traced_path = self.origin.trace()
  400. # Prep elements list
  401. parent_objects = []
  402. terminations = []
  403. connectors = []
  404. # Iterate through each (term, cable, term) segment in the path
  405. for i, segment in enumerate(traced_path):
  406. near_end, connector, far_end = segment
  407. # Near end parent
  408. if i == 0:
  409. # If this is the first segment, draw the originating termination's parent object
  410. parent_object = self._draw_box(
  411. width=self.width,
  412. color=self._get_color(near_end.parent_object),
  413. url=near_end.parent_object.get_absolute_url(),
  414. labels=self._get_labels(near_end.parent_object),
  415. padding_multiplier=2
  416. )
  417. parent_objects.append(parent_object)
  418. # Near end termination
  419. if near_end is not None:
  420. termination = self._draw_box(
  421. width=self.width * .8,
  422. color=self._get_color(near_end),
  423. url=near_end.get_absolute_url(),
  424. labels=self._get_labels(near_end),
  425. y_indent=PADDING,
  426. radius=5
  427. )
  428. terminations.append(termination)
  429. # Connector (a Cable or WirelessLink)
  430. if connector is not None:
  431. # Cable
  432. if type(connector) is Cable:
  433. connector_labels = [
  434. f'Cable {connector}',
  435. connector.get_status_display()
  436. ]
  437. if connector.type:
  438. connector_labels.append(connector.get_type_display())
  439. if connector.length and connector.length_unit:
  440. connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
  441. cable = self._draw_cable(
  442. color=connector.color or '000000',
  443. url=connector.get_absolute_url(),
  444. labels=connector_labels
  445. )
  446. connectors.append(cable)
  447. # WirelessLink
  448. elif type(connector) is WirelessLink:
  449. connector_labels = [
  450. f'Wireless link {connector}',
  451. connector.get_status_display()
  452. ]
  453. if connector.ssid:
  454. connector_labels.append(connector.ssid)
  455. wirelesslink = self._draw_wirelesslink(
  456. url=connector.get_absolute_url(),
  457. labels=connector_labels
  458. )
  459. connectors.append(wirelesslink)
  460. # Far end termination
  461. termination = self._draw_box(
  462. width=self.width * .8,
  463. color=self._get_color(far_end),
  464. url=far_end.get_absolute_url(),
  465. labels=self._get_labels(far_end),
  466. radius=5
  467. )
  468. terminations.append(termination)
  469. # Far end parent
  470. parent_object = self._draw_box(
  471. width=self.width,
  472. color=self._get_color(far_end.parent_object),
  473. url=far_end.parent_object.get_absolute_url(),
  474. labels=self._get_labels(far_end.parent_object),
  475. y_indent=PADDING,
  476. padding_multiplier=2
  477. )
  478. parent_objects.append(parent_object)
  479. elif far_end:
  480. # Attachment
  481. attachment = self._draw_attachment()
  482. connectors.append(attachment)
  483. # ProviderNetwork
  484. parent_object = self._draw_box(
  485. width=self.width,
  486. color=self._get_color(far_end),
  487. url=far_end.get_absolute_url(),
  488. labels=self._get_labels(far_end),
  489. padding_multiplier=2
  490. )
  491. parent_objects.append(parent_object)
  492. # Determine drawing size
  493. self.drawing = svgwrite.Drawing(
  494. size=(self.width, self.cursor + 2)
  495. )
  496. # Attach CSS stylesheet
  497. with open(f'{settings.STATIC_ROOT}/cable_trace.css') as css_file:
  498. self.drawing.defs.add(self.drawing.style(css_file.read()))
  499. # Add elements to the drawing in order of depth (Z axis)
  500. for element in connectors + parent_objects + terminations:
  501. self.drawing.add(element)
  502. return self.drawing