svg.py 22 KB

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