racks.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import decimal
  2. import svgwrite
  3. from svgwrite.container import Hyperlink
  4. from svgwrite.image import Image
  5. from svgwrite.gradients import LinearGradient
  6. from svgwrite.shapes import Rect
  7. from svgwrite.text import Text
  8. from django.conf import settings
  9. from django.core.exceptions import FieldError
  10. from django.db.models import Q
  11. from django.template.defaultfilters import floatformat
  12. from django.urls import reverse
  13. from django.utils.http import urlencode
  14. from netbox.config import get_config
  15. from utilities.utils import foreground_color, array_to_ranges
  16. from dcim.constants import RACK_ELEVATION_BORDER_WIDTH
  17. __all__ = (
  18. 'RackElevationSVG',
  19. )
  20. def get_device_name(device):
  21. if device.virtual_chassis:
  22. name = f'{device.virtual_chassis.name}:{device.vc_position}'
  23. elif device.name:
  24. name = device.name
  25. else:
  26. name = str(device.device_type)
  27. if device.devicebay_count:
  28. name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
  29. return name
  30. def get_device_description(device):
  31. return '{} ({}) — {} {} ({}U) {} {}'.format(
  32. device.name,
  33. device.device_role,
  34. device.device_type.manufacturer.name,
  35. device.device_type.model,
  36. floatformat(device.device_type.u_height),
  37. device.asset_tag or '',
  38. device.serial or ''
  39. )
  40. class RackElevationSVG:
  41. """
  42. Use this class to render a rack elevation as an SVG image.
  43. :param rack: A NetBox Rack instance
  44. :param unit_width: Rendered unit width, in pixels
  45. :param unit_height: Rendered unit height, in pixels
  46. :param legend_width: Legend width, in pixels (where the unit labels appear)
  47. :param margin_width: Margin width, in pixels (where reservations appear)
  48. :param user: User instance. If specified, only devices viewable by this user will be fully displayed.
  49. :param include_images: If true, the SVG document will embed front/rear device face images, where available
  50. :param base_url: Base URL for links within the SVG document. If none, links will be relative.
  51. :param highlight_params: Iterable of two-tuples which identifies attributes of devices to highlight
  52. """
  53. def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, margin_width=None, user=None,
  54. include_images=True, base_url=None, highlight_params=None):
  55. self.rack = rack
  56. self.include_images = include_images
  57. self.base_url = base_url.rstrip('/') if base_url is not None else ''
  58. # Set drawing dimensions
  59. config = get_config()
  60. self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
  61. self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
  62. self.legend_width = legend_width or config.RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
  63. self.margin_width = margin_width or config.RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
  64. # Determine the subset of devices within this rack that are viewable by the user, if any
  65. permitted_devices = self.rack.devices
  66. if user is not None:
  67. permitted_devices = permitted_devices.restrict(user, 'view')
  68. self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
  69. # Determine device(s) to highlight within the elevation (if any)
  70. self.highlight_devices = []
  71. if highlight_params:
  72. q = Q()
  73. for k, v in highlight_params:
  74. q |= Q(**{k: v})
  75. try:
  76. self.highlight_devices = permitted_devices.filter(q)
  77. except FieldError:
  78. pass
  79. @staticmethod
  80. def _add_gradient(drawing, id_, color):
  81. gradient = LinearGradient(
  82. start=(0, 0),
  83. end=(0, 25),
  84. spreadMethod='repeat',
  85. id_=id_,
  86. gradientTransform='rotate(45, 0, 0)',
  87. gradientUnits='userSpaceOnUse'
  88. )
  89. gradient.add_stop_color(offset='0%', color='#f7f7f7')
  90. gradient.add_stop_color(offset='50%', color='#f7f7f7')
  91. gradient.add_stop_color(offset='50%', color=color)
  92. gradient.add_stop_color(offset='100%', color=color)
  93. drawing.defs.add(gradient)
  94. def _setup_drawing(self):
  95. width = self.unit_width + self.legend_width + self.margin_width + RACK_ELEVATION_BORDER_WIDTH * 2
  96. height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
  97. drawing = svgwrite.Drawing(size=(width, height))
  98. # Add the stylesheet
  99. with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file:
  100. drawing.defs.add(drawing.style(css_file.read()))
  101. # Add gradients
  102. RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff')
  103. RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
  104. RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
  105. return drawing
  106. def _get_device_coords(self, position, height):
  107. """
  108. Return the X, Y coordinates of the top left corner for a device in the specified rack unit.
  109. """
  110. x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH
  111. y = RACK_ELEVATION_BORDER_WIDTH
  112. if self.rack.desc_units:
  113. y += int((position - 1) * self.unit_height)
  114. else:
  115. y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height)
  116. return x, y
  117. def _draw_device(self, device, coords, size, color=None, image=None):
  118. name = get_device_name(device)
  119. description = get_device_description(device)
  120. text_color = f'#{foreground_color(color)}' if color else '#000000'
  121. text_coords = (
  122. coords[0] + size[0] / 2,
  123. coords[1] + size[1] / 2
  124. )
  125. # Determine whether highlighting is in use, and if so, whether to shade this device
  126. is_shaded = self.highlight_devices and device not in self.highlight_devices
  127. css_extra = ' shaded' if is_shaded else ''
  128. # Create hyperlink element
  129. link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target="_parent")
  130. link.set_desc(description)
  131. # Add rect element to hyperlink
  132. if color:
  133. link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}'))
  134. else:
  135. link.add(Rect(coords, size, class_=f'slot blocked{css_extra}'))
  136. link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}'))
  137. # Embed device type image if provided
  138. if self.include_images and image:
  139. url = f'{self.base_url}{image.url}' if image.url.startswith('/') else image.url
  140. image = Image(
  141. href=url,
  142. insert=coords,
  143. size=size,
  144. class_=f'device-image{css_extra}'
  145. )
  146. image.fit(scale='slice')
  147. link.add(image)
  148. link.add(
  149. Text(name, insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round',
  150. class_=f'device-image-label{css_extra}')
  151. )
  152. link.add(
  153. Text(name, insert=text_coords, fill='white', class_=f'device-image-label{css_extra}')
  154. )
  155. self.drawing.add(link)
  156. def draw_device_front(self, device, coords, size):
  157. """
  158. Draw the front (mounted) face of a device.
  159. """
  160. color = device.device_role.color
  161. image = device.device_type.front_image
  162. self._draw_device(device, coords, size, color=color, image=image)
  163. def draw_device_rear(self, device, coords, size):
  164. """
  165. Draw the rear (opposite) face of a device.
  166. """
  167. image = device.device_type.rear_image
  168. self._draw_device(device, coords, size, image=image)
  169. def draw_border(self):
  170. """
  171. Draw a border around the collection of rack units.
  172. """
  173. border_width = RACK_ELEVATION_BORDER_WIDTH
  174. border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
  175. frame = Rect(
  176. insert=(self.legend_width + border_offset, border_offset),
  177. size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width),
  178. class_='rack'
  179. )
  180. self.drawing.add(frame)
  181. def draw_legend(self):
  182. """
  183. Draw the rack unit labels along the lefthand side of the elevation.
  184. """
  185. for ru in range(0, self.rack.u_height):
  186. start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH
  187. position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
  188. unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
  189. self.drawing.add(
  190. Text(str(unit), position_coordinates, class_='unit')
  191. )
  192. def draw_margin(self):
  193. """
  194. Draw any rack reservations in the right-hand margin alongside the rack elevation.
  195. """
  196. for reservation in self.rack.reservations.all():
  197. for segment in array_to_ranges(reservation.units):
  198. u_height = 1 if len(segment) == 1 else segment[1] + 1 - segment[0]
  199. coords = self._get_device_coords(segment[0], u_height)
  200. coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1])
  201. size = (
  202. self.margin_width,
  203. u_height * self.unit_height
  204. )
  205. link = Hyperlink(href=f'{self.base_url}{reservation.get_absolute_url()}', target='_parent')
  206. link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}')
  207. link.add(
  208. Rect(coords, size, class_='reservation')
  209. )
  210. self.drawing.add(link)
  211. def draw_background(self, face):
  212. """
  213. Draw the rack unit placeholders which form the "background" of the rack elevation.
  214. """
  215. x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width
  216. url_string = '{}?{}&position={{}}'.format(
  217. reverse('dcim:device_add'),
  218. urlencode({
  219. 'site': self.rack.site.pk,
  220. 'location': self.rack.location.pk if self.rack.location else '',
  221. 'rack': self.rack.pk,
  222. 'face': face,
  223. })
  224. )
  225. for ru in range(0, self.rack.u_height):
  226. unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
  227. y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height
  228. text_coords = (
  229. x_offset + self.unit_width / 2,
  230. y_offset + self.unit_height / 2
  231. )
  232. link = Hyperlink(href=url_string.format(unit), target='_parent')
  233. link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot'))
  234. link.add(Text('add device', insert=text_coords, class_='add-device'))
  235. self.drawing.add(link)
  236. def draw_face(self, face, opposite=False):
  237. """
  238. Draw any occupied rack units for the specified rack face.
  239. """
  240. for unit in self.rack.get_rack_units(face=face, expand_devices=False):
  241. # Loop through all units in the elevation
  242. device = unit['device']
  243. height = unit.get('height', decimal.Decimal(1.0))
  244. device_coords = self._get_device_coords(unit['id'], height)
  245. device_size = (
  246. self.unit_width,
  247. int(self.unit_height * height)
  248. )
  249. # Draw the device
  250. if device and device.pk in self.permitted_device_ids:
  251. if device.face == face and not opposite:
  252. self.draw_device_front(device, device_coords, device_size)
  253. else:
  254. self.draw_device_rear(device, device_coords, device_size)
  255. elif device:
  256. # Devices which the user does not have permission to view are rendered only as unavailable space
  257. self.drawing.add(Rect(device_coords, device_size, class_='blocked'))
  258. def render(self, face):
  259. """
  260. Return an SVG document representing a rack elevation.
  261. """
  262. # Initialize the drawing
  263. self.drawing = self._setup_drawing()
  264. # Draw the empty rack, legend, and margin
  265. self.draw_legend()
  266. self.draw_background(face)
  267. self.draw_margin()
  268. # Draw the rack face
  269. self.draw_face(face)
  270. # Draw the rack border last
  271. self.draw_border()
  272. return self.drawing