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

1import decimal 

2 

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 

16 

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 

21 

22__all__ = ( 

23 'RackElevationSVG', 

24) 

25 

26GRADIENT_RESERVED = '#b0b0ff' 

27GRADIENT_OCCUPIED = '#d7d7d7' 

28GRADIENT_BLOCKED = '#ffc0c0' 

29STROKE_RESERVED = '#4d4dff' 

30 

31 

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) 

39 

40 return name 

41 

42 

43def get_device_description(device): 

44 """ 

45 Return a description for a device to be rendered in the rack elevation in the following format 

46 

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}' 

66 

67 return description 

68 

69 

70def truncate_text(text, width, font_size=15): 

71 """ 

72 Truncate text to fit within the width of a rectangle. 

73 

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) 

80 

81 return text if len(text) <= max_char else text[:max_char] + '...' 

82 

83 

84class RackElevationSVG: 

85 """ 

86 Use this class to render a rack elevation as an SVG image. 

87 

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 '' 

103 

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 

110 

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) 

116 

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 

127 

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) 

142 

143 drawing.defs.add(gradient) 

144 

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)) 

149 

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())) 

153 

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) 

158 

159 return drawing 

160 

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 ) 

174 

175 return x, y 

176 

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 ) 

185 

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 '' 

189 

190 # Create hyperlink element 

191 link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target="_parent") 

192 link.set_desc(description) 

193 

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)) 

199 

200 self.drawing.defs.add(clip_path) 

201 

202 # Name to display 

203 display_name = truncate_text(name, size[0]) 

204 

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 ) 

214 

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 ) 

233 

234 self.drawing.add(link) 

235 

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) 

243 

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) 

250 

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) 

263 

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 ) 

276 

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) 

296 

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 ) 

311 

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 ) 

320 

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')) 

324 

325 self.drawing.add(link) 

326 

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): 

332 

333 # Loop through all units in the elevation 

334 device = unit['device'] 

335 height = unit.get('height', decimal.Decimal(1.0)) 

336 

337 device_coords = self._get_device_coords(unit['id'], height) 

338 device_size = ( 

339 self.unit_width, 

340 int(self.unit_height * height) 

341 ) 

342 

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) 

349 

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')) 

353 

354 def render(self, face): 

355 """ 

356 Return an SVG document representing a rack elevation. 

357 """ 

358 

359 # Initialize the drawing 

360 self.drawing = self._setup_drawing() 

361 

362 # Draw the empty rack, legend, and margin 

363 self.draw_legend() 

364 self.draw_background(face) 

365 self.draw_margin() 

366 

367 # Draw the rack face 

368 self.draw_face(face) 

369 

370 # Draw the rack border last 

371 self.draw_border() 

372 

373 return self.drawing