Coverage for netbox_data_import/cable_target.py: 98%

747 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-09 20:50 +0000

1# SPDX-License-Identifier: Apache-2.0 

2# SPDX-FileCopyrightText: 2026 Marcin Zieba <marcinpsk@gmail.com> 

3"""The Cable Target Module: Patched Path Replacement planning and writes. 

4 

5Section 6 gives one complete Source Trace one Synchronization Unit: it deletes the single direct 

6Logical Cable when one exists, then creates every physical segment the trace states. It reads the 

7decisions and the Cable policy the Import Profile holds, and it writes Cables and provenance rows. 

8 

9It consumes Source Traces by output kind, so it imports no Source Adapter. It reads PortMapping 

10rows and never writes one, and it never creates or mutates a CablePath. 

11 

12Live target state that no Planned Change carries joins the unit through an `info` diagnostic. 

13Visible reused Cables and PortMapping rows contribute identities. Hidden Cable drift contributes 

14only its diagnostic code. Execution replans inside its transaction, so a material change makes the 

15accepted unit stale and rolls the write back. 

16""" 

17 

18from __future__ import annotations 

19 

20from dataclasses import dataclass, field 

21from typing import Any 

22 

23from .catalog import OutputKind, TargetModuleKey 

24from .field_keys import ( 

25 FRONT_PORT_KIND, 

26 INTERFACE_KIND, 

27 MAPPED_PEER_ROLE, 

28 REAR_PORT_KIND, 

29 SELECT_TERMINATION_TASK, 

30 TERMINATION_ROLE, 

31 claimed_termination_kind, 

32 parse_termination_field_key, 

33 same_device_and_cards, 

34 termination_field_key, 

35) 

36from .object_permissions import enforce_saved_object_permission 

37from .plan import Diagnostic, Disposition, PlannedChange, Severity, SynchronizationUnit 

38from .target_runtime import DeletedObject, PreconditionFailed 

39from .values import identity_text, source_text 

40 

41CABLE_STATUS = "connected" 

42ELIGIBLE_TERMINATION_LIMIT = 20 

43 

44# Section 10.2 badge vocabulary. T8 adds the proposal states to the same set. 

45UNRESOLVED = "unresolved" 

46AUTOMATICALLY_RESOLVED = "automatically resolved" 

47MANUALLY_RESOLVED = "manually resolved" 

48 

49CREATE_SEGMENT = "create" 

50REUSE_SEGMENT = "reuse existing" 

51CONFLICT_SEGMENT = "conflict" 

52_CONFLICT_CODES = frozenset( 

53 { 

54 "cable.termination_occupied", 

55 "cable.multi_termination_conflict", 

56 "cable.planned_termination_conflict", 

57 "cable.resolved_segment_conflict", 

58 } 

59) 

60 

61_KIND_BY_MODEL_NAME = { 

62 "interface": INTERFACE_KIND, 

63 "frontport": FRONT_PORT_KIND, 

64 "rearport": REAR_PORT_KIND, 

65} 

66_SUPPORTED_TERMINATION_LABELS = frozenset(f"dcim.{name}" for name in _KIND_BY_MODEL_NAME) 

67_READER_ACCESSOR_BY_KIND = { 

68 INTERFACE_KIND: "interfaces", 

69 FRONT_PORT_KIND: "front_ports", 

70 REAR_PORT_KIND: "rear_ports", 

71} 

72_VIEW_PERMISSION_BY_KIND = { 

73 INTERFACE_KIND: "dcim.view_interface", 

74 FRONT_PORT_KIND: "dcim.view_frontport", 

75 REAR_PORT_KIND: "dcim.view_rearport", 

76} 

77 

78 

79@dataclass(frozen=True) 

80class EligibleTerminations: 

81 """One capped page of eligible terminations and its uncapped matching total.""" 

82 

83 candidates: tuple[Any, ...] 

84 total: int 

85 

86 

87def _object_type_label(obj) -> str: 

88 """Return the ``app_label.model_name`` key one target object is recorded under.""" 

89 return f"{obj._meta.app_label}.{obj._meta.model_name}" 

90 

91 

92def _model_for_label(label: str): 

93 """Return the model class one recorded object-type label names.""" 

94 from django.apps import apps 

95 

96 app_label, _, model_name = label.partition(".") 

97 return apps.get_model(app_label, model_name) 

98 

99 

100def _object_identity(label: str, object_id: int) -> str: 

101 """Return the plan identity string for one target object.""" 

102 return f"{label}:{object_id}" 

103 

104 

105@dataclass(frozen=True) 

106class _Termination: 

107 """One source Termination Reference bound to a NetBox object.""" 

108 

109 object_type: str 

110 object_id: int 

111 kind: str 

112 device_id: int 

113 display: str 

114 

115 @property 

116 def key(self) -> tuple[str, int]: 

117 """Return the comparison key that names this object across queries.""" 

118 return self.object_type, self.object_id 

119 

120 @property 

121 def identity(self) -> str: 

122 """Return the plan identity string for this object.""" 

123 return _object_identity(self.object_type, self.object_id) 

124 

125 def as_json(self) -> list: 

126 """Return the serializable object and resolved Device a creation precondition carries.""" 

127 return [self.object_type, self.object_id, self.device_id] 

128 

129 

130@dataclass(frozen=True) 

131class _DesiredSegment: 

132 """One physical Cable the trace states.""" 

133 

134 index: int 

135 left: _Termination 

136 right: _Termination 

137 cable_class: str 

138 

139 @property 

140 def key(self) -> str: 

141 """Return the direction-independent key two traces share for one identical segment.""" 

142 first, second = sorted((self.left.key, self.right.key)) 

143 return f"{_object_identity(*first)}|{_object_identity(*second)}" 

144 

145 @property 

146 def change_identity(self) -> str: 

147 """Return the Planned Change identity this segment creates under.""" 

148 return f"cable:create:{self.key}" 

149 

150 @property 

151 def terminations(self) -> tuple[_Termination, _Termination]: 

152 """Return both ends in canonical order.""" 

153 return self.left, self.right 

154 

155 def as_json(self) -> list: 

156 """Return the sorted termination records a payload and a precondition carry.""" 

157 return sorted((self.left.as_json(), self.right.as_json())) 

158 

159 

160@dataclass(frozen=True) 

161class _ExistingCable: 

162 """One existing Cable and the termination sets it holds.""" 

163 

164 cable: Any 

165 a_side: frozenset 

166 b_side: frozenset 

167 

168 @property 

169 def multi_termination(self) -> bool: 

170 """Return whether either side holds more than one termination.""" 

171 return len(self.a_side) > 1 or len(self.b_side) > 1 

172 

173 @property 

174 def terminations(self) -> list: 

175 """Return the sorted termination pairs a precondition records.""" 

176 return sorted([label, object_id] for label, object_id in self.a_side | self.b_side) 

177 

178 def satisfies(self, segment: _DesiredSegment) -> bool: 

179 """Return whether this Cable is exactly the unordered pair *segment* states.""" 

180 return self.connects(segment.left, segment.right) 

181 

182 def connects(self, first: _Termination, second: _Termination) -> bool: 

183 """Return whether this Cable directly joins the two given terminations, one end each.""" 

184 return (self.a_side, self.b_side) in ( 

185 ({first.key}, {second.key}), 

186 ({second.key}, {first.key}), 

187 ) 

188 

189 

190def _delete_identity(cable_pk: int) -> str: 

191 """Return the Planned Change identity that removes one Logical Cable. 

192 

193 The unit's dependency graph and the deletion change both name it, so one helper states it. 

194 """ 

195 return f"cable:delete:{cable_pk}" 

196 

197 

198@dataclass 

199class _TraceAnalysis: 

200 """What one Source Trace contributes, built in planning order.""" 

201 

202 trace: Any 

203 identity: str 

204 display: dict 

205 diagnostics: list = field(default_factory=list) 

206 endpoints: tuple = () 

207 segments: list = field(default_factory=list) 

208 proven: dict = field(default_factory=dict) 

209 policies: dict = field(default_factory=dict) 

210 terminations: dict = field(default_factory=dict) 

211 topology_read: bool = False 

212 logical_cable: Any = None 

213 retains_logical_cable: bool = False 

214 blocked: bool = False 

215 invalid: bool = False 

216 

217 @property 

218 def stopped(self) -> bool: 

219 """Return whether a finding makes further planning for this trace meaningless.""" 

220 return self.blocked or self.invalid 

221 

222 @property 

223 def pending(self) -> list: 

224 """Return the desired segments no existing Cable already proves.""" 

225 return [segment for segment in self.segments if segment.index not in self.proven] 

226 

227 @property 

228 def deleted_logical_cable(self): 

229 """Return the Logical Cable this trace removes, which a Cable that proves its path is not.""" 

230 return None if self.retains_logical_cable else self.logical_cable 

231 

232 @property 

233 def delete_identity(self) -> str | None: 

234 """Return the Planned Change identity that removes this trace's Logical Cable.""" 

235 removed = self.deleted_logical_cable 

236 return None if removed is None else _delete_identity(removed.cable.pk) 

237 

238 def error(self, code: str, display: dict, identities=()) -> None: 

239 """Record one blocking or invalidating finding.""" 

240 self.diagnostics.append( 

241 Diagnostic(code=code, severity=Severity.ERROR, identities=tuple(identities), display=display) 

242 ) 

243 

244 def note(self, code: str, display: dict, identities=()) -> None: 

245 """Record one review note whose identities keep the unit honest about live state.""" 

246 self.diagnostics.append( 

247 Diagnostic(code=code, severity=Severity.INFO, identities=tuple(identities), display=display) 

248 ) 

249 

250 def block(self, code: str, display: dict, identities=()) -> None: 

251 """Record a finding an operator decision or a NetBox correction can resolve.""" 

252 self.error(code, display, identities) 

253 self.blocked = True 

254 

255 def refuse(self, code: str, display: dict, identities=()) -> None: 

256 """Record a finding no decision inside this plugin can resolve.""" 

257 self.error(code, display, identities) 

258 self.invalid = True 

259 

260 

261def _endpoint_label(reference) -> str: 

262 """Return the operator-facing name of one Termination Reference.""" 

263 parts = [source_text(reference.device), source_text(reference.cards), source_text(reference.port)] 

264 return " ".join(part for part in parts if part) 

265 

266 

267def _reference_display(reference) -> dict: 

268 """Return the source values one Termination Reference states.""" 

269 return { 

270 "device": source_text(reference.device), 

271 "cards": source_text(reference.cards), 

272 "port": source_text(reference.port), 

273 "port_class": source_text(reference.port_class), 

274 } 

275 

276 

277def _field_key(reference, role: str = TERMINATION_ROLE) -> str: 

278 """Return the canonical field key one Termination Reference resolves under.""" 

279 return termination_field_key( 

280 device=reference.device, 

281 cards=reference.cards, 

282 port=reference.port, 

283 kind=claimed_termination_kind(reference.port_class), 

284 role=role, 

285 ) 

286 

287 

288def _source_record(trace, segment_index: int) -> dict: 

289 """Return the provenance one Source Trace contributes to one created Cable.""" 

290 provenance = trace.provenance[0] 

291 return { 

292 "trace_identity": trace.identity, 

293 "segment_index": segment_index, 

294 "from_text": provenance.from_text, 

295 "to_text": provenance.to_text, 

296 "direction": provenance.direction, 

297 "workbook_fingerprint": provenance.workbook_fingerprint, 

298 "sheet": provenance.sheet, 

299 "block_ordinal": provenance.block_ordinal, 

300 "row_start": provenance.row_start, 

301 "row_end": provenance.row_end, 

302 "export_timestamp": provenance.export_timestamp, 

303 } 

304 

305 

306def _visible_devices(netbox_reader, names) -> dict[str, list]: 

307 """Return the visible Devices at the import target, grouped by comparison name.""" 

308 from django.db.models import Q 

309 

310 wanted = {identity_text(name) for name in names if identity_text(name)} 

311 if not wanted: 

312 return {} 

313 devices = netbox_reader.devices() 

314 if netbox_reader.site is not None: 

315 devices = devices.filter(site=netbox_reader.site) 

316 lookup = Q() 

317 for name in sorted(wanted): 

318 lookup |= Q(name__iexact=name) 

319 grouped: dict[str, list] = {} 

320 for device in devices.filter(lookup): 

321 comparison = identity_text(device.name) 

322 if comparison in wanted: 

323 grouped.setdefault(comparison, []).append(device) 

324 return grouped 

325 

326 

327def eligible_terminations( 

328 field_key: str, 

329 netbox_reader, 

330 *, 

331 profile, 

332 search: str = "", 

333 limit: int = ELIGIBLE_TERMINATION_LIMIT, 

334) -> EligibleTerminations: 

335 """Return one page of candidates for a canonical termination field key. 

336 

337 The termination role offers the claimed kind on the resolved Device. The mapped-peer role starts 

338 from the profile's saved base resolution or the exact-name match, then offers its opposite ports. 

339 The reader keeps both inside the actor's view scope. The picker and a proposal request share this 

340 query. 

341 """ 

342 parsed = parse_termination_field_key(field_key) 

343 device_name, kind = parsed["device"], parsed["kind"] 

344 accessor = _READER_ACCESSOR_BY_KIND[kind] 

345 devices = _visible_devices(netbox_reader, [device_name]).get(identity_text(device_name), []) 

346 if len(devices) != 1: 

347 return EligibleTerminations(candidates=(), total=0) 

348 device = devices[0] 

349 candidates = getattr(netbox_reader, accessor)().filter(device_id=device.pk) 

350 if parsed["role"] == MAPPED_PEER_ROLE: 

351 from .models import TerminationResolution, index_digest 

352 

353 base_field_key = termination_field_key( 

354 device=parsed["device"], 

355 cards=parsed["cards"], 

356 port=parsed["port"], 

357 kind=kind, 

358 role=TERMINATION_ROLE, 

359 ) 

360 stored = ( 

361 TerminationResolution.objects.filter( 

362 profile=profile, 

363 task_type=SELECT_TERMINATION_TASK, 

364 field_key=base_field_key, 

365 field_key_digest=index_digest(base_field_key), 

366 ) 

367 .select_related("selected_object_type") 

368 .first() 

369 ) 

370 if stored is None: 

371 resolved = [candidate for candidate in candidates if identity_text(candidate.name) == parsed["port"]] 

372 else: 

373 resolved = [] 

374 expected_label = _object_type_label(candidates.model) 

375 selected_label = f"{stored.selected_object_type.app_label}.{stored.selected_object_type.model}" 

376 if selected_label == expected_label: 

377 selected = candidates.filter(pk=stored.selected_object_id).first() 

378 if selected is not None: 

379 resolved.append(selected) 

380 if len(resolved) != 1 or kind == INTERFACE_KIND: 

381 return EligibleTerminations(candidates=(), total=0) 

382 if kind == FRONT_PORT_KIND: 

383 peer_kind = REAR_PORT_KIND 

384 peer_ids = ( 

385 netbox_reader.port_mappings() 

386 .filter(front_port_id=resolved[0].pk) 

387 .values_list("rear_port_id", flat=True) 

388 ) 

389 else: 

390 peer_kind = FRONT_PORT_KIND 

391 peer_ids = ( 

392 netbox_reader.port_mappings() 

393 .filter(rear_port_id=resolved[0].pk) 

394 .values_list("front_port_id", flat=True) 

395 ) 

396 candidates = getattr(netbox_reader, _READER_ACCESSOR_BY_KIND[peer_kind])().filter( 

397 device_id=device.pk, 

398 pk__in=peer_ids, 

399 ) 

400 if search: 

401 candidates = candidates.filter(name__icontains=search) 

402 candidates = candidates.order_by("name", "pk") 

403 return EligibleTerminations(candidates=tuple(candidates[:limit]), total=candidates.count()) 

404 

405 

406class _CableBatch: 

407 """Plan every Source Trace in one batch, so an identical shared segment plans once.""" 

408 

409 def __init__(self, traces, profile, netbox_reader, *, lock_plan_references: bool = False): 

410 self.profile = profile 

411 self.reader = netbox_reader 

412 self.actor = netbox_reader.actor 

413 self.lock_plan_references = lock_plan_references 

414 self.analyses = [self._new_analysis(trace) for trace in traces] 

415 self._objects: dict[tuple[str, int], Any] = {} 

416 self._components: dict[tuple[int, str], dict[str, list]] = {} 

417 self._resolved: dict[str, dict[tuple, _Termination]] = {} 

418 self._mappings_by_front: dict[int, list] = {} 

419 self._mappings_by_rear: dict[int, list] = {} 

420 self._mapping_ports: dict[tuple[str, int], Any] = {} 

421 self._existing: dict[int, _ExistingCable] = {} 

422 self._occupied: dict[tuple[str, int], _ExistingCable] = {} 

423 self._mapping_rows: dict[str, Any] | None = None 

424 self._stored = self._stored_resolutions() 

425 self._devices = self._load_devices() 

426 self._resolve_terminations() 

427 self._load_mappings() 

428 self._build_segments() 

429 self._lock_segment_terminations() 

430 self._load_existing_cables() 

431 self._classify() 

432 self._decide() 

433 self._block_planned_termination_conflicts() 

434 self._refuse_conflicting_creations() 

435 self._deletes_by_segment = self._shared_deletes() 

436 self._sources_by_segment = self._shared_sources() 

437 

438 def units(self) -> list[SynchronizationUnit]: 

439 """Return one Synchronization Unit per Source Trace, in batch order.""" 

440 return [self._unit(analysis) for analysis in self.analyses] 

441 

442 def _new_analysis(self, trace) -> _TraceAnalysis: 

443 """Return the analysis record one Source Trace starts from.""" 

444 summary = trace.endpoint_summary 

445 provenance = trace.provenance[0] 

446 display = { 

447 "name": f"{_endpoint_label(summary.from_termination)} to {_endpoint_label(summary.to_termination)}", 

448 "row_number": provenance.row_start, 

449 "sheet": provenance.sheet, 

450 "trace_identity": trace.identity, 

451 "from_text": provenance.from_text, 

452 "to_text": provenance.to_text, 

453 "segment_count": len(trace.segments), 

454 } 

455 analysis = _TraceAnalysis(trace=trace, identity=f"cable:trace:{trace.identity}", display=display) 

456 for error in trace.errors: 

457 analysis.refuse(error.code, {"message": error.message, "row_number": error.row_number}) 

458 return analysis 

459 

460 @staticmethod 

461 def _references(trace) -> list: 

462 """Return every Termination Reference one trace resolves, endpoints included.""" 

463 summary = trace.endpoint_summary 

464 references = [summary.from_termination, summary.to_termination] 

465 for segment in trace.segments: 

466 references.extend((segment.left, segment.right)) 

467 return references 

468 

469 def _stored_resolutions(self) -> dict[str, Any]: 

470 """Return the operator's saved termination decisions, keyed by field key.""" 

471 from .models import TerminationResolution 

472 

473 field_keys = set() 

474 for analysis in self.analyses: 

475 if analysis.stopped: 

476 continue 

477 for reference in self._references(analysis.trace): 

478 field_keys.add(_field_key(reference)) 

479 field_keys.add(_field_key(reference, MAPPED_PEER_ROLE)) 

480 if not field_keys: 

481 return {} 

482 from .models import index_digest 

483 

484 # The unique constraint indexes the digest, and nothing indexes the unbounded field key. 

485 rows = TerminationResolution.objects.filter( 

486 profile=self.profile, 

487 task_type=SELECT_TERMINATION_TASK, 

488 field_key_digest__in=sorted(index_digest(key) for key in field_keys), 

489 ).select_related("selected_object_type") 

490 return {row.field_key: row for row in rows} 

491 

492 def _load_devices(self) -> dict[str, list]: 

493 """Return the visible Devices every planned trace names, grouped by comparison name.""" 

494 names = [ 

495 reference.device 

496 for analysis in self.analyses 

497 if not analysis.stopped 

498 for reference in self._references(analysis.trace) 

499 ] 

500 return _visible_devices(self.reader, names) 

501 

502 def _components_for(self, device_id: int, kind: str) -> dict[str, list]: 

503 """Return one Device's terminations of one kind, grouped by comparison name.""" 

504 cached = self._components.get((device_id, kind)) 

505 if cached is None: 

506 cached = {} 

507 for component in getattr(self.reader, _READER_ACCESSOR_BY_KIND[kind])().filter(device_id=device_id): 

508 cached.setdefault(identity_text(component.name), []).append(component) 

509 self._components[(device_id, kind)] = cached 

510 return cached 

511 

512 def _resolve_terminations(self) -> None: 

513 """Bind every Termination Reference to one NetBox object, or record why it stays open.""" 

514 for analysis in self.analyses: 

515 if analysis.stopped: 

516 continue 

517 resolved: dict[tuple, _Termination] = {} 

518 for reference in self._references(analysis.trace): 

519 if reference.identity_key in resolved: 

520 continue 

521 termination = self._resolve_one(analysis, reference) 

522 if termination is not None: 

523 resolved[reference.identity_key] = termination 

524 self._resolved[analysis.identity] = resolved 

525 

526 def _resolve_one(self, analysis: _TraceAnalysis, reference) -> _Termination | None: 

527 """Return the NetBox object one Termination Reference names, or record the open decision.""" 

528 devices = self._devices.get(identity_text(reference.device), []) 

529 if len(devices) != 1: 

530 analysis.block("trace.device_unresolved", {**_reference_display(reference), "matches": len(devices)}) 

531 # With no resolved Device there is nothing to pick from, so the picker cannot help here. 

532 self._record_resolution( 

533 analysis, 

534 reference, 

535 UNRESOLVED, 

536 None, 

537 reason=f"The source names {len(devices)} matching Devices, so no port list applies.", 

538 ) 

539 return None 

540 device = devices[0] 

541 stored = self._stored.get(_field_key(reference)) 

542 if stored is not None: 

543 termination = self._stored_termination(analysis, reference, device, stored) 

544 state = UNRESOLVED if termination is None else MANUALLY_RESOLVED 

545 self._record_resolution(analysis, reference, state, termination) 

546 return termination 

547 kind = claimed_termination_kind(reference.port_class) 

548 candidates = self._components_for(device.pk, kind).get(identity_text(reference.port), []) 

549 if len(candidates) != 1: 

550 analysis.block( 

551 "cable.termination_unresolved", {**_reference_display(reference), "matches": len(candidates)} 

552 ) 

553 self._record_resolution(analysis, reference, UNRESOLVED, None) 

554 return None 

555 termination = self._termination(candidates[0]) 

556 self._record_resolution(analysis, reference, AUTOMATICALLY_RESOLVED, termination) 

557 return termination 

558 

559 @staticmethod 

560 def _record_resolution( 

561 analysis: _TraceAnalysis, 

562 reference, 

563 state: str, 

564 termination, 

565 reason: str = "", 

566 role: str = TERMINATION_ROLE, 

567 ) -> None: 

568 """Record how one Termination Reference was settled, for its badge and its picker.""" 

569 key = _field_key(reference, role) 

570 claimed = claimed_termination_kind(reference.port_class) 

571 if role == MAPPED_PEER_ROLE: 

572 # The picker offers the ports on the far side of the panel, which are the opposite kind. 

573 kind = REAR_PORT_KIND if claimed == FRONT_PORT_KIND else FRONT_PORT_KIND 

574 label = f"{_endpoint_label(reference)} (mapped peer)" 

575 else: 

576 kind, label = claimed, _endpoint_label(reference) 

577 analysis.terminations.setdefault( 

578 key, 

579 { 

580 "field_key": key, 

581 "label": label, 

582 "kind": kind, 

583 "state": state, 

584 "selected": "" if termination is None else termination.display, 

585 "selectable": not reason, 

586 "reason": reason, 

587 }, 

588 ) 

589 

590 def _stored_termination(self, analysis: _TraceAnalysis, reference, device, stored) -> _Termination | None: 

591 """Return the object one saved decision selected, rechecked against current target state.""" 

592 label = f"{stored.selected_object_type.app_label}.{stored.selected_object_type.model}" 

593 if label not in _SUPPORTED_TERMINATION_LABELS: 

594 analysis.refuse( 

595 "cable.unsupported_termination_kind", 

596 {**_reference_display(reference), "selected_object_type": label}, 

597 ) 

598 return None 

599 selected_kind = _KIND_BY_MODEL_NAME[label.partition(".")[2]] 

600 claimed_kind = claimed_termination_kind(reference.port_class) 

601 if selected_kind != claimed_kind: 

602 analysis.block( 

603 "cable.termination_kind_mismatch", 

604 { 

605 **_reference_display(reference), 

606 "selected_display_name": stored.selected_display_name, 

607 "claimed_kind": claimed_kind, 

608 "selected_kind": selected_kind, 

609 }, 

610 ) 

611 return None 

612 accessor = _READER_ACCESSOR_BY_KIND[selected_kind] 

613 # A saved selection that left the resolved Device no longer answers the question it was asked. 

614 selected = getattr(self.reader, accessor)().filter(pk=stored.selected_object_id, device_id=device.pk).first() 

615 if selected is None: 

616 analysis.block( 

617 "cable.termination_unresolved", 

618 {**_reference_display(reference), "selected_display_name": stored.selected_display_name}, 

619 ) 

620 return None 

621 return self._termination(selected) 

622 

623 def _termination(self, component) -> _Termination: 

624 """Return the plan-side record of one resolved NetBox termination.""" 

625 label = _object_type_label(component) 

626 self._objects[(label, component.pk)] = component 

627 return _Termination( 

628 object_type=label, 

629 object_id=component.pk, 

630 kind=_KIND_BY_MODEL_NAME[label.partition(".")[2]], 

631 device_id=component.device_id, 

632 display=str(component), 

633 ) 

634 

635 def _load_mappings(self) -> None: 

636 """Read every PortMapping row on a Device this batch resolved a pass-through port on.""" 

637 device_ids = { 

638 termination.device_id 

639 for resolved in self._resolved.values() 

640 for termination in resolved.values() 

641 if termination.kind != INTERFACE_KIND 

642 } 

643 if device_ids: 

644 mappings = self.reader.port_mappings().filter(device_id__in=sorted(device_ids)).order_by("pk") 

645 if self.lock_plan_references: 

646 mappings = mappings.select_for_update(of=("self",)) 

647 # Indexed on load, because every pass-through end asks for the rows of one port. 

648 for row in mappings: 

649 self._mappings_by_front.setdefault(row.front_port_id, []).append(row) 

650 self._mappings_by_rear.setdefault(row.rear_port_id, []).append(row) 

651 ids_by_kind = { 

652 FRONT_PORT_KIND: set(self._mappings_by_front), 

653 REAR_PORT_KIND: set(self._mappings_by_rear), 

654 } 

655 for kind, object_ids in ids_by_kind.items(): 

656 for component in getattr(self.reader, _READER_ACCESSOR_BY_KIND[kind])().filter(pk__in=object_ids): 

657 self._mapping_ports[(kind, component.pk)] = component 

658 

659 def _peers_of(self, termination: _Termination) -> list: 

660 """Return the PortMapping rows that link one pass-through port to its opposite side.""" 

661 if termination.kind == FRONT_PORT_KIND: 

662 return self._mappings_by_front.get(termination.object_id, []) 

663 if termination.kind == REAR_PORT_KIND: 

664 return self._mappings_by_rear.get(termination.object_id, []) 

665 return [] 

666 

667 def _peer_termination(self, mapping, termination: _Termination) -> _Termination | None: 

668 """Return the visible opposite port one PortMapping row links to *termination*.""" 

669 if termination.kind == FRONT_PORT_KIND: 

670 peer_key = REAR_PORT_KIND, mapping.rear_port_id 

671 else: 

672 peer_key = FRONT_PORT_KIND, mapping.front_port_id 

673 component = self._mapping_ports.get(peer_key) 

674 return None if component is None else self._termination(component) 

675 

676 def _mapped_peers(self, analysis: _TraceAnalysis, reference, termination: _Termination) -> list | None: 

677 """Return visible mapped peers, or block when one peer is outside the actor's view scope.""" 

678 peers = [] 

679 for mapping in self._peers_of(termination): 

680 peer = self._peer_termination(mapping, termination) 

681 if peer is None: 

682 peer_kind = REAR_PORT_KIND if termination.kind == FRONT_PORT_KIND else FRONT_PORT_KIND 

683 analysis.block( 

684 "cable.permission_denied", 

685 {**_reference_display(reference), "permission": _VIEW_PERMISSION_BY_KIND[peer_kind]}, 

686 ) 

687 return None 

688 peers.append((peer, mapping)) 

689 return peers 

690 

691 def _build_segments(self) -> None: 

692 """Verify every Pass-Through Claim and turn Segment Evidence into desired segments.""" 

693 for analysis in self.analyses: 

694 if analysis.stopped: 

695 continue 

696 resolved = self._resolved.get(analysis.identity, {}) 

697 summary = analysis.trace.endpoint_summary 

698 endpoints = tuple( 

699 resolved.get(reference.identity_key) for reference in (summary.from_termination, summary.to_termination) 

700 ) 

701 if any(endpoint is None for endpoint in endpoints): 

702 continue 

703 analysis.endpoints = endpoints 

704 self._build_one(analysis, resolved) 

705 

706 def _build_one(self, analysis: _TraceAnalysis, resolved: dict) -> None: 

707 """Bind one trace's Segment Evidence, substituting a mapped peer where the source repeats a port.""" 

708 segments = analysis.trace.segments 

709 left_ends: list[_Termination] = [] 

710 right_ends: list[_Termination] = [] 

711 for segment in segments: 

712 left, right = resolved.get(segment.left.identity_key), resolved.get(segment.right.identity_key) 

713 if left is None or right is None: 

714 return 

715 left_ends.append(left) 

716 right_ends.append(right) 

717 for index in range(len(segments) - 1): 

718 if not same_device_and_cards(segments[index].right, segments[index + 1].left): 

719 continue 

720 entry = self._continue_path(analysis, segments[index + 1].left, right_ends[index], left_ends[index + 1]) 

721 if entry is None: 

722 return 

723 left_ends[index + 1] = entry 

724 for index, segment in enumerate(segments): 

725 if left_ends[index].key != right_ends[index].key: 

726 continue 

727 analysis.block( 

728 "cable.segment_self_connection", 

729 { 

730 "segment_index": index, 

731 "cable_class": source_text(segment.cable_class), 

732 "termination": left_ends[index].display, 

733 }, 

734 identities=(left_ends[index].identity,), 

735 ) 

736 return 

737 analysis.segments = [ 

738 _DesiredSegment( 

739 index=index, 

740 left=left_ends[index], 

741 right=right_ends[index], 

742 cable_class=source_text(segment.cable_class), 

743 ) 

744 for index, segment in enumerate(segments) 

745 ] 

746 

747 def _continue_path(self, analysis: _TraceAnalysis, reference, exit_end, entry_end) -> _Termination | None: 

748 """Return the termination the next cable end takes where the path passes through a panel.""" 

749 if exit_end.key != entry_end.key: 

750 return self._verified_pass_through(analysis, reference, exit_end, entry_end) 

751 mapped_peers = self._mapped_peers(analysis, reference, exit_end) 

752 if mapped_peers is None: 

753 return None 

754 peers: dict[tuple[str, int], Any] = {} 

755 for peer, row in mapped_peers: 

756 peers.setdefault(peer.key, (peer, row)) 

757 if not peers: 

758 analysis.refuse( 

759 "cable.pass_through_not_mapped", 

760 {**_reference_display(reference), "entry": exit_end.display, "exit": entry_end.display, "mapped": []}, 

761 identities=(exit_end.identity,), 

762 ) 

763 return None 

764 if len(peers) > 1: 

765 return self._chosen_peer(analysis, reference, exit_end, peers) 

766 peer, mapping = next(iter(peers.values())) 

767 return self._substituted(analysis, reference, exit_end, peer, mapping) 

768 

769 def _substituted(self, analysis: _TraceAnalysis, reference, exit_end, peer, mapping) -> _Termination: 

770 """Record one same-port continuation and return the mapped peer it substitutes.""" 

771 analysis.note( 

772 "cable.same_port_continuation", 

773 {**_reference_display(reference), "port": exit_end.display, "peer": peer.display}, 

774 identities=(exit_end.identity, peer.identity, _object_identity("dcim.portmapping", mapping.pk)), 

775 ) 

776 return peer 

777 

778 def _chosen_peer(self, analysis: _TraceAnalysis, reference, exit_end, peers) -> _Termination | None: 

779 """Return the mapped peer the operator selected, or block on the several NetBox offers.""" 

780 stored = self._stored.get(_field_key(reference, MAPPED_PEER_ROLE)) 

781 if stored is not None: 

782 selected = ( 

783 f"{stored.selected_object_type.app_label}.{stored.selected_object_type.model}", 

784 stored.selected_object_id, 

785 ) 

786 if selected in peers: 

787 peer, mapping = peers[selected] 

788 self._record_resolution(analysis, reference, MANUALLY_RESOLVED, peer, role=MAPPED_PEER_ROLE) 

789 return self._substituted(analysis, reference, exit_end, peer, mapping) 

790 self._record_resolution(analysis, reference, UNRESOLVED, None, role=MAPPED_PEER_ROLE) 

791 analysis.block( 

792 "cable.ambiguous_mapped_peer", 

793 { 

794 **_reference_display(reference), 

795 "port": exit_end.display, 

796 "peers": sorted(peer.display for peer, _mapping in peers.values()), 

797 }, 

798 identities=(exit_end.identity, *sorted(_object_identity(*key) for key in peers)), 

799 ) 

800 return None 

801 

802 def _verified_pass_through(self, analysis: _TraceAnalysis, reference, exit_end, entry_end) -> _Termination | None: 

803 """Return the stated entry port once a PortMapping row proves the panel joins the two ports.""" 

804 mapping = None 

805 if {exit_end.kind, entry_end.kind} == {FRONT_PORT_KIND, REAR_PORT_KIND}: 

806 front, rear = (exit_end, entry_end) if exit_end.kind == FRONT_PORT_KIND else (entry_end, exit_end) 

807 mapping = next( 

808 (row for row in self._peers_of(front) if row.rear_port_id == rear.object_id), 

809 None, 

810 ) 

811 if mapping is None: 

812 mapped = [] 

813 for row in self._peers_of(exit_end): 

814 peer = self._peer_termination(row, exit_end) 

815 if peer is not None: 

816 mapped.append(peer.display) 

817 analysis.refuse( 

818 "cable.pass_through_not_mapped", 

819 { 

820 **_reference_display(reference), 

821 "entry": exit_end.display, 

822 "exit": entry_end.display, 

823 "mapped": sorted(mapped), 

824 }, 

825 identities=(exit_end.identity, entry_end.identity), 

826 ) 

827 return None 

828 analysis.note( 

829 "cable.pass_through_verified", 

830 {**_reference_display(reference), "entry": exit_end.display, "exit": entry_end.display}, 

831 identities=(exit_end.identity, entry_end.identity, _object_identity("dcim.portmapping", mapping.pk)), 

832 ) 

833 return entry_end 

834 

835 def _load_existing_cables(self) -> None: 

836 """Read every Cable that already holds a termination this batch resolved.""" 

837 from dcim.models import Cable, CableTermination 

838 

839 wanted = { 

840 termination.key for analysis in self.analyses for termination in self._analysis_terminations(analysis) 

841 } 

842 # Occupancy is unscoped: a Cable the actor cannot view still holds the termination. 

843 cable_ids = { 

844 component.cable_id for key, component in self._objects.items() if key in wanted and component.cable_id 

845 } 

846 if not cable_ids: 

847 return 

848 cables = Cable.objects.filter(pk__in=sorted(cable_ids)).order_by("pk") 

849 terminations = CableTermination.objects.filter(cable_id__in=sorted(cable_ids)).order_by("pk") 

850 if self.lock_plan_references: 

851 cables = cables.select_for_update(of=("self",)) 

852 terminations = terminations.select_for_update(of=("self",)) 

853 # Each Cable lock precedes its CableTermination locks, as it does during deletion. 

854 locked = list(cables) 

855 sides: dict[int, dict[str, set]] = {} 

856 for row in terminations: 

857 label = _object_type_label(row.termination_type.model_class()) 

858 sides.setdefault(row.cable_id, {"A": set(), "B": set()})[row.cable_end].add((label, row.termination_id)) 

859 for cable in locked: 

860 ends = sides.get(cable.pk, {"A": set(), "B": set()}) 

861 existing = _ExistingCable(cable=cable, a_side=frozenset(ends["A"]), b_side=frozenset(ends["B"])) 

862 self._existing[cable.pk] = existing 

863 for key in existing.a_side | existing.b_side: 

864 self._occupied[key] = existing 

865 

866 def _lock_segment_terminations(self) -> None: 

867 """Lock every desired segment termination in global object-type and primary-key order.""" 

868 if not self.lock_plan_references: 

869 return 

870 ids_by_label: dict[str, set[int]] = {} 

871 for analysis in self.analyses: 

872 for segment in analysis.segments: 

873 for termination in segment.terminations: 

874 ids_by_label.setdefault(termination.object_type, set()).add(termination.object_id) 

875 for label in sorted(ids_by_label): 

876 object_ids = sorted(ids_by_label[label]) 

877 for object_id in object_ids: 

878 self._objects.pop((label, object_id), None) 

879 components = ( 

880 _model_for_label(label).objects.filter(pk__in=object_ids).order_by("pk").select_for_update(of=("self",)) 

881 ) 

882 for component in components: 

883 self._objects[(label, component.pk)] = component 

884 

885 @staticmethod 

886 def _analysis_terminations(analysis: _TraceAnalysis) -> list[_Termination]: 

887 """Return every resolved termination one trace's plan depends on.""" 

888 terminations = list(analysis.endpoints) 

889 for segment in analysis.segments: 

890 terminations.extend(segment.terminations) 

891 return terminations 

892 

893 def _cable_diagnostic_disclosure(self, cable: Any) -> tuple[dict[str, Any], tuple[str, ...]]: 

894 """Return the diagnostic fields and identity the actor may see for one Cable.""" 

895 cable_visible = self.actor is None or self.actor.has_perm("dcim.view_cable", cable) 

896 if not cable_visible: 

897 return {"cable_visible": False}, () 

898 return ( 

899 {"cable_visible": True, "cable": str(cable)}, 

900 (_object_identity("dcim.cable", cable.pk),), 

901 ) 

902 

903 def _classify(self) -> None: 

904 """Decide which segments already exist, then which Cable is each trace's Logical Cable. 

905 

906 Every desired segment of the batch is classified before any Logical Cable is chosen, so a 

907 Cable that proves one trace's segment can never be deleted as another trace's leftover. 

908 """ 

909 pending = self._writing() 

910 for analysis in pending: 

911 # Only these analyses look at the live topology, so only they may report what is there. 

912 analysis.topology_read = True 

913 for segment in analysis.segments: 

914 candidate = self._occupied.get(segment.left.key) 

915 proven = candidate if candidate is not None and candidate.satisfies(segment) else None 

916 if proven is not None: 

917 analysis.proven[segment.index] = proven 

918 self._note_reuse(analysis, segment, proven) 

919 proven_ids = {item.cable.pk for analysis in pending for item in analysis.proven.values()} 

920 for analysis in pending: 

921 analysis.logical_cable = self._logical_cable(analysis, proven_ids) 

922 if analysis.segments: 

923 self._report_conflicts(analysis) 

924 else: 

925 self._classify_endpoint_evidence(analysis) 

926 

927 def _logical_cable(self, analysis: _TraceAnalysis, proven_ids) -> _ExistingCable | None: 

928 """Return the direct endpoint-to-endpoint Cable that proves no desired segment.""" 

929 first, second = analysis.endpoints 

930 existing = self._occupied.get(first.key) 

931 if existing is not None and existing.cable.pk not in proven_ids and existing.connects(first, second): 

932 return existing 

933 return None 

934 

935 def _classify_endpoint_evidence(self, analysis: _TraceAnalysis) -> None: 

936 """Decide an Endpoint Summary fallback, which states endpoints and no physical path.""" 

937 if analysis.logical_cable is None: 

938 analysis.block( 

939 "trace.endpoint_evidence_only", 

940 {"name": analysis.display["name"]}, 

941 identities=tuple(endpoint.identity for endpoint in analysis.endpoints), 

942 ) 

943 return 

944 # The stated endpoints are already joined, so the evidence is satisfied and nothing is removed. 

945 cable_display, cable_identities = self._cable_diagnostic_disclosure(analysis.logical_cable.cable) 

946 analysis.note( 

947 "cable.segment_reused", 

948 {"segment_index": 0, **cable_display}, 

949 identities=cable_identities, 

950 ) 

951 analysis.retains_logical_cable = True 

952 

953 def _note_reuse(self, analysis: _TraceAnalysis, segment: _DesiredSegment, proven: _ExistingCable) -> None: 

954 """Record the proven physical segment, so its live state joins the unit fingerprint.""" 

955 cable_display, cable_identities = self._cable_diagnostic_disclosure(proven.cable) 

956 analysis.note( 

957 "cable.segment_reused", 

958 {"segment_index": segment.index, **cable_display}, 

959 identities=(*cable_identities, segment.left.identity, segment.right.identity), 

960 ) 

961 drift = self._attribute_drift(segment, proven.cable) 

962 if drift: 

963 drift_display = drift if cable_display["cable_visible"] else {} 

964 analysis.note( 

965 "cable.attribute_drift", 

966 {"segment_index": segment.index, **cable_display, **drift_display}, 

967 identities=cable_identities, 

968 ) 

969 

970 def _attribute_drift(self, segment: _DesiredSegment, cable) -> dict: 

971 """Return the reused Cable attributes that differ from what this import would have written.""" 

972 mapping = self._cable_class_mapping(segment.cable_class) 

973 drift = {} 

974 if cable.status != CABLE_STATUS: 

975 drift["status"] = cable.status 

976 if mapping is not None and mapping.cable_type_resolved and (cable.type or None) != mapping.cable_type: 

977 drift["type"] = cable.type or "" 

978 if mapping is not None and mapping.cable_profile_resolved and (cable.profile or None) != mapping.cable_profile: 

979 drift["profile"] = cable.profile or "" 

980 if cable.label: 

981 drift["label"] = cable.label 

982 return drift 

983 

984 def _report_conflicts(self, analysis: _TraceAnalysis) -> None: 

985 """Block the trace when a Cable this import may not touch holds a termination it needs.""" 

986 removed = analysis.deleted_logical_cable 

987 logical_id = None if removed is None else removed.cable.pk 

988 for segment in analysis.pending: 

989 for termination in segment.terminations: 

990 occupying = self._occupied.get(termination.key) 

991 if occupying is None or occupying.cable.pk == logical_id: 

992 continue 

993 code = ( 

994 "cable.multi_termination_conflict" if occupying.multi_termination else "cable.termination_occupied" 

995 ) 

996 cable_display, cable_identities = self._cable_diagnostic_disclosure(occupying.cable) 

997 display = {"segment_index": segment.index, "port": termination.display, **cable_display} 

998 identities = [termination.identity, *cable_identities] 

999 analysis.block(code, display, identities=identities) 

1000 

1001 def _cable_class_mapping(self, cable_class: str): 

1002 """Return the Cable policy row one CableClass value carries, or None.""" 

1003 if self._mapping_rows is None: 

1004 from .models import CableClassMapping 

1005 

1006 rows = CableClassMapping.objects.filter(profile=self.profile) 

1007 self._mapping_rows = {row.cable_class: row for row in rows} 

1008 return self._mapping_rows.get(cable_class) 

1009 

1010 def _decide(self) -> None: 

1011 """Settle the Cable policy and the write permissions every actionable trace needs.""" 

1012 for analysis in self.analyses: 

1013 if analysis.stopped or not analysis.endpoints: 

1014 continue 

1015 for segment in analysis.pending: 

1016 policy = self._cable_policy(analysis, segment) 

1017 if policy is not None: 

1018 analysis.policies[segment.index] = policy 

1019 self._check_permissions(analysis) 

1020 

1021 def _cable_policy(self, analysis: _TraceAnalysis, segment: _DesiredSegment) -> dict | None: 

1022 """Return the Cable Type and Cable Profile one new segment is written with.""" 

1023 from .models import cable_class_mapping_choice_errors 

1024 

1025 display = {"segment_index": segment.index, "cable_class": segment.cable_class} 

1026 mapping = self._cable_class_mapping(segment.cable_class) 

1027 if mapping is None or not (mapping.cable_type_resolved and mapping.cable_profile_resolved): 

1028 analysis.block("cable.cableclass_unmapped", display) 

1029 return None 

1030 errors = cable_class_mapping_choice_errors(mapping.cable_type, mapping.cable_profile) 

1031 for error in errors.values(): 

1032 analysis.block(error.code, {**display, "message": error.messages[0]}) 

1033 if errors: 

1034 return None 

1035 return {"cable_type": mapping.cable_type, "cable_profile": mapping.cable_profile} 

1036 

1037 def _check_permissions(self, analysis: _TraceAnalysis) -> None: 

1038 """Block the trace when the actor may not make every Cable write it asks for.""" 

1039 if self.actor is None: 

1040 return 

1041 if analysis.pending and not self.actor.has_perm("dcim.add_cable"): 

1042 analysis.block("cable.permission_denied", {"permission": "dcim.add_cable"}) 

1043 return 

1044 logical = analysis.deleted_logical_cable 

1045 if logical is not None and not self.actor.has_perm("dcim.delete_cable", logical.cable): 

1046 cable_display, cable_identities = self._cable_diagnostic_disclosure(logical.cable) 

1047 analysis.block( 

1048 "cable.permission_denied", 

1049 {"permission": "dcim.delete_cable", **cable_display}, 

1050 identities=cable_identities, 

1051 ) 

1052 

1053 def _block_planned_termination_conflicts(self) -> None: 

1054 """Block every actionable trace that competes for one free termination.""" 

1055 claims: dict[tuple[str, int], list[tuple[_TraceAnalysis, _DesiredSegment, _Termination]]] = {} 

1056 for analysis in self._writing(): 

1057 for segment in analysis.pending: 

1058 for termination in segment.terminations: 

1059 claims.setdefault(termination.key, []).append((analysis, segment, termination)) 

1060 for records in claims.values(): 

1061 for analysis, segment, termination in records: 

1062 competitors = [ 

1063 other 

1064 for other, other_segment, _other_termination in records 

1065 if other is not analysis and other_segment.key != segment.key 

1066 ] 

1067 for competitor in competitors: 

1068 analysis.block( 

1069 "cable.planned_termination_conflict", 

1070 { 

1071 "segment_index": segment.index, 

1072 "termination": termination.display, 

1073 "competing_trace": competitor.display["name"], 

1074 }, 

1075 identities=(termination.identity,), 

1076 ) 

1077 

1078 def _refuse_conflicting_creations(self) -> None: 

1079 """Invalidate traces that resolve one shared segment to different Cable policies.""" 

1080 contributors: dict[str, list] = {} 

1081 for analysis in self.analyses: 

1082 if analysis.invalid or not analysis.endpoints: 

1083 continue 

1084 for segment in analysis.pending: 

1085 policy = analysis.policies.get(segment.index) 

1086 if policy is not None: 

1087 contributors.setdefault(segment.key, []).append((analysis, segment, policy)) 

1088 for records in contributors.values(): 

1089 policies = { 

1090 (segment.cable_class, policy["cable_type"], policy["cable_profile"]) 

1091 for _analysis, segment, policy in records 

1092 } 

1093 if len(policies) < 2: 

1094 continue 

1095 for analysis, segment, policy in records: 

1096 analysis.refuse( 

1097 "cable.resolved_segment_conflict", 

1098 { 

1099 "segment_index": segment.index, 

1100 "cable_class": segment.cable_class, 

1101 "cable_type": policy["cable_type"], 

1102 "cable_profile": policy["cable_profile"], 

1103 "terminations": segment.as_json(), 

1104 }, 

1105 identities=(segment.left.identity, segment.right.identity), 

1106 ) 

1107 

1108 def _shared_deletes(self) -> dict[str, tuple[str, ...]]: 

1109 """Return, per desired segment, every Logical Cable deletion its creation waits for. 

1110 

1111 Two traces that state one identical segment share one Planned Change, so the change names 

1112 the deletions of both and reads the same in either unit. 

1113 """ 

1114 deletes: dict[str, set] = {} 

1115 for analysis in self._writing(): 

1116 if analysis.delete_identity is not None: 

1117 for segment in analysis.pending: 

1118 deletes.setdefault(segment.key, set()).add(analysis.delete_identity) 

1119 return {key: tuple(sorted(values)) for key, values in deletes.items()} 

1120 

1121 def _shared_sources(self) -> dict[str, list]: 

1122 """Return, per desired segment, the provenance of every Source Trace that states it.""" 

1123 sources: dict[str, list] = {} 

1124 for analysis in self.analyses: 

1125 # Unlike _shared_deletes: a repaired trace reuses the Cable, so creation records it or nothing does. 

1126 if analysis.invalid: 

1127 continue 

1128 for segment in analysis.pending: 

1129 sources.setdefault(segment.key, []).append(_source_record(analysis.trace, segment.index)) 

1130 return { 

1131 key: sorted(records, key=lambda record: (record["trace_identity"], record["segment_index"])) 

1132 for key, records in sources.items() 

1133 } 

1134 

1135 def _writing(self) -> list[_TraceAnalysis]: 

1136 """Return the analyses that still contribute writes to this plan.""" 

1137 return [analysis for analysis in self.analyses if not analysis.stopped and analysis.endpoints] 

1138 

1139 def _unit(self, analysis: _TraceAnalysis) -> SynchronizationUnit: 

1140 """Return the one Synchronization Unit one Source Trace produces.""" 

1141 changes = self._changes(analysis) 

1142 if analysis.invalid: 

1143 disposition = Disposition.INVALID 

1144 elif analysis.blocked: 

1145 disposition = Disposition.BLOCKED 

1146 elif changes: 

1147 disposition = Disposition.ACTIONABLE 

1148 else: 

1149 disposition = Disposition.NO_OP 

1150 return SynchronizationUnit( 

1151 identity=analysis.identity, 

1152 disposition=disposition, 

1153 changes=changes, 

1154 diagnostics=tuple(analysis.diagnostics), 

1155 display={ 

1156 **analysis.display, 

1157 "detail": self._detail(analysis, disposition), 

1158 "trace": self._workspace(analysis, bool(changes)), 

1159 }, 

1160 ) 

1161 

1162 def _workspace(self, analysis: _TraceAnalysis, writes: bool) -> dict: 

1163 """Return what the three review workspace panels show for one Source Trace.""" 

1164 summary = analysis.trace.endpoint_summary 

1165 resolved = {segment.index: segment for segment in analysis.segments} 

1166 by_reference = self._resolved.get(analysis.identity, {}) 

1167 conflicted = { 

1168 diagnostic.display.get("segment_index") 

1169 for diagnostic in analysis.diagnostics 

1170 if diagnostic.code in _CONFLICT_CODES 

1171 } 

1172 segments = [] 

1173 stated_segments = list(analysis.trace.segments) 

1174 for index, stated in enumerate(stated_segments): 

1175 planned = resolved.get(index) 

1176 source_left, source_right = _endpoint_label(stated.left), _endpoint_label(stated.right) 

1177 left = source_left if planned is None else planned.left.display 

1178 right = source_right if planned is None else planned.right.display 

1179 segments.append( 

1180 { 

1181 "index": index, 

1182 "cable_class": source_text(stated.cable_class), 

1183 "source_left": source_left, 

1184 "source_right": source_right, 

1185 "left": left, 

1186 "right": right, 

1187 # A path that re-enters one panel implies a claim the source never states. 

1188 "pass_through": index > 0 and same_device_and_cards(stated_segments[index - 1].right, stated.left), 

1189 # Planning substituted the entry port only where the source re-used one port. 

1190 "substituted": self._entered_through_claim(planned, by_reference.get(stated.left.identity_key)), 

1191 "status": self._segment_status(analysis, index, conflicted, planned, writes), 

1192 } 

1193 ) 

1194 return { 

1195 "identity": analysis.trace.identity, 

1196 "endpoints": { 

1197 "from": _endpoint_label(summary.from_termination), 

1198 "to": _endpoint_label(summary.to_termination), 

1199 }, 

1200 "segments": segments, 

1201 "logical_cable": self._logical_cable_display(analysis), 

1202 "topology_known": analysis.topology_read, 

1203 # A unit with no changes proposes nothing, so the panel must not offer to delete one. 

1204 "deletes_logical_cable": writes and analysis.deleted_logical_cable is not None, 

1205 "terminations": list(analysis.terminations.values()), 

1206 } 

1207 

1208 @staticmethod 

1209 def _entered_through_claim(planned: _DesiredSegment | None, stated: _Termination | None) -> bool: 

1210 """Return whether planning entered this segment through a port the source never stated.""" 

1211 return planned is not None and stated is not None and planned.left.key != stated.key 

1212 

1213 @staticmethod 

1214 def _segment_status(analysis: _TraceAnalysis, index: int, conflicted: set, planned, writes: bool) -> str: 

1215 """Return the verdict the proposed panel shows for one segment, or none for an unplanned one.""" 

1216 if index in conflicted: 

1217 return CONFLICT_SEGMENT 

1218 if planned is None: 

1219 return "" 

1220 if index in analysis.proven: 

1221 return REUSE_SEGMENT 

1222 # A blocked unit contributes no change, so nothing here is going to be created. 

1223 return CREATE_SEGMENT if writes else "" 

1224 

1225 def _logical_cable_display(self, analysis: _TraceAnalysis) -> dict | None: 

1226 """Return the Logical Cable NetBox holds for this trace, as far as the actor may see it.""" 

1227 if analysis.logical_cable is None: 

1228 return None 

1229 cable = analysis.logical_cable.cable 

1230 disclosure, _identities = self._cable_diagnostic_disclosure(cable) 

1231 if not disclosure["cable_visible"]: 

1232 return {"visible": False, "display": "", "description": "", "tags": []} 

1233 # Section 6.3 reviews what the deletion removes, which the deletion payload also carries. 

1234 return { 

1235 "visible": True, 

1236 "display": disclosure["cable"], 

1237 "description": cable.description, 

1238 "tags": sorted(cable.tags.values_list("name", flat=True)), 

1239 } 

1240 

1241 def _changes(self, analysis: _TraceAnalysis) -> tuple[PlannedChange, ...]: 

1242 """Return the deletion and the creations one actionable trace performs, in that order.""" 

1243 if analysis.stopped: 

1244 return () 

1245 changes = [] 

1246 if analysis.deleted_logical_cable is not None: 

1247 changes.append(self._delete_change(analysis.deleted_logical_cable)) 

1248 changes.extend(self._create_change(segment, analysis.policies[segment.index]) for segment in analysis.pending) 

1249 return tuple(changes) 

1250 

1251 @staticmethod 

1252 def _delete_change(logical: _ExistingCable) -> PlannedChange: 

1253 """Return the one deletion a Patched Path Replacement ever performs.""" 

1254 return PlannedChange( 

1255 identity=_delete_identity(logical.cable.pk), 

1256 target_module=CableModule.key, 

1257 operation="delete", 

1258 payload={ 

1259 "cable_id": logical.cable.pk, 

1260 "display": str(logical.cable), 

1261 "description": logical.cable.description, 

1262 "tags": sorted(logical.cable.tags.values_list("name", flat=True)), 

1263 }, 

1264 preconditions={"cable_id": logical.cable.pk, "terminations": logical.terminations}, 

1265 ) 

1266 

1267 def _create_change(self, segment: _DesiredSegment, policy: dict) -> PlannedChange: 

1268 """Return the creation of one physical segment, shared by every trace that states it.""" 

1269 return PlannedChange( 

1270 identity=segment.change_identity, 

1271 target_module=CableModule.key, 

1272 operation="create", 

1273 payload={ 

1274 "terminations": segment.as_json(), 

1275 "status": CABLE_STATUS, 

1276 "cable_class": segment.cable_class, 

1277 "sources": self._sources_by_segment.get(segment.key, []), 

1278 **policy, 

1279 }, 

1280 dependencies=self._deletes_by_segment.get(segment.key, ()), 

1281 preconditions={"terminations": segment.as_json()}, 

1282 ) 

1283 

1284 @staticmethod 

1285 def _detail(analysis: _TraceAnalysis, disposition: str) -> str: 

1286 """Return the operator wording one unit shows before the trace workspace exists.""" 

1287 if disposition == Disposition.NO_OP: 

1288 return "The stated path already exists." 

1289 if disposition != Disposition.ACTIONABLE: 

1290 return "" 

1291 count = len(analysis.pending) 

1292 if analysis.deleted_logical_cable is not None: 

1293 return f"Would replace the logical cable with {count} physical segment(s)." 

1294 return f"Would create {count} physical segment(s)." 

1295 

1296 

1297class CableModule: 

1298 """Plan and write the Cables one Source Trace states, as a Patched Path Replacement.""" 

1299 

1300 key = TargetModuleKey.CABLE 

1301 consumes = frozenset({OutputKind.SOURCE_TRACE}) 

1302 

1303 def plan( 

1304 self, 

1305 source_batch, 

1306 profile, 

1307 catalog, 

1308 netbox_reader, 

1309 *, 

1310 lock_plan_references: bool = False, 

1311 ) -> list[SynchronizationUnit]: 

1312 """Return one unit per trace and optionally lock rows that prove the plan.""" 

1313 del catalog 

1314 if not (self.consumes & source_batch.output_kinds): 

1315 return [] 

1316 return _CableBatch( 

1317 source_batch.rows, 

1318 profile, 

1319 netbox_reader, 

1320 lock_plan_references=lock_plan_references, 

1321 ).units() 

1322 

1323 def apply(self, planned_change: PlannedChange, execution_context) -> Any: 

1324 """Apply one Cable change, having locked its rows and rechecked its preconditions.""" 

1325 if planned_change.operation == "delete": 

1326 return self._delete(planned_change, execution_context) 

1327 if planned_change.operation == "create": 

1328 return self._create(planned_change, execution_context) 

1329 raise PreconditionFailed(f"The Cable Target Module cannot apply operation '{planned_change.operation}'.") 

1330 

1331 @staticmethod 

1332 def _delete(planned_change: PlannedChange, execution_context) -> DeletedObject: 

1333 """Remove the one direct Logical Cable this unit replaces.""" 

1334 from dcim.models import Cable 

1335 

1336 cable_id = planned_change.preconditions["cable_id"] 

1337 cable = Cable.objects.filter(pk=cable_id).select_for_update(of=("self",)).first() 

1338 if cable is None: 

1339 raise PreconditionFailed(f"Cable {cable_id} is gone, so the logical cable cannot be replaced.") 

1340 current = _cable_terminations(cable_id) 

1341 if current != [list(item) for item in planned_change.preconditions["terminations"]]: 

1342 raise PreconditionFailed(f"Cable {cable_id} was re-terminated after the plan was made.") 

1343 enforce_saved_object_permission(cable, execution_context.actor, "delete") 

1344 # The audit row is the only record left of this Cable, so it keeps what the row carried. 

1345 snapshot = DeletedObject( 

1346 object_type="dcim.cable", 

1347 object_id=cable_id, 

1348 display=str(cable), 

1349 detail={ 

1350 "terminations": current, 

1351 "description": cable.description, 

1352 "tags": sorted(cable.tags.values_list("name", flat=True)), 

1353 }, 

1354 ) 

1355 cable.delete() 

1356 return snapshot 

1357 

1358 @staticmethod 

1359 def _create(planned_change: PlannedChange, execution_context) -> Any: 

1360 """Create one physical Cable segment and the provenance rows its Source Traces earn.""" 

1361 from dcim.models import Cable 

1362 

1363 payload = planned_change.payload 

1364 ends = [ 

1365 CableModule._free_termination(label, object_id, device_id) 

1366 for label, object_id, device_id in payload["terminations"] 

1367 ] 

1368 cable = Cable( 

1369 type=payload["cable_type"], 

1370 profile=payload["cable_profile"] or "", 

1371 status=payload["status"], 

1372 a_terminations=[ends[0]], 

1373 b_terminations=[ends[1]], 

1374 ) 

1375 cable.full_clean() 

1376 cable.save() 

1377 enforce_saved_object_permission(cable, execution_context.actor, "add") 

1378 CableModule._store_provenance(cable, payload, execution_context.profile) 

1379 return cable 

1380 

1381 @staticmethod 

1382 def _free_termination(label: str, object_id: int, device_id: int): 

1383 """Return one locked termination that is still free and on its resolved Device.""" 

1384 component = _model_for_label(label).objects.filter(pk=object_id).select_for_update(of=("self",)).first() 

1385 if component is None: 

1386 raise PreconditionFailed(f"{label} {object_id} is gone, so the segment cannot be created.") 

1387 if component.device_id != device_id: 

1388 raise PreconditionFailed(f"{label} {object_id} moved to another Device after the plan was made.") 

1389 if component.cable_id is not None: 

1390 raise PreconditionFailed(f"{component} received a cable after the plan was made.") 

1391 return component 

1392 

1393 @staticmethod 

1394 def _store_provenance(cable, payload, profile) -> None: 

1395 """Write one provenance row per Source Trace that states this segment.""" 

1396 from .models import CableImportSource, index_digest 

1397 

1398 for record in payload["sources"]: 

1399 # The unique constraint carries the digest, so the lookup matches it. 

1400 CableImportSource.objects.update_or_create( 

1401 cable=cable, 

1402 profile=profile, 

1403 trace_key=index_digest(record["trace_identity"]), 

1404 defaults={ 

1405 "trace_identity": record["trace_identity"], 

1406 "segment_index": record["segment_index"], 

1407 "from_text": record["from_text"], 

1408 "to_text": record["to_text"], 

1409 "direction": record["direction"], 

1410 "workbook_fingerprint": record["workbook_fingerprint"], 

1411 "sheet": record["sheet"], 

1412 "block_ordinal": record["block_ordinal"], 

1413 "row_start": record["row_start"], 

1414 "row_end": record["row_end"], 

1415 "export_timestamp": record["export_timestamp"], 

1416 }, 

1417 ) 

1418 

1419 

1420def _cable_terminations(cable_id: int) -> list: 

1421 """Return one Cable's termination pairs in the order a precondition records them.""" 

1422 from dcim.models import CableTermination 

1423 

1424 return sorted( 

1425 [_object_type_label(row.termination_type.model_class()), row.termination_id] 

1426 for row in CableTermination.objects.filter(cable_id=cable_id).order_by("pk").select_for_update(of=("self",)) 

1427 ) 

1428 

1429 

1430__all__ = ( 

1431 "AUTOMATICALLY_RESOLVED", 

1432 "CABLE_STATUS", 

1433 "CONFLICT_SEGMENT", 

1434 "CREATE_SEGMENT", 

1435 "ELIGIBLE_TERMINATION_LIMIT", 

1436 "MANUALLY_RESOLVED", 

1437 "REUSE_SEGMENT", 

1438 "UNRESOLVED", 

1439 "CableModule", 

1440 "EligibleTerminations", 

1441 "eligible_terminations", 

1442)