Coverage for dcim/svg/cables.py: 12%

257 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import svgwrite 

2from django.conf import settings 

3from svgwrite.container import Group, Hyperlink 

4from svgwrite.shapes import Line, Polyline, Rect 

5from svgwrite.text import Text 

6 

7from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH 

8from utilities.html import foreground_color 

9 

10__all__ = ( 

11 'CableTraceSVG', 

12) 

13 

14OFFSET = 0.5 

15PADDING = 10 

16LINE_HEIGHT = 20 

17FANOUT_HEIGHT = 35 

18FANOUT_LEG_HEIGHT = 15 

19CABLE_HEIGHT = 5 * LINE_HEIGHT + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT 

20 

21 

22def _cable_side_nodes(term_nodes, cable_terminations): 

23 """ 

24 Filter a list of termination nodes to those connected to the given side of a Cable. A channel subinterface does 

25 not terminate the cable directly; it derives its connection from its (channelized) parent interface, which carries 

26 the actual CableTermination, so it is matched via its parent. 

27 """ 

28 def matches(obj): 

29 if obj in cable_terminations: 

30 return True 

31 if getattr(obj, 'channel_id', None): 

32 return obj.parent in cable_terminations 

33 return False 

34 

35 return [node for node in term_nodes if matches(node.object)] 

36 

37 

38class Node(Hyperlink): 

39 """ 

40 Create a node to be represented in the SVG document as a rectangular box with a hyperlink. 

41 

42 Arguments: 

43 position: (x, y) coordinates of the box's top left corner 

44 width: Box width 

45 url: Hyperlink URL 

46 color: Box fill color (RRGGBB format) 

47 labels: An iterable of text strings. Each label will render on a new line within the box. 

48 radius: Box corner radius, for rounded corners (default: 10) 

49 object: A copy of the object to allow reference when drawing cables to determine which cables are connected to 

50 which terminations. 

51 """ 

52 

53 object = None 

54 

55 def __init__(self, position, width, url, color, labels, radius=10, object=object, **extra): 

56 super().__init__(href=url, target='_parent', **extra) 

57 

58 # Save object for reference by cable systems 

59 self.object = object 

60 

61 x, y = position 

62 

63 # Add the box 

64 dimensions = (width - 2, PADDING + LINE_HEIGHT * len(labels) + PADDING) 

65 box = Rect((x + OFFSET, y), dimensions, rx=radius, class_='parent-object', style=f'fill: #{color}') 

66 self.add(box) 

67 

68 cursor = y + PADDING 

69 

70 # Add text label(s) 

71 for i, label in enumerate(labels): 

72 cursor += LINE_HEIGHT 

73 text_coords = (x + width / 2, cursor - LINE_HEIGHT / 2) 

74 text_color = f'#{foreground_color(color, dark="303030")}' 

75 text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else []) 

76 self.add(text) 

77 

78 @property 

79 def box(self): 

80 return self.elements[0] if self.elements else None 

81 

82 @property 

83 def top_center(self): 

84 return self.box['x'] + self.box['width'] / 2, self.box['y'] 

85 

86 @property 

87 def bottom_center(self): 

88 return self.box['x'] + self.box['width'] / 2, self.box['y'] + self.box['height'] 

89 

90 

91class Connector(Group): 

92 """ 

93 Return an SVG group containing a line element and text labels representing a Cable. 

94 

95 Arguments: 

96 color: Cable (line) color 

97 url: Hyperlink URL 

98 labels: Iterable of text labels 

99 """ 

100 

101 def __init__(self, start, url, color, wireless, labels=[], description=[], end=None, text_offset=0, **extra): 

102 super().__init__(class_="connector", **extra) 

103 

104 self.start = start 

105 self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 

106 # Allow to specify end-position or auto-calculate 

107 self.end = end if end else (start[0], start[1] + self.height) 

108 self.color = color or '000000' 

109 

110 if wireless: 

111 # Draw the cable 

112 cable = Line(start=self.start, end=self.end, class_="wireless-link") 

113 self.add(cable) 

114 else: 

115 # Draw a "shadow" line to give the cable a border 

116 cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow') 

117 self.add(cable_shadow) 

118 

119 # Draw the cable 

120 cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}') 

121 self.add(cable) 

122 

123 # Add link 

124 link = Hyperlink(href=url, target='_parent') 

125 

126 # Add text label(s) 

127 cursor = start[1] + text_offset 

128 cursor += PADDING * 2 + LINE_HEIGHT * 2 

129 x_coord = (start[0] + end[0]) / 2 + PADDING 

130 for i, label in enumerate(labels): 

131 cursor += LINE_HEIGHT 

132 text_coords = (x_coord, cursor - LINE_HEIGHT / 2) 

133 text = Text(label, insert=text_coords, class_='bold' if not i else []) 

134 link.add(text) 

135 if len(description) > 0: 

136 link.set_desc("\n".join(description)) 

137 

138 self.add(link) 

139 

140 

141class CableTraceSVG: 

142 """ 

143 Generate a graphical representation of a CablePath in SVG format. 

144 

145 :param origin: The originating termination 

146 :param width: Width of the generated image (in pixels) 

147 :param base_url: Base URL for links within the SVG document. If none, links will be relative. 

148 """ 

149 def __init__(self, origin, width=CABLE_TRACE_SVG_DEFAULT_WIDTH, base_url=None): 

150 self.origin = origin 

151 self.width = width 

152 self.base_url = base_url.rstrip('/') if base_url is not None else '' 

153 

154 # Establish a cursor to track position on the y axis 

155 # Center edges on pixels to render sharp borders 

156 self.cursor = OFFSET 

157 

158 # Prep elements lists 

159 self.parent_objects = [] 

160 self.terminations = [] 

161 self.connectors = [] 

162 

163 @property 

164 def center(self): 

165 return self.width / 2 

166 

167 @classmethod 

168 def _get_labels(cls, instance): 

169 """ 

170 Return a list of text labels for the given instance based on model type. 

171 """ 

172 labels = [str(instance)] 

173 if instance._meta.model_name == 'device': 

174 labels.append(f'{instance.device_type.manufacturer} {instance.device_type}') 

175 location_label = f'{instance.site}' 

176 if instance.location: 

177 location_label += f' / {instance.location}' 

178 if instance.rack: 

179 location_label += f' / {instance.rack}' 

180 if instance.position: 

181 location_label += f' / {instance.get_face_display()}' 

182 location_label += f' / U{instance.position}' 

183 labels.append(location_label) 

184 elif instance._meta.model_name == 'circuit': 

185 labels[0] = f'Circuit {instance}' 

186 labels.append(instance.type) 

187 labels.append(instance.provider) 

188 if instance.description: 

189 labels.append(instance.description) 

190 elif instance._meta.model_name == 'circuittermination': 

191 if instance.xconnect_id: 

192 labels.append(f'{instance.xconnect_id}') 

193 elif instance._meta.model_name == 'providernetwork': 

194 labels.append(instance.provider) 

195 

196 return labels 

197 

198 @classmethod 

199 def _get_color(cls, instance): 

200 """ 

201 Return the appropriate fill color for an object within a cable path. 

202 """ 

203 if hasattr(instance, 'parent_object'): 

204 # Termination 

205 return getattr(instance, 'color', 'f0f0f0') or 'f0f0f0' 

206 if hasattr(instance, 'role'): 

207 # Device 

208 return instance.role.color 

209 if instance._meta.model_name == 'circuit' and instance.type.color: 

210 return instance.type.color 

211 # Other parent object 

212 return 'e0e0e0' 

213 

214 def draw_parent_objects(self, obj_list): 

215 """ 

216 Draw a set of parent objects (eg hosts, switched, patchpanels) and return all created nodes 

217 """ 

218 objects = [] 

219 width = self.width / len(obj_list) 

220 for i, obj in enumerate(obj_list): 

221 node = Node( 

222 position=(i * width, self.cursor), 

223 width=width, 

224 url=f'{self.base_url}{obj.get_absolute_url()}', 

225 color=self._get_color(obj), 

226 labels=self._get_labels(obj), 

227 object=obj 

228 ) 

229 objects.append(node) 

230 self.parent_objects.append(node) 

231 if i + 1 == len(obj_list): 

232 self.cursor += node.box['height'] 

233 return objects 

234 

235 def draw_object_terminations(self, terminations, offset_x, width): 

236 """ 

237 Draw all terminations belonging to an object with specified offset and width 

238 Return all created nodes and their maximum height 

239 """ 

240 nodes_height = 0 

241 nodes = [] 

242 for i, term in enumerate(terminations): 

243 node = Node( 

244 position=(offset_x + i * width, self.cursor), 

245 width=width, 

246 url=f'{self.base_url}{term.get_absolute_url()}', 

247 color=self._get_color(term), 

248 labels=self._get_labels(term), 

249 radius=5, 

250 object=term 

251 ) 

252 nodes_height = max(nodes_height, node.box['height']) 

253 nodes.append(node) 

254 return nodes, nodes_height 

255 

256 def draw_terminations(self, terminations, parent_object_nodes): 

257 """ 

258 Draw a row of terminating objects (e.g. interfaces) and return all created nodes 

259 Attach them to previously created parent objects 

260 """ 

261 nodes = [] 

262 nodes_height = 0 

263 

264 # Draw terminations for each parent object 

265 for parent in parent_object_nodes: 

266 parent_terms = [term for term in terminations if term.parent_object == parent.object] 

267 

268 # Width and offset(position) for each termination box 

269 width = parent.box['width'] / len(parent_terms) 

270 offset_x = parent.box['x'] 

271 

272 result, nodes_height = self.draw_object_terminations(parent_terms, offset_x, width) 

273 nodes.extend(result) 

274 

275 self.cursor += nodes_height 

276 self.terminations.extend(nodes) 

277 

278 return nodes 

279 

280 def draw_far_objects(self, obj_list, terminations): 

281 """ 

282 Draw the far-end objects and its terminations and return all created nodes 

283 """ 

284 # Make sure elements are sorted by name for readability 

285 objects = sorted(obj_list, key=lambda x: str(x)) 

286 width = self.width / len(objects) 

287 

288 # Max-height of created terminations 

289 terms_height = 0 

290 term_nodes = [] 

291 

292 # Draw the terminations by per object first 

293 for i, obj in enumerate(objects): 

294 obj_terms = [term for term in terminations if term.parent_object == obj] 

295 obj_pos = i * width 

296 result, result_nodes_height = self.draw_object_terminations(obj_terms, obj_pos, width / len(obj_terms)) 

297 

298 terms_height = max(terms_height, result_nodes_height) 

299 term_nodes.extend(result) 

300 

301 # Update cursor and draw the objects 

302 self.cursor += terms_height 

303 self.terminations.extend(term_nodes) 

304 object_nodes = self.draw_parent_objects(objects) 

305 

306 return object_nodes, term_nodes 

307 

308 def draw_fanin(self, target, terminations, color): 

309 """ 

310 Draw the fan-in-lines from each of the terminations to the targetpoint 

311 """ 

312 for term in terminations: 

313 points = ( 

314 term.bottom_center, 

315 (term.bottom_center[0], term.bottom_center[1] + FANOUT_LEG_HEIGHT), 

316 target, 

317 ) 

318 self.connectors.extend(( 

319 Polyline(points=points, class_='cable-shadow'), 

320 Polyline(points=points, style=f'stroke: #{color}'), 

321 )) 

322 

323 def draw_fanout(self, start, terminations, color): 

324 """ 

325 Draw the fan-out-lines from the startpoint to each of the terminations 

326 """ 

327 for term in terminations: 

328 points = ( 

329 term.top_center, 

330 (term.top_center[0], term.top_center[1] - FANOUT_LEG_HEIGHT), 

331 start, 

332 ) 

333 self.connectors.extend(( 

334 Polyline(points=points, class_='cable-shadow'), 

335 Polyline(points=points, style=f'stroke: #{color}'), 

336 )) 

337 

338 def draw_attachment(self): 

339 """ 

340 Return an SVG group containing a line element and "Attachment" label. 

341 """ 

342 group = Group(class_='connector') 

343 

344 # Draw attachment (line) 

345 start = (OFFSET + self.center, OFFSET + self.cursor) 

346 end = (start[0], start[1] + CABLE_HEIGHT) 

347 line = Line(start=start, end=end, class_='attachment') 

348 group.add(line) 

349 

350 return group 

351 

352 def render(self): 

353 """ 

354 Return an SVG document representing a cable trace. 

355 """ 

356 from dcim.models import Cable 

357 from wireless.models import WirelessLink 

358 

359 traced_path = self.origin.trace() 

360 

361 parent_object_nodes = [] 

362 # Iterate through each (terms, cable, terms) segment in the path 

363 for i, segment in enumerate(traced_path): 

364 near_ends, links, far_ends = segment 

365 

366 # This is segment number one. 

367 if i == 0: 

368 # If this is the first segment, draw the originating termination's parent object 

369 parent_object_nodes = self.draw_parent_objects(set(end.parent_object for end in near_ends)) 

370 # Else: No need to draw parent objects (parent objects are drawn in last "round" as the far-end!) 

371 

372 near_terminations = self.draw_terminations(near_ends, parent_object_nodes) 

373 

374 # Connector (a Cable or WirelessLink) 

375 if links and far_ends: 

376 self.cursor += CABLE_HEIGHT 

377 

378 obj_list = {end.parent_object for end in far_ends} 

379 parent_object_nodes, far_terminations = self.draw_far_objects(obj_list, far_ends) 

380 for cable in links: 

381 # Fill in labels and description with all available data 

382 description = [ 

383 f"Link {cable}", 

384 cable.get_status_display() 

385 ] 

386 near = [] 

387 far = [] 

388 color = '000000' 

389 if cable.description: 

390 description.append(f"{cable.description}") 

391 if isinstance(cable, Cable): 

392 labels = [f"{cable}"] if len(links) > 2 else [f"Cable {cable}", cable.get_status_display()] 

393 if cable.type: 

394 description.append(cable.get_type_display()) 

395 if cable.length and cable.length_unit: 

396 description.append(f"{cable.length} {cable.get_length_unit_display()}") 

397 color = cable.color or '000000' 

398 

399 # Collect all connected nodes to this cable. Channel subinterfaces are matched via their 

400 # parent interface, which carries the actual cable termination. 

401 near = _cable_side_nodes(near_terminations, cable.a_terminations) 

402 far = _cable_side_nodes(far_terminations, cable.b_terminations) 

403 if not (near and far): 

404 # a and b terminations may be swapped 

405 near = _cable_side_nodes(near_terminations, cable.b_terminations) 

406 far = _cable_side_nodes(far_terminations, cable.a_terminations) 

407 elif isinstance(cable, WirelessLink): 

408 labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()] 

409 if cable.ssid: 

410 description.append(f"{cable.ssid}") 

411 if cable.distance and cable.distance_unit: 

412 description.append(f"{cable.distance} {cable.get_distance_unit_display()}") 

413 near = [term for term in near_terminations if term.object == cable.interface_a] 

414 far = [term for term in far_terminations if term.object == cable.interface_b] 

415 if not (near and far): 

416 # a and b terminations may be swapped 

417 near = [term for term in near_terminations if term.object == cable.interface_b] 

418 far = [term for term in far_terminations if term.object == cable.interface_a] 

419 

420 # Select most-probable start and end position 

421 start = near[0].bottom_center 

422 end = far[0].top_center 

423 text_offset = 0 

424 

425 if len(near) > 1 and len(far) > 1: 

426 start_center = sum([pos.bottom_center[0] for pos in near]) / len(near) 

427 end_center = sum([pos.bottom_center[0] for pos in far]) / len(far) 

428 center_x = (start_center + end_center) / 2 

429 

430 start = (center_x, start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT) 

431 end = (center_x, end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT) 

432 text_offset -= (FANOUT_HEIGHT + FANOUT_LEG_HEIGHT) 

433 self.draw_fanin(start, near, color) 

434 self.draw_fanout(end, far, color) 

435 elif len(near) > 1: 

436 # Handle Fan-In - change start position to be directly below start 

437 start = (end[0], start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT) 

438 self.draw_fanin(start, near, color) 

439 text_offset -= FANOUT_HEIGHT + FANOUT_LEG_HEIGHT 

440 elif len(far) > 1: 

441 # Handle Fan-Out - change end position to be directly above end 

442 end = (start[0], end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT) 

443 self.draw_fanout(end, far, color) 

444 text_offset -= FANOUT_HEIGHT 

445 

446 # Create the connector 

447 connector = Connector( 

448 start=start, 

449 end=end, 

450 color=color, 

451 wireless=isinstance(cable, WirelessLink), 

452 url=f'{self.base_url}{cable.get_absolute_url()}', 

453 text_offset=text_offset, 

454 labels=labels, 

455 description=description 

456 ) 

457 self.connectors.append(connector) 

458 

459 # Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with 

460 # a CircuitTermination) 

461 elif far_ends: 

462 # Attachment 

463 attachment = self.draw_attachment() 

464 self.connectors.append(attachment) 

465 self.cursor += CABLE_HEIGHT 

466 

467 # Object 

468 parent_object_nodes = self.draw_parent_objects(far_ends) 

469 

470 # Determine drawing size 

471 self.drawing = svgwrite.Drawing( 

472 size=(self.width, self.cursor + 2) 

473 ) 

474 

475 # Attach CSS stylesheet 

476 with open(f'{settings.STATIC_ROOT}/cable_trace.css') as css_file: 

477 self.drawing.defs.add(self.drawing.style(css_file.read())) 

478 

479 # Add elements to the drawing in order of depth (Z axis) 

480 for element in self.connectors + self.parent_objects + self.terminations: 

481 self.drawing.add(element) 

482 

483 return self.drawing