Coverage for dcim/utils.py: 16%

147 statements  

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

1from collections import defaultdict 

2 

3from django.apps import apps 

4from django.contrib.contenttypes.models import ContentType 

5from django.db import router, transaction 

6from django.utils.translation import gettext as _ 

7 

8from dcim.constants import MODULE_TOKEN 

9 

10 

11def inherit_module_token(position, parent_positions): 

12 """ 

13 Resolve a single {module} token in a bay position by inheriting from the position 

14 one level deeper in a module bay hierarchy. Returns position unchanged unless 

15 parent_positions is non-empty and position contains {module}, in which case the 

16 token is substituted with parent_positions[-1]. 

17 

18 Used by resolve_position_chain(), the single inheritance implementation shared by 

19 get_module_bay_positions() and the module move planner. 

20 """ 

21 if parent_positions and MODULE_TOKEN in position: 

22 return position.replace(MODULE_TOKEN, parent_positions[-1]) 

23 return position 

24 

25 

26def get_module_bay_raw_positions(module_bay): 

27 """ 

28 Given a module bay, traverse up the module hierarchy and return the stored 

29 (unresolved) bay position strings from root to leaf. 

30 

31 Raises ValueError if the module bay hierarchy contains a cycle. 

32 """ 

33 positions = [] 

34 visited = set() 

35 while module_bay: 

36 if module_bay.pk in visited: 

37 raise ValueError(_("Module bay hierarchy contains a cycle.")) 

38 visited.add(module_bay.pk) 

39 positions.append(module_bay.position or '') 

40 module_bay = module_bay.module.module_bay if module_bay.module else None 

41 positions.reverse() 

42 return positions 

43 

44 

45def resolve_position_chain(raw_positions): 

46 """ 

47 Apply leaf-to-root {module} token inheritance over a root-to-leaf list of raw bay 

48 positions: each position inherits from the resolved position one level deeper, and 

49 the leaf's own token is never resolved. Shared by get_module_bay_positions() and 

50 the module move planner so a planned chain always equals what a fresh walk 

51 computes once the planned positions are stored. 

52 """ 

53 resolved = [] 

54 for position in reversed(raw_positions): 

55 resolved.append(inherit_module_token(position, resolved)) 

56 resolved.reverse() 

57 return resolved 

58 

59 

60def get_module_bay_positions(module_bay): 

61 """ 

62 Given a module bay, traverse up the module hierarchy and return a list of bay 

63 position strings from root to leaf, resolving any {module} tokens in each 

64 position using the parent position (position inheritance). 

65 

66 Raises ValueError if the module bay hierarchy contains a cycle. 

67 """ 

68 return resolve_position_chain(get_module_bay_raw_positions(module_bay)) 

69 

70 

71def resolve_module_placeholder(value, positions): 

72 """ 

73 Resolve {module} placeholder tokens in a string using the given 

74 list of module bay positions (ordered root to leaf). 

75 

76 A single {module} token resolves to the leaf (immediate parent) bay's position. 

77 Multiple tokens must match the tree depth and resolve level-by-level. 

78 

79 Returns the resolved string. 

80 Raises ValueError if token count is greater than 1 and doesn't match tree depth. 

81 """ 

82 if MODULE_TOKEN not in value: 

83 return value 

84 

85 token_count = value.count(MODULE_TOKEN) 

86 if token_count == 1: 

87 return value.replace(MODULE_TOKEN, positions[-1]) 

88 if token_count == len(positions): 

89 for pos in positions: 

90 value = value.replace(MODULE_TOKEN, pos, 1) 

91 return value 

92 raise ValueError( 

93 _("Cannot install module with placeholder values in a module bay tree " 

94 "{level} levels deep but {tokens} placeholders given.").format( 

95 level=len(positions), tokens=token_count 

96 ) 

97 ) 

98 

99 

100def compile_path_node(ct_id, object_id): 

101 return f'{ct_id}:{object_id}' 

102 

103 

104def decompile_path_node(repr): 

105 ct_id, object_id = repr.split(':') 

106 return int(ct_id), int(object_id) 

107 

108 

109def object_to_path_node(obj): 

110 """ 

111 Return a representation of an object suitable for inclusion in a CablePath path. Node representation is in the 

112 form <ContentType ID>:<Object ID>. 

113 """ 

114 ct = ContentType.objects.get_for_model(obj) 

115 return compile_path_node(ct.pk, obj.pk) 

116 

117 

118def path_node_to_object(repr): 

119 """ 

120 Given the string representation of a path node, return the corresponding instance. If the object no longer 

121 exists, return None. 

122 """ 

123 ct_id, object_id = decompile_path_node(repr) 

124 ct = ContentType.objects.get_for_id(ct_id) 

125 return ct.model_class().objects.filter(pk=object_id).first() 

126 

127 

128def create_cablepaths(objects): 

129 """ 

130 Create CablePaths for all paths originating from the specified set of nodes. 

131 

132 :param objects: Iterable of cabled objects (e.g. Interfaces) 

133 """ 

134 from dcim.models import CablePath, Interface 

135 

136 # Expand any channelized interface into its channel subinterfaces. A channelized parent originates no path of its 

137 # own; instead, each channel subinterface traces independently from the single connector position it occupies. 

138 # Plain (non-channelized) origins pass through unchanged, keeping this expansion re-entrant so that 

139 # rebuild_paths() -> create_cablepaths(cp.origins) does not re-expand the channel subinterfaces it already holds. 

140 expanded = [] 

141 for obj in objects: 

142 if isinstance(obj, Interface) and obj.channels: 

143 expanded.extend(obj.child_interfaces.filter(channel_id__isnull=False, cable__isnull=False)) 

144 else: 

145 expanded.append(obj) 

146 

147 # Arrange objects by cable connector. All objects with a null connector are grouped together. Channel 

148 # subinterfaces must each originate their own path, as sharing a connector would otherwise collapse a group of 

149 # siblings into a single malformed path. 

150 origins = defaultdict(list) 

151 for obj in expanded: 

152 if isinstance(obj, Interface) and obj.channel_id: 

153 if cp := CablePath.from_origin([obj]): 

154 cp.save() 

155 else: 

156 origins[obj.cable_connector].append(obj) 

157 

158 for connector, objects in origins.items(): 

159 if cp := CablePath.from_origin(objects): 

160 cp.save() 

161 

162 

163def rebuild_paths(terminations): 

164 """ 

165 Rebuild all CablePaths which traverse the specified nodes. 

166 """ 

167 from dcim.models import CablePath 

168 

169 for obj in terminations: 

170 cable_paths = CablePath.objects.filter(_nodes__contains=obj) 

171 

172 with transaction.atomic(using=router.db_for_write(CablePath)): 

173 for cp in cable_paths: 

174 cp.delete() 

175 create_cablepaths(cp.origins) 

176 

177 

178def rebuild_cable_paths(cable): 

179 """ 

180 Delete and rebuild every CablePath affected by the given Cable, tracing freshly from the Cable's current 

181 terminations and from the origins of the affected paths. Used when a Cable's connectivity must be reconciled 

182 without its own save() having traced it: the channelization of a terminated interface has changed, or the Cable 

183 was written by a process which bypasses save(). 

184 """ 

185 from dcim.choices import CableEndChoices 

186 from dcim.models import CablePath, CableTermination, PathEndpoint 

187 

188 with transaction.atomic(using=router.db_for_write(CablePath)): 

189 a_terminations, b_terminations = [], [] 

190 for ct in CableTermination.objects.filter(cable=cable).prefetch_related('termination'): 

191 if ct.cable_end == CableEndChoices.SIDE_A: 

192 a_terminations.append(ct.termination) 

193 else: 

194 b_terminations.append(ct.termination) 

195 

196 # Every path traversing the Cable, plus those traversing a termination which is not itself a path endpoint: 

197 # the latter may not reach the Cable yet (e.g. an incomplete path through a pass-through port which this 

198 # Cable completes). 

199 affected = {cp.pk: cp for cp in CablePath.objects.filter(_nodes__contains=cable)} 

200 for termination in (*a_terminations, *b_terminations): 

201 if not isinstance(termination, PathEndpoint): 

202 affected.update({cp.pk: cp for cp in CablePath.objects.filter(_nodes__contains=termination)}) 

203 

204 # Record each affected path's originating node(s) before deleting it. These are kept as compiled path 

205 # nodes; resolving them to objects is deferred to the paths which actually need restoring. 

206 origin_keys = {tuple(cp.path[0]) for cp in affected.values()} 

207 

208 # Delete existing paths individually so each clears its `_path` back-reference on the originating endpoints. 

209 for cp in affected.values(): 

210 cp.delete() 

211 

212 # Trace from the Cable's own terminations first, so that a channelized origin is expanded into its channel 

213 # subinterfaces exactly once 

214 for nodes in (a_terminations, b_terminations): 

215 if nodes and isinstance(nodes[0], PathEndpoint): 

216 create_cablepaths(nodes) 

217 retraced = {tuple(cp.path[0]) for cp in CablePath.objects.filter(_nodes__contains=cable)} 

218 

219 # Restore the affected paths which merely passed through the Cable: those originate elsewhere, so the 

220 # tracing above cannot reproduce them. 

221 for key in origin_keys - retraced: 

222 nodes = [obj for node in key if (obj := path_node_to_object(node))] 

223 if not nodes: 

224 continue 

225 

226 # A path endpoint terminating this Cable belongs to the tracing above: that it produced no path 

227 # means the origin no longer has one (e.g. a channel subinterface moved to another parent). 

228 if any(isinstance(obj, PathEndpoint) and obj.cable_id == cable.pk for obj in nodes): 

229 continue 

230 

231 # Nor restore an origin whose path has already been traced through another Cable 

232 if key in {tuple(cp.path[0]) for cp in CablePath.objects.filter(_nodes__contains=nodes[0])}: 

233 continue 

234 

235 create_cablepaths(nodes) 

236 

237 

238def update_interface_parents(device, interface_templates, module=None): 

239 """ 

240 Used for device and module instantiation. Iterates all InterfaceTemplates with a parent assigned and applies it to 

241 the actual interfaces. Must run after all interfaces have been instantiated (so that every parent interface exists) 

242 and before update_interface_bridges() (so that channel subinterfaces validate against a populated parent). 

243 """ 

244 Interface = apps.get_model('dcim', 'Interface') 

245 

246 for interface_template in interface_templates.exclude(parent=None): 246 ↛ 247line 246 didn't jump to line 247 because the loop on line 246 never started

247 interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module)) 

248 interface.parent = Interface.objects.get( 

249 device=device, 

250 name=interface_template.parent.resolve_name(module=module) 

251 ) 

252 interface.full_clean() 

253 interface.save() 

254 

255 

256def update_interface_bridges(device, interface_templates, module=None): 

257 """ 

258 Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned 

259 and applies it to the actual interfaces. 

260 """ 

261 Interface = apps.get_model('dcim', 'Interface') 

262 

263 for interface_template in interface_templates.exclude(bridge=None): 263 ↛ 264line 263 didn't jump to line 264 because the loop on line 263 never started

264 interface = Interface.objects.get( 

265 device=device, 

266 name=interface_template.resolve_name(module=module, device=device) 

267 ) 

268 

269 if interface_template.bridge: 

270 interface.bridge = Interface.objects.get( 

271 device=device, 

272 name=interface_template.bridge.resolve_name(module=module, device=device) 

273 ) 

274 interface.full_clean() 

275 interface.save() 

276 

277 

278def create_port_mappings(device, device_or_module_type, module=None): 

279 """ 

280 Replicate all front/rear port mappings from a DeviceType or ModuleType to the given device. 

281 """ 

282 from dcim.models import FrontPort, PortMapping, RearPort 

283 

284 templates = device_or_module_type.port_mappings.prefetch_related('front_port', 'rear_port') 

285 

286 # Cache front & rear ports for efficient lookups by name 

287 front_ports = { 

288 fp.name: fp for fp in FrontPort.objects.filter(device=device) 

289 } 

290 rear_ports = { 

291 rp.name: rp for rp in RearPort.objects.filter(device=device) 

292 } 

293 

294 # Replicate PortMappings 

295 mappings = [] 

296 for template in templates: 296 ↛ 297line 296 didn't jump to line 297 because the loop on line 296 never started

297 front_port = front_ports.get(template.front_port.resolve_name(module=module, device=device)) 

298 rear_port = rear_ports.get(template.rear_port.resolve_name(module=module, device=device)) 

299 mappings.append( 

300 PortMapping( 

301 device_id=front_port.device_id, 

302 front_port=front_port, 

303 front_port_position=template.front_port_position, 

304 rear_port=rear_port, 

305 rear_port_position=template.rear_port_position, 

306 ) 

307 ) 

308 # Bulk-created (no per-mapping ObjectChange) to match how every other component is instantiated. 

309 PortMapping.objects.bulk_create(mappings) 

310 

311 

312def reconcile_port_mappings(mapping_model, parent_field, parent, desired): 

313 """ 

314 Reconcile a parent port's mappings against `desired`, writing only the difference so unchanged 

315 mappings keep their PK (and emit no changelog entry). Changed/removed rows are deleted before 

316 replacements are created, all in one transaction, so position swaps don't trip the unique 

317 constraint. Per-row create()/delete() let the change-logging signals fire naturally. 

318 

319 Args: 

320 mapping_model: PortMapping or PortTemplateMapping. 

321 parent_field: 'front_port' or 'rear_port' — the side being edited; its '<parent_field>_position' 

322 is each mapping's stable identity within the set. 

323 parent: the parent instance (FrontPort/RearPort or their templates). 

324 desired: iterable of dicts of mapping field values EXCLUDING the parent FK, using '<field>_id' 

325 for the opposite-port FK, e.g. {'front_port_position': 1, 'rear_port_id': 5, 

326 'rear_port_position': 2}. save() derives device/device_type/module_type from the front port. 

327 """ 

328 key_field = f'{parent_field}_position' 

329 other_field = 'rear_port' if parent_field == 'front_port' else 'front_port' 

330 value_fields = (f'{other_field}_id', f'{other_field}_position') 

331 

332 def target(source): 

333 # The comparable "value" of a mapping: the opposite port and its position. Two mappings with 

334 # the same parent-side position but a different target represent a re-pointing of that slot. 

335 get = source.get if isinstance(source, dict) else lambda f: getattr(source, f) 

336 return tuple(get(f) for f in value_fields) 

337 

338 desired_by_key = {d[key_field]: d for d in desired} 

339 

340 with transaction.atomic(using=router.db_for_write(mapping_model)): 

341 # Lock the parent's existing mappings for the duration of the reconcile. Two requests editing 

342 # the same port would otherwise read the same snapshot and race, the second colliding on a 

343 # unique constraint when it recreates rows the first has already committed. 

344 existing = { 

345 getattr(m, key_field): m 

346 for m in mapping_model.objects.filter(**{parent_field: parent}).select_for_update() 

347 } 

348 

349 # Delete rows that no longer exist or whose target changed (before creating, to free the slots) 

350 for key, mapping in existing.items(): 

351 if key not in desired_by_key or target(mapping) != target(desired_by_key[key]): 

352 mapping.delete() 

353 

354 # Create rows that are new or whose target changed 

355 for key, attrs in desired_by_key.items(): 

356 if key not in existing or target(existing[key]) != target(attrs): 

357 mapping_model.objects.create(**{parent_field: parent, **attrs})