Coverage for dcim/svg/racks.py: 19%
183 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import decimal
3import svgwrite
4from django.conf import settings
5from django.core.exceptions import FieldError
6from django.db.models import Q
7from django.template.defaultfilters import floatformat
8from django.urls import reverse
9from django.utils.http import urlencode
10from svgwrite.container import Hyperlink
11from svgwrite.gradients import LinearGradient
12from svgwrite.image import Image
13from svgwrite.masking import ClipPath
14from svgwrite.shapes import Rect
15from svgwrite.text import Text
17from dcim.constants import RACK_ELEVATION_BORDER_WIDTH
18from netbox.config import get_config
19from utilities.data import array_to_ranges
20from utilities.html import foreground_color
22__all__ = (
23 'RackElevationSVG',
24)
26GRADIENT_RESERVED = '#b0b0ff'
27GRADIENT_OCCUPIED = '#d7d7d7'
28GRADIENT_BLOCKED = '#ffc0c0'
29STROKE_RESERVED = '#4d4dff'
32def get_device_name(device):
33 if device.label:
34 name = device.label
35 else:
36 name = str(device.device_type)
37 if device.devicebay_count:
38 name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
40 return name
43def get_device_description(device):
44 """
45 Return a description for a device to be rendered in the rack elevation in the following format
47 Name: <name>
48 Role: <role>
49 Status: <status>
50 Device Type: <manufacturer> <model> (<u_height>)
51 Asset tag: <asset_tag> (if defined)
52 Serial: <serial> (if defined)
53 Description: <description> (if defined)
54 """
55 description = f'Name: {device.name}'
56 description += f'\nRole: {device.role}'
57 description += f'\nStatus: {device.get_status_display()}'
58 u_height = f'{floatformat(device.device_type.u_height)}U'
59 description += f'\nDevice Type: {device.device_type.manufacturer.name} {device.device_type.model} ({u_height})'
60 if device.asset_tag:
61 description += f'\nAsset tag: {device.asset_tag}'
62 if device.serial:
63 description += f'\nSerial: {device.serial}'
64 if device.description:
65 description += f'\nDescription: {device.description}'
67 return description
70def truncate_text(text, width, font_size=15):
71 """
72 Truncate text to fit within the width of a rectangle.
74 :param text: The text to truncate
75 :param width: Width of rectangle
76 :param font_size: Font size (default is 15, ~0.875rem)
77 """
78 char_width = font_size * 0.6 # 0.6 is an approximation of the average character width in pixels
79 max_char = int(width / char_width)
81 return text if len(text) <= max_char else text[:max_char] + '...'
84class RackElevationSVG:
85 """
86 Use this class to render a rack elevation as an SVG image.
88 :param rack: A NetBox Rack instance
89 :param unit_width: Rendered unit width, in pixels
90 :param unit_height: Rendered unit height, in pixels
91 :param legend_width: Legend width, in pixels (where the unit labels appear)
92 :param margin_width: Margin width, in pixels (where reservations appear)
93 :param user: User instance. If specified, only devices viewable by this user will be fully displayed.
94 :param include_images: If true, the SVG document will embed front/rear device face images, where available
95 :param base_url: Base URL for links within the SVG document. If none, links will be relative.
96 :param highlight_params: Iterable of two-tuples which identifies attributes of devices to highlight
97 """
98 def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, margin_width=None, user=None,
99 include_images=True, base_url=None, highlight_params=None):
100 self.rack = rack
101 self.include_images = include_images
102 self.base_url = base_url.rstrip('/') if base_url is not None else ''
104 # Set drawing dimensions
105 config = get_config()
106 self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
107 self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
108 self.legend_width = legend_width or config.RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
109 self.margin_width = margin_width or config.RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
111 # Determine the subset of devices within this rack that are viewable by the user, if any
112 permitted_devices = self.rack.devices
113 if user is not None:
114 permitted_devices = permitted_devices.restrict(user, 'view')
115 self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
117 # Determine device(s) to highlight within the elevation (if any)
118 self.highlight_devices = []
119 if highlight_params:
120 q = Q()
121 for k, v in highlight_params:
122 q |= Q(**{k: v})
123 try:
124 self.highlight_devices = permitted_devices.filter(q)
125 except FieldError:
126 pass
128 @staticmethod
129 def _add_gradient(drawing, id_, color):
130 gradient = LinearGradient(
131 start=(0, 0),
132 end=(0, 25),
133 spreadMethod='repeat',
134 id_=id_,
135 gradientTransform='rotate(45, 0, 0)',
136 gradientUnits='userSpaceOnUse'
137 )
138 gradient.add_stop_color(offset='0%', color='#f7f7f7')
139 gradient.add_stop_color(offset='50%', color='#f7f7f7')
140 gradient.add_stop_color(offset='50%', color=color)
141 gradient.add_stop_color(offset='100%', color=color)
143 drawing.defs.add(gradient)
145 def _setup_drawing(self):
146 width = self.unit_width + self.legend_width + self.margin_width + RACK_ELEVATION_BORDER_WIDTH * 2
147 height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
148 drawing = svgwrite.Drawing(size=(width, height))
150 # Add the stylesheet
151 with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file:
152 drawing.defs.add(drawing.style(css_file.read()))
154 # Add gradients
155 RackElevationSVG._add_gradient(drawing, 'reserved', GRADIENT_RESERVED)
156 RackElevationSVG._add_gradient(drawing, 'occupied', GRADIENT_OCCUPIED)
157 RackElevationSVG._add_gradient(drawing, 'blocked', GRADIENT_BLOCKED)
159 return drawing
161 def _get_device_coords(self, position, height):
162 """
163 Return the X, Y coordinates of the top left corner for a device in the specified rack unit.
164 """
165 x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH
166 y = RACK_ELEVATION_BORDER_WIDTH
167 if self.rack.desc_units:
168 y += int((position - self.rack.starting_unit) * self.unit_height)
169 else:
170 y += (
171 int((self.rack.u_height - position + self.rack.starting_unit) * self.unit_height) -
172 int(height * self.unit_height)
173 )
175 return x, y
177 def _draw_device(self, device, coords, size, color=None, image=None):
178 name = get_device_name(device)
179 description = get_device_description(device)
180 text_color = f'#{foreground_color(color)}' if color else '#000000'
181 text_coords = (
182 coords[0] + size[0] / 2,
183 coords[1] + size[1] / 2
184 )
186 # Determine whether highlighting is in use, and if so, whether to shade this device
187 is_shaded = self.highlight_devices and device not in self.highlight_devices
188 css_extra = ' shaded' if is_shaded else ''
190 # Create hyperlink element
191 link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target="_parent")
192 link.set_desc(description)
194 # Create clipPath element
195 # This is necessary as fallback because the truncate_text method is an approximation
196 clip_id = f"clip-{device.id}"
197 clip_path = ClipPath(id=clip_id)
198 clip_path.add(Rect(coords, size))
200 self.drawing.defs.add(clip_path)
202 # Name to display
203 display_name = truncate_text(name, size[0])
205 # Add rect element to hyperlink
206 if color:
207 link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}'))
208 else:
209 link.add(Rect(coords, size, class_=f'slot blocked{css_extra}'))
210 link.add(
211 Text(display_name, insert=text_coords, fill=text_color, clip_path=f"url(#{clip_id})",
212 class_=f'label{css_extra}')
213 )
215 # Embed device type image if provided
216 if self.include_images and image:
217 url = f'{self.base_url}{image.url}' if image.url.startswith('/') else image.url
218 image = Image(
219 href=url,
220 insert=coords,
221 size=size,
222 class_=f'device-image{css_extra}'
223 )
224 image.fit(scale='slice')
225 link.add(image)
226 link.add(
227 Text(name, insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round',
228 class_=f'device-image-label{css_extra}')
229 )
230 link.add(
231 Text(name, insert=text_coords, fill='white', class_=f'device-image-label{css_extra}')
232 )
234 self.drawing.add(link)
236 def draw_device_front(self, device, coords, size):
237 """
238 Draw the front (mounted) face of a device.
239 """
240 color = device.role.color
241 image = device.device_type.front_image
242 self._draw_device(device, coords, size, color=color, image=image)
244 def draw_device_rear(self, device, coords, size):
245 """
246 Draw the rear (opposite) face of a device.
247 """
248 image = device.device_type.rear_image
249 self._draw_device(device, coords, size, image=image)
251 def draw_border(self):
252 """
253 Draw a border around the collection of rack units.
254 """
255 border_width = RACK_ELEVATION_BORDER_WIDTH
256 border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
257 frame = Rect(
258 insert=(self.legend_width + border_offset, border_offset),
259 size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width),
260 class_='rack'
261 )
262 self.drawing.add(frame)
264 def draw_legend(self):
265 """
266 Draw the rack unit labels along the lefthand side of the elevation.
267 """
268 for ru in range(0, self.rack.u_height):
269 start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH
270 position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
271 unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
272 unit = unit + self.rack.starting_unit - 1
273 self.drawing.add(
274 Text(str(unit), position_coordinates, class_='unit')
275 )
277 def draw_margin(self):
278 """
279 Draw any rack reservations in the right-hand margin alongside the rack elevation.
280 """
281 for reservation in self.rack.reservations.all():
282 for segment in array_to_ranges(reservation.units):
283 u_height = 1 if len(segment) == 1 else segment[1] + 1 - segment[0]
284 coords = self._get_device_coords(segment[0], u_height)
285 coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1])
286 size = (
287 self.margin_width - 3,
288 u_height * self.unit_height
289 )
290 link = Hyperlink(href=f'{self.base_url}{reservation.get_absolute_url()}', target='_parent')
291 link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}')
292 link.add(
293 Rect(coords, size, class_='reservation', stroke=STROKE_RESERVED, stroke_width=2)
294 )
295 self.drawing.add(link)
297 def draw_background(self, face):
298 """
299 Draw the rack unit placeholders which form the "background" of the rack elevation.
300 """
301 x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width
302 url_string = '{}?{}&position={{}}'.format(
303 reverse('dcim:device_add'),
304 urlencode({
305 'site': self.rack.site.pk,
306 'location': self.rack.location.pk if self.rack.location else '',
307 'rack': self.rack.pk,
308 'face': face,
309 })
310 )
312 for ru in range(0, self.rack.u_height):
313 unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
314 unit = unit + self.rack.starting_unit - 1
315 y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height
316 text_coords = (
317 x_offset + self.unit_width / 2,
318 y_offset + self.unit_height / 2
319 )
321 link = Hyperlink(href=url_string.format(unit), target='_parent')
322 link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot'))
323 link.add(Text('add device', insert=text_coords, class_='add-device'))
325 self.drawing.add(link)
327 def draw_face(self, face, opposite=False):
328 """
329 Draw any occupied rack units for the specified rack face.
330 """
331 for unit in self.rack.get_rack_units(face=face, expand_devices=False):
333 # Loop through all units in the elevation
334 device = unit['device']
335 height = unit.get('height', decimal.Decimal(1.0))
337 device_coords = self._get_device_coords(unit['id'], height)
338 device_size = (
339 self.unit_width,
340 int(self.unit_height * height)
341 )
343 # Draw the device
344 if device and device.pk in self.permitted_device_ids:
345 if device.face == face and not opposite:
346 self.draw_device_front(device, device_coords, device_size)
347 else:
348 self.draw_device_rear(device, device_coords, device_size)
350 elif device:
351 # Devices which the user does not have permission to view are rendered only as unavailable space
352 self.drawing.add(Rect(device_coords, device_size, class_='blocked'))
354 def render(self, face):
355 """
356 Return an SVG document representing a rack elevation.
357 """
359 # Initialize the drawing
360 self.drawing = self._setup_drawing()
362 # Draw the empty rack, legend, and margin
363 self.draw_legend()
364 self.draw_background(face)
365 self.draw_margin()
367 # Draw the rack face
368 self.draw_face(face)
370 # Draw the rack border last
371 self.draw_border()
373 return self.drawing