Coverage for dcim/models/cables.py: 18%

670 statements  

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

1import itertools 

2import logging 

3import threading 

4from collections import Counter 

5 

6from django.contrib.contenttypes.fields import GenericForeignKey 

7from django.contrib.contenttypes.models import ContentType 

8from django.contrib.postgres.fields import ArrayField 

9from django.contrib.postgres.indexes import GinIndex 

10from django.core.exceptions import ValidationError 

11from django.core.validators import MaxValueValidator, MinValueValidator 

12from django.db import models, router, transaction 

13from django.dispatch import Signal 

14from django.urls import reverse 

15from django.utils.translation import gettext_lazy as _ 

16 

17from core.models import ObjectType 

18from dcim.choices import * 

19from dcim.constants import * 

20from dcim.exceptions import UnsupportedCablePath 

21from dcim.fields import PathField 

22from dcim.utils import decompile_path_node, object_to_path_node, rebuild_cable_paths 

23from netbox.choices import ColorChoices 

24from netbox.models import ChangeLoggedModel, PrimaryModel 

25from utilities.conversion import to_meters 

26from utilities.data import normalize_update_fields 

27from utilities.exceptions import AbortRequest 

28from utilities.fields import ColorField, GenericArrayForeignKey 

29from utilities.querysets import RestrictedQuerySet, chunked_update 

30from utilities.serialization import deserialize_object, serialize_object 

31from wireless.models import WirelessLink 

32 

33from .device_components import FrontPort, Interface, PathEndpoint, PortMapping, RearPort 

34 

35__all__ = ( 

36 'Cable', 

37 'CableBundle', 

38 'CablePath', 

39 'CableTermination', 

40) 

41 

42logger = logging.getLogger(f'netbox.{__name__}') 

43 

44trace_paths = Signal() 

45 

46 

47# 

48# Cable bundles 

49# 

50 

51class CableBundle(PrimaryModel): 

52 """ 

53 A logical grouping of individual cables. 

54 """ 

55 name = models.CharField( 

56 verbose_name=_('name'), 

57 max_length=100, 

58 unique=True, 

59 ) 

60 

61 class Meta: 

62 ordering = ('name',) 

63 verbose_name = _('cable bundle') 

64 verbose_name_plural = _('cable bundles') 

65 

66 def __str__(self): 

67 return self.name 

68 

69 def get_absolute_url(self): 

70 return reverse('dcim:cablebundle', args=[self.pk]) 

71 

72 

73# 

74# Cables 

75# 

76 

77class CableQuerySet(RestrictedQuerySet): 

78 

79 def delete(self): 

80 # Track these Cables as being deleted for the duration, as Cable.delete() does for a single 

81 # instance: a queryset delete never calls it. Between them the two cover every deletion, as a 

82 # Cable is never itself cascade-deleted (nothing points at it with on_delete=CASCADE). 

83 # Resolve the PKs on the DB the delete will use, so read routing can't miss a lagging replica. 

84 using = self._db or router.db_for_write(self.model, **self._hints) 

85 pks = list(self.using(using).values_list('pk', flat=True)) 

86 for pk in pks: 

87 Cable._track_deletion(pk) 

88 try: 

89 return super().delete() 

90 finally: 

91 for pk in pks: 

92 Cable._untrack_deletion(pk) 

93 

94 

95class Cable(PrimaryModel): 

96 """ 

97 A physical connection between two endpoints. 

98 """ 

99 # Per-thread tracking of Cable PKs currently being deleted; referenced by 

100 # dcim.signals.nullify_connected_endpoints to record the disconnect on each terminating object and 

101 # to skip per-CableTermination path retracing during the cascade (retrace_cable_paths does it once). 

102 _deletion_tracking = threading.local() 

103 

104 type = models.CharField( 

105 verbose_name=_('type'), 

106 max_length=50, 

107 choices=CableTypeChoices, 

108 blank=True, 

109 null=True 

110 ) 

111 status = models.CharField( 

112 verbose_name=_('status'), 

113 max_length=50, 

114 choices=LinkStatusChoices, 

115 default=LinkStatusChoices.STATUS_CONNECTED 

116 ) 

117 profile = models.CharField( 

118 verbose_name=_('profile'), 

119 max_length=50, 

120 choices=CableProfileChoices, 

121 blank=True, 

122 ) 

123 tenant = models.ForeignKey( 

124 to='tenancy.Tenant', 

125 on_delete=models.PROTECT, 

126 related_name='cables', 

127 blank=True, 

128 null=True 

129 ) 

130 label = models.CharField( 

131 verbose_name=_('label'), 

132 max_length=100, 

133 blank=True 

134 ) 

135 color = ColorField( 

136 verbose_name=_('color'), 

137 blank=True 

138 ) 

139 length = models.DecimalField( 

140 verbose_name=_('length'), 

141 max_digits=8, 

142 decimal_places=2, 

143 blank=True, 

144 null=True 

145 ) 

146 length_unit = models.CharField( 

147 verbose_name=_('length unit'), 

148 max_length=50, 

149 choices=CableLengthUnitChoices, 

150 blank=True, 

151 null=True 

152 ) 

153 # Stores the normalized length (in meters) for database ordering 

154 _abs_length = models.DecimalField( 

155 max_digits=14, 

156 decimal_places=4, 

157 blank=True, 

158 null=True 

159 ) 

160 bundle = models.ForeignKey( 

161 to='dcim.CableBundle', 

162 on_delete=models.SET_NULL, 

163 related_name='cables', 

164 blank=True, 

165 null=True, 

166 verbose_name=_('bundle'), 

167 ) 

168 

169 clone_fields = ('tenant', 'type', 'profile', 'bundle') 

170 

171 objects = CableQuerySet.as_manager() 

172 

173 class Meta: 

174 ordering = ('pk',) 

175 verbose_name = _('cable') 

176 verbose_name_plural = _('cables') 

177 

178 def __init__(self, *args, a_terminations=None, b_terminations=None, **kwargs): 

179 super().__init__(*args, **kwargs) 

180 

181 # A copy of the PK to be used by __str__ in case the object is deleted 

182 self._pk = self.__dict__.get('id') 

183 

184 # Cache the original profile & status so we can check later whether either has been changed 

185 self._orig_status = self.__dict__.get('status') 

186 self._orig_profile = self.__dict__.get('profile') 

187 

188 self._terminations_modified = False 

189 

190 # Assign or retrieve A/B terminations 

191 if a_terminations: 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true

192 self.a_terminations = a_terminations 

193 if b_terminations: 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true

194 self.b_terminations = b_terminations 

195 

196 def __str__(self): 

197 pk = self.pk or self._pk 

198 return self.label or f'#{pk}' 

199 

200 def get_status_color(self): 

201 return LinkStatusChoices.colors.get(self.status) 

202 

203 @property 

204 def profile_class(self): 

205 from dcim import cable_profiles 

206 return { 

207 CableProfileChoices.SINGLE_1C1P: cable_profiles.Single1C1PCableProfile, 

208 CableProfileChoices.SINGLE_1C2P: cable_profiles.Single1C2PCableProfile, 

209 CableProfileChoices.SINGLE_1C4P: cable_profiles.Single1C4PCableProfile, 

210 CableProfileChoices.SINGLE_1C6P: cable_profiles.Single1C6PCableProfile, 

211 CableProfileChoices.SINGLE_1C8P: cable_profiles.Single1C8PCableProfile, 

212 CableProfileChoices.SINGLE_1C12P: cable_profiles.Single1C12PCableProfile, 

213 CableProfileChoices.SINGLE_1C16P: cable_profiles.Single1C16PCableProfile, 

214 CableProfileChoices.TRUNK_2C1P: cable_profiles.Trunk2C1PCableProfile, 

215 CableProfileChoices.TRUNK_2C2P: cable_profiles.Trunk2C2PCableProfile, 

216 CableProfileChoices.TRUNK_2C4P: cable_profiles.Trunk2C4PCableProfile, 

217 CableProfileChoices.TRUNK_2C4P_SHUFFLE: cable_profiles.Trunk2C4PShuffleCableProfile, 

218 CableProfileChoices.TRUNK_2C6P: cable_profiles.Trunk2C6PCableProfile, 

219 CableProfileChoices.TRUNK_2C8P: cable_profiles.Trunk2C8PCableProfile, 

220 CableProfileChoices.TRUNK_2C12P: cable_profiles.Trunk2C12PCableProfile, 

221 CableProfileChoices.TRUNK_4C1P: cable_profiles.Trunk4C1PCableProfile, 

222 CableProfileChoices.TRUNK_4C2P: cable_profiles.Trunk4C2PCableProfile, 

223 CableProfileChoices.TRUNK_4C4P: cable_profiles.Trunk4C4PCableProfile, 

224 CableProfileChoices.TRUNK_4C4P_SHUFFLE: cable_profiles.Trunk4C4PShuffleCableProfile, 

225 CableProfileChoices.TRUNK_4C6P: cable_profiles.Trunk4C6PCableProfile, 

226 CableProfileChoices.TRUNK_4C8P: cable_profiles.Trunk4C8PCableProfile, 

227 CableProfileChoices.TRUNK_8C4P: cable_profiles.Trunk8C4PCableProfile, 

228 CableProfileChoices.BREAKOUT_1C2P_2C1P: cable_profiles.Breakout1C2Px2C1PCableProfile, 

229 CableProfileChoices.BREAKOUT_1C4P_4C1P: cable_profiles.Breakout1C4Px4C1PCableProfile, 

230 CableProfileChoices.BREAKOUT_1C6P_6C1P: cable_profiles.Breakout1C6Px6C1PCableProfile, 

231 CableProfileChoices.BREAKOUT_1C8P_8C1P: cable_profiles.Breakout1C8Px8C1PCableProfile, 

232 CableProfileChoices.BREAKOUT_2C4P_8C1P_SHUFFLE: cable_profiles.Breakout2C4Px8C1PShuffleCableProfile, 

233 }.get(self.profile) 

234 

235 def _get_x_terminations(self, side): 

236 """ 

237 Return the terminating objects for the given cable end (A or B). 

238 """ 

239 if side not in (CableEndChoices.SIDE_A, CableEndChoices.SIDE_B): 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true

240 raise ValueError(f"Unknown cable side: {side}") 

241 attr = f'_{side.lower()}_terminations' 

242 

243 if hasattr(self, attr): 243 ↛ 244line 243 didn't jump to line 244 because the condition on line 243 was never true

244 return getattr(self, attr) 

245 if not self.pk: 245 ↛ 247line 245 didn't jump to line 247 because the condition on line 245 was always true

246 return [] 

247 return [ 

248 # Query self.terminations.all() to leverage cached results 

249 ct.termination for ct in self.terminations.all() if ct.cable_end == side 

250 ] 

251 

252 def _set_x_terminations(self, side, value): 

253 """ 

254 Set the terminating objects for the given cable end (A or B). 

255 """ 

256 if side not in (CableEndChoices.SIDE_A, CableEndChoices.SIDE_B): 

257 raise ValueError(f"Unknown cable side: {side}") 

258 _attr = f'_{side.lower()}_terminations' 

259 

260 # If the provided value is a list of CableTermination IDs, resolve them 

261 # to their corresponding termination objects. 

262 if all(isinstance(item, int) for item in value): 

263 value = [ 

264 ct.termination for ct in CableTermination.objects.filter(pk__in=value).prefetch_related('termination') 

265 ] 

266 

267 if not self.pk or getattr(self, _attr, []) != list(value): 

268 self._terminations_modified = True 

269 

270 setattr(self, _attr, value) 

271 

272 @property 

273 def a_terminations(self): 

274 return self._get_x_terminations(CableEndChoices.SIDE_A) 

275 

276 @a_terminations.setter 

277 def a_terminations(self, value): 

278 self._set_x_terminations(CableEndChoices.SIDE_A, value) 

279 

280 @property 

281 def b_terminations(self): 

282 return self._get_x_terminations(CableEndChoices.SIDE_B) 

283 

284 @b_terminations.setter 

285 def b_terminations(self, value): 

286 self._set_x_terminations(CableEndChoices.SIDE_B, value) 

287 

288 @property 

289 def color_name(self): 

290 color_name = "" 

291 for hex_code, label in ColorChoices.CHOICES: 

292 if hex_code.lower() == self.color.lower(): 

293 color_name = str(label) 

294 

295 return color_name 

296 

297 def clean(self): 

298 super().clean() 

299 

300 # Validate length and length_unit 

301 if self.length is not None and not self.length_unit: 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true

302 raise ValidationError(_("Must specify a unit when setting a cable length")) 

303 

304 if self._state.adding and self.pk is None and (not self.a_terminations or not self.b_terminations): 304 ↛ 308line 304 didn't jump to line 308 because the condition on line 304 was always true

305 raise ValidationError(_("Must define A and B terminations when creating a new cable.")) 

306 

307 # Validate terminations against the assigned cable profile (if any) 

308 if self.profile: 

309 self.profile_class().clean(self) 

310 

311 if self._terminations_modified: 

312 

313 # Check that all termination objects for either end are of the same type 

314 for terms in (self.a_terminations, self.b_terminations): 

315 if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]): 

316 raise ValidationError(_("Cannot connect different termination types to same end of cable.")) 

317 

318 # Check that termination types are compatible 

319 if self.a_terminations and self.b_terminations: 

320 a_type = self.a_terminations[0]._meta.model_name 

321 b_type = self.b_terminations[0]._meta.model_name 

322 if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type): 

323 raise ValidationError( 

324 _("Incompatible termination types: {type_a} and {type_b}").format(type_a=a_type, type_b=b_type) 

325 ) 

326 if a_type == b_type: 

327 # can't directly use self.a_terminations here as possible they 

328 # don't have pk yet 

329 a_pks = set(obj.pk for obj in self.a_terminations if obj.pk) 

330 b_pks = set(obj.pk for obj in self.b_terminations if obj.pk) 

331 

332 if (a_pks & b_pks): 

333 raise ValidationError( 

334 _("A and B terminations cannot connect to the same object.") 

335 ) 

336 

337 # Run clean() on any new CableTerminations 

338 for termination in self.a_terminations: 

339 CableTermination(cable=self, cable_end='A', termination=termination).clean() 

340 for termination in self.b_terminations: 

341 CableTermination(cable=self, cable_end='B', termination=termination).clean() 

342 

343 def save(self, *args, force_insert=False, force_update=False, using=None, update_fields=None): 

344 _created = self.pk is None 

345 save_kwargs = { 

346 'using': using, 

347 'update_fields': update_fields, 

348 } 

349 update_fields = normalize_update_fields(save_kwargs) 

350 

351 length_written = update_fields is None or 'length' in update_fields 

352 length_unit_written = update_fields is None or 'length_unit' in update_fields 

353 

354 if length_written or length_unit_written: 

355 if length_written and length_unit_written: 

356 stored = {} 

357 else: 

358 # Read from the database this save will write, so a router cannot split the two 

359 db = using or router.db_for_write(Cable, instance=self) 

360 stored = Cable.objects.using(db).filter(pk=self.pk).values('length', 'length_unit').first() or {} 

361 length = self.length if length_written else stored.get('length') 

362 length_unit = self.length_unit if length_unit_written else stored.get('length_unit') 

363 

364 # Clear length_unit if no length is defined 

365 if length is None and length_unit_written: 

366 self.length_unit = None 

367 

368 # Store the given length (if any) in meters for use in database ordering 

369 if length is not None and length_unit: 

370 self._abs_length = to_meters(length, length_unit) 

371 else: 

372 self._abs_length = None 

373 

374 # _abs_length is a denormalized cache of length and length_unit, so persist them together 

375 if update_fields is not None: 

376 save_kwargs['update_fields'] = update_fields | {'_abs_length'} 

377 

378 # A field counts as changed only when this save actually writes it 

379 status_written = update_fields is None or 'status' in update_fields 

380 profile_written = update_fields is None or 'profile' in update_fields 

381 

382 # If this is a new Cable, save it before attempting to create its CableTerminations 

383 if self._state.adding: 

384 super().save(*args, force_insert=True, **save_kwargs) 

385 # Update the private PK used in __str__() 

386 self._pk = self.pk 

387 

388 if profile_written and self._orig_profile != self.profile: 

389 self.update_terminations(force=True) 

390 elif self._terminations_modified: 

391 self.update_terminations() 

392 

393 super().save(*args, force_update=True, **save_kwargs) 

394 

395 try: 

396 trace_paths.send(Cable, instance=self, created=_created) 

397 except UnsupportedCablePath as e: 

398 raise AbortRequest(e) 

399 

400 # Reset change tracking for the next save of this instance 

401 if status_written: 

402 self._orig_status = self.status 

403 if profile_written: 

404 self._orig_profile = self.profile 

405 self._terminations_modified = False 

406 

407 def delete(self, *args, **kwargs): 

408 # Cache the PK locally because super().delete() clears self.pk before the finally block runs; the 

409 # finally also guarantees the PK is discarded if the delete raises. CableQuerySet.delete() tracks 

410 # the same way for a queryset delete, which never calls this. 

411 pk = self.pk 

412 Cable._track_deletion(pk) 

413 try: 

414 return super().delete(*args, **kwargs) 

415 finally: 

416 Cable._untrack_deletion(pk) 

417 

418 @classmethod 

419 def _track_deletion(cls, pk): 

420 """ 

421 Track a Cable as being deleted, so that the post_delete handler for its cascaded CableTerminations can 

422 record the disconnect on each terminating object and skip redundant path retracing (retrace_cable_paths() 

423 retraces each affected path once, after the Cable itself is deleted). The tracking set lives on a 

424 threading.local() to isolate concurrent deletions across threads. 

425 """ 

426 if not hasattr(cls._deletion_tracking, 'pks'): 

427 cls._deletion_tracking.pks = set() 

428 cls._deletion_tracking.pks.add(pk) 

429 

430 @classmethod 

431 def _untrack_deletion(cls, pk): 

432 if hasattr(cls._deletion_tracking, 'pks'): 

433 cls._deletion_tracking.pks.discard(pk) 

434 

435 @classmethod 

436 def _is_being_deleted(cls, pk): 

437 return pk in getattr(cls._deletion_tracking, 'pks', ()) 

438 

439 def clone(self): 

440 """ 

441 Return attributes suitable for cloning this cable. 

442 

443 In addition to the fields defined in `clone_fields`, include the termination 

444 type and parent selector fields used by dcim.forms.connections.get_cable_form(). 

445 """ 

446 attrs = super().clone() 

447 

448 # Mirror dcim.forms.connections.get_cable_form() parent-field logic 

449 for cable_end, terminations in (('a', self.a_terminations), ('b', self.b_terminations)): 

450 if not terminations: 

451 continue 

452 

453 term_cls = type(terminations[0]) 

454 term_label = term_cls._meta.label_lower 

455 

456 # Matches CableForm choices: "<app_label>.<model>" 

457 attrs[f'{cable_end}_terminations_type'] = term_label 

458 

459 # Device component 

460 if hasattr(term_cls, 'device'): 

461 device_ids = sorted({t.device_id for t in terminations if t.device_id}) 

462 if device_ids: 

463 attrs[f'termination_{cable_end}_device'] = device_ids 

464 

465 # PowerFeed 

466 elif term_label == 'dcim.powerfeed': 

467 powerpanel_ids = sorted({t.power_panel_id for t in terminations if t.power_panel_id}) 

468 if powerpanel_ids: 

469 attrs[f'termination_{cable_end}_powerpanel'] = powerpanel_ids 

470 

471 # CircuitTermination 

472 elif term_label == 'circuits.circuittermination': 

473 circuit_ids = sorted({t.circuit_id for t in terminations if t.circuit_id}) 

474 if circuit_ids: 

475 attrs[f'termination_{cable_end}_circuit'] = circuit_ids 

476 

477 # Never clone the actual terminations, as they are already occupied 

478 attrs.pop('a_terminations', None) 

479 attrs.pop('b_terminations', None) 

480 

481 return attrs 

482 

483 def serialize_object(self, exclude=None): 

484 data = serialize_object(self, exclude=exclude or []) 

485 

486 # Add A & B terminations to the serialized data 

487 a_terminations, b_terminations = self.get_terminations() 

488 data['a_terminations'] = sorted([ct.pk for ct in a_terminations.values()]) 

489 data['b_terminations'] = sorted([ct.pk for ct in b_terminations.values()]) 

490 

491 return data 

492 

493 @classmethod 

494 def deserialize_object(cls, data, pk=None): 

495 a_terminations = data.pop('a_terminations', []) 

496 b_terminations = data.pop('b_terminations', []) 

497 

498 instance = deserialize_object(cls, data, pk=pk) 

499 

500 # Assign A & B termination objects to the Cable instance 

501 queryset = CableTermination.objects.prefetch_related('termination') 

502 instance.a_terminations = [ 

503 ct.termination for ct in queryset.filter(pk__in=a_terminations) 

504 ] 

505 instance.b_terminations = [ 

506 ct.termination for ct in queryset.filter(pk__in=b_terminations) 

507 ] 

508 

509 return instance 

510 

511 def update_dependent_objects(self): 

512 """ 

513 Recreate the CablePaths traversing this Cable from its current terminations. 

514 """ 

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

516 

517 # Restore channel cable attributes omitted by bulk-update change logging. 

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

519 if isinstance(ct.termination, Interface) and ct.termination.channels: 

520 ct.termination.propagate_channel_cables() 

521 

522 rebuild_cable_paths(self) 

523 update_dependent_objects.alters_data = True 

524 

525 def get_terminations(self): 

526 """ 

527 Return two dictionaries mapping A & B side terminating objects to their corresponding CableTerminations 

528 for this Cable. 

529 """ 

530 a_terminations = {} 

531 b_terminations = {} 

532 

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

534 if ct.cable_end == CableEndChoices.SIDE_A: 

535 a_terminations[ct.termination] = ct 

536 else: 

537 b_terminations[ct.termination] = ct 

538 

539 return a_terminations, b_terminations 

540 

541 def _connectors_reassigned(self, existing, terminations): 

542 """ 

543 Return True if any of the given terminating objects already terminates this Cable, but would be 

544 assigned to a different connector than the one it currently occupies. 

545 

546 Args: 

547 existing: Mapping of terminating objects to their current CableTerminations, as returned by 

548 get_terminations() 

549 terminations: The ordered list of terminating objects to be assigned to this end of the Cable 

550 """ 

551 if not self.profile: 

552 # Connectors are assigned only for a Cable which has a profile 

553 return False 

554 for connector, termination in enumerate(terminations, start=1): 

555 if (ct := existing.get(termination)) and ct.connector != connector: 

556 return True 

557 return False 

558 

559 def update_terminations(self, force=False): 

560 """ 

561 Create/delete CableTerminations for this Cable to reflect its current state. 

562 

563 Args: 

564 force: Force the recreation of all CableTerminations, even if no changes have been made. Needed e.g. when 

565 altering a Cable's assigned profile. 

566 """ 

567 a_terminations, b_terminations = self.get_terminations() 

568 

569 # A CableTermination's connector is derived from its position within its end's list of terminating 

570 # objects, so reordering that list (or removing an object from the middle of it) rewires the Cable 

571 # without changing which objects it connects. Recreate the affected end's CableTerminations so that 

572 # each is reassigned to its new connector. 

573 force_a = force or self._connectors_reassigned(a_terminations, self.a_terminations) 

574 force_b = force or self._connectors_reassigned(b_terminations, self.b_terminations) 

575 

576 # When force-recreating terminations (e.g. after a profile change), cache the termination objects 

577 # from the database before deleting, so they are available for recreation. Without this, the 

578 # a_terminations/b_terminations properties would query the DB after deletion and return empty lists. 

579 if force_a and not hasattr(self, '_a_terminations'): 

580 self._a_terminations = list(a_terminations.keys()) 

581 if force_b and not hasattr(self, '_b_terminations'): 

582 self._b_terminations = list(b_terminations.keys()) 

583 

584 # Recreating terminations invalidates existing paths, even when the endpoints are unchanged 

585 self._terminations_modified = True 

586 

587 # Delete any stale CableTerminations 

588 for termination, ct in a_terminations.items(): 

589 if force_a or (termination.pk and termination not in self.a_terminations): 

590 ct.delete() 

591 for termination, ct in b_terminations.items(): 

592 if force_b or (termination.pk and termination not in self.b_terminations): 

593 ct.delete() 

594 

595 # Save any new CableTerminations 

596 profile = self.profile_class() if self.profile else None 

597 for i, termination in enumerate(self.a_terminations, start=1): 

598 if force_a or not termination.pk or termination not in a_terminations: 

599 connector = positions = None 

600 if profile: 

601 connector = i 

602 positions = profile.get_position_list(profile.a_connectors[i]) 

603 CableTermination( 

604 cable=self, 

605 cable_end=CableEndChoices.SIDE_A, 

606 connector=connector, 

607 positions=positions, 

608 termination=termination 

609 ).save() 

610 for i, termination in enumerate(self.b_terminations, start=1): 

611 if force_b or not termination.pk or termination not in b_terminations: 

612 connector = positions = None 

613 if profile: 

614 connector = i 

615 positions = profile.get_position_list(profile.b_connectors[i]) 

616 CableTermination( 

617 cable=self, 

618 cable_end=CableEndChoices.SIDE_B, 

619 connector=connector, 

620 positions=positions, 

621 termination=termination 

622 ).save() 

623 

624 

625class CableTermination(ChangeLoggedModel): 

626 """ 

627 A mapping between side A or B of a Cable and a terminating object (e.g. an Interface or CircuitTermination). 

628 """ 

629 cable = models.ForeignKey( 

630 to='dcim.Cable', 

631 on_delete=models.CASCADE, 

632 related_name='terminations' 

633 ) 

634 cable_end = models.CharField( 

635 max_length=1, 

636 choices=CableEndChoices, 

637 verbose_name=_('end') 

638 ) 

639 termination_type = models.ForeignKey( 

640 to='contenttypes.ContentType', 

641 on_delete=models.PROTECT, 

642 related_name='+' 

643 ) 

644 termination_id = models.PositiveBigIntegerField() 

645 termination = GenericForeignKey( 

646 ct_field='termination_type', 

647 fk_field='termination_id' 

648 ) 

649 connector = models.PositiveSmallIntegerField( 

650 blank=True, 

651 null=True, 

652 validators=( 

653 MinValueValidator(CABLE_CONNECTOR_MIN), 

654 MaxValueValidator(CABLE_CONNECTOR_MAX) 

655 ), 

656 ) 

657 positions = ArrayField( 

658 base_field=models.PositiveSmallIntegerField( 

659 validators=( 

660 MinValueValidator(CABLE_POSITION_MIN), 

661 MaxValueValidator(CABLE_POSITION_MAX) 

662 ) 

663 ), 

664 blank=True, 

665 null=True, 

666 ) 

667 

668 # Cached associations to enable efficient filtering 

669 _device = models.ForeignKey( 

670 to='dcim.Device', 

671 on_delete=models.CASCADE, 

672 blank=True, 

673 null=True 

674 ) 

675 _rack = models.ForeignKey( 

676 to='dcim.Rack', 

677 on_delete=models.CASCADE, 

678 blank=True, 

679 null=True 

680 ) 

681 _location = models.ForeignKey( 

682 to='dcim.Location', 

683 on_delete=models.CASCADE, 

684 blank=True, 

685 null=True 

686 ) 

687 _site = models.ForeignKey( 

688 to='dcim.Site', 

689 on_delete=models.CASCADE, 

690 blank=True, 

691 null=True 

692 ) 

693 

694 objects = RestrictedQuerySet.as_manager() 

695 

696 class Meta: 

697 ordering = ('cable', 'cable_end', 'connector', 'pk') 

698 constraints = ( 

699 models.UniqueConstraint( 

700 fields=('termination_type', 'termination_id'), 

701 name='%(app_label)s_%(class)s_unique_termination' 

702 ), 

703 models.UniqueConstraint( 

704 fields=('cable', 'cable_end', 'connector'), 

705 name='%(app_label)s_%(class)s_unique_connector' 

706 ), 

707 ) 

708 verbose_name = _('cable termination') 

709 verbose_name_plural = _('cable terminations') 

710 

711 def __str__(self): 

712 return f'Cable {self.cable} to {self.termination}' 

713 

714 def clean(self): 

715 super().clean() 

716 

717 # Disallow connecting a cable to any termination object that is 

718 # explicitly flagged as "mark connected". 

719 termination = getattr(self, 'termination', None) 

720 if termination is not None and getattr(termination, "mark_connected", False): 

721 raise ValidationError( 

722 _("Cannot connect a cable to {obj_parent} > {obj} because it is marked as connected.").format( 

723 obj_parent=termination.parent_object, 

724 obj=termination, 

725 ) 

726 ) 

727 

728 # Check for existing termination 

729 qs = CableTermination.objects.filter( 

730 termination_type=self.termination_type, 

731 termination_id=self.termination_id 

732 ) 

733 if self.cable.pk: 

734 qs = qs.exclude(cable=self.cable) 

735 

736 existing_termination = qs.first() 

737 if existing_termination is not None: 

738 raise ValidationError( 

739 _("Duplicate termination found for {app_label}.{model} {termination_id}: cable {cable_pk}").format( 

740 app_label=self.termination_type.app_label, 

741 model=self.termination_type.model, 

742 termination_id=self.termination_id, 

743 cable_pk=existing_termination.cable.pk 

744 ) 

745 ) 

746 # A channel subinterface derives its cable from its parent interface and cannot be cabled directly. Checked 

747 # ahead of the generic type validation below (channel is a nonconnectable type) to surface the more specific 

748 # guidance. 

749 if self.termination_type.model == 'interface' and self.termination.channel_id: 

750 raise ValidationError( 

751 _("Cables cannot be terminated directly to a channel subinterface; cable the parent interface instead.") 

752 ) 

753 

754 # Validate the interface type (if applicable) 

755 if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES: 

756 raise ValidationError( 

757 _("Cables cannot be terminated to {type_display} interfaces").format( 

758 type_display=self.termination.get_type_display() 

759 ) 

760 ) 

761 

762 # A CircuitTermination attached to a ProviderNetwork cannot have a Cable 

763 if self.termination_type.model == 'circuittermination' and self.termination._provider_network is not None: 

764 raise ValidationError(_("Circuit terminations attached to a provider network may not be cabled.")) 

765 

766 def save(self, *args, **kwargs): 

767 

768 # Cache objects associated with the terminating object (for filtering) 

769 self.cache_related_objects() 

770 

771 super().save(*args, **kwargs) 

772 

773 # Set the cable on the terminating object 

774 termination = self.termination._meta.model.objects.get(pk=self.termination_id) 

775 termination.snapshot() 

776 termination.set_cable_termination(self) 

777 termination.save() 

778 

779 def delete(self, *args, **kwargs): 

780 

781 # Delete the cable association on the terminating object 

782 termination = self.termination._meta.model.objects.get(pk=self.termination_id) 

783 termination.snapshot() 

784 termination.clear_cable_termination(self) 

785 termination.save() 

786 

787 super().delete(*args, **kwargs) 

788 

789 def cache_related_objects(self): 

790 """ 

791 Cache objects related to the termination (e.g. device, rack, site) directly on the object to 

792 enable efficient filtering. 

793 """ 

794 if self.termination is None: 

795 raise ValueError( 

796 _("Invalid cable termination: the assigned termination object does not exist.") 

797 ) 

798 

799 # Device components 

800 if getattr(self.termination, 'device', None): 

801 self._device = self.termination.device 

802 self._rack = self.termination.device.rack 

803 self._location = self.termination.device.location 

804 self._site = self.termination.device.site 

805 

806 # Power feeds 

807 elif getattr(self.termination, 'rack', None): 

808 self._rack = self.termination.rack 

809 self._location = self.termination.rack.location 

810 self._site = self.termination.rack.site 

811 

812 # Circuit terminations (which cache their own site/location) 

813 elif self.termination._meta.label_lower == 'circuits.circuittermination': 

814 self._site = self.termination._site 

815 self._location = self.termination._location 

816 cache_related_objects.alters_data = True 

817 

818 def to_objectchange(self, action): 

819 objectchange = super().to_objectchange(action) 

820 objectchange.related_object = self.termination 

821 return objectchange 

822 

823 

824class CablePath(models.Model): 

825 """ 

826 A CablePath instance represents the physical path from a set of origin nodes to a set of destination nodes, 

827 including all intermediate elements. 

828 

829 `path` contains the ordered set of nodes, arranged in lists of (type, ID) tuples. (Each cable in the path can 

830 terminate to one or more objects.) For example, consider the following 

831 topology: 

832 

833 A B C 

834 Interface 1 --- Front Port 1 | Rear Port 1 --- Rear Port 2 | Front Port 3 --- Interface 2 

835 Front Port 2 Front Port 4 

836 

837 This path would be expressed as: 

838 

839 CablePath( 

840 path = [ 

841 [Interface 1], 

842 [Cable A], 

843 [Front Port 1, Front Port 2], 

844 [Rear Port 1], 

845 [Cable B], 

846 [Rear Port 2], 

847 [Front Port 3, Front Port 4], 

848 [Cable C], 

849 [Interface 2], 

850 ] 

851 ) 

852 

853 `is_active` is set to True only if every Cable within the path has a status of "connected". `is_complete` is True 

854 if the instance represents a complete end-to-end path from origin(s) to destination(s). `is_split` is True if the 

855 path diverges across multiple cables. 

856 

857 `_nodes` retains a flattened list of all nodes within the path to enable simple filtering. 

858 """ 

859 path = models.JSONField( 

860 verbose_name=_('path'), 

861 default=list 

862 ) 

863 is_active = models.BooleanField( 

864 verbose_name=_('is active'), 

865 default=False 

866 ) 

867 is_complete = models.BooleanField( 

868 verbose_name=_('is complete'), 

869 default=False 

870 ) 

871 is_split = models.BooleanField( 

872 verbose_name=_('is split'), 

873 default=False 

874 ) 

875 _nodes = PathField() 

876 

877 _netbox_private = True 

878 

879 class Meta: 

880 indexes = ( 

881 # GIN index supports @> operator used by `_nodes__contains` lookups, 

882 # which fire on every cable/termination delete and path retrace. 

883 GinIndex(fields=('_nodes',)), 

884 ) 

885 verbose_name = _('cable path') 

886 verbose_name_plural = _('cable paths') 

887 

888 def __str__(self): 

889 return f"Path #{self.pk}: {len(self.path)} hops" 

890 

891 def save(self, *args, **kwargs): 

892 

893 # Save the flattened nodes list 

894 self._nodes = list(itertools.chain(*self.path)) 

895 

896 super().save(*args, **kwargs) 

897 

898 # Record a direct reference to this CablePath on its originating object(s). Only PathEndpoint 

899 # subclasses carry the denormalized `_path` back-reference; other valid origins (e.g. 

900 # CircuitTermination) do not, so skip the update for them. 

901 origin_model = self.origin_type.model_class() 

902 if issubclass(origin_model, PathEndpoint): 

903 origin_ids = [decompile_path_node(node)[1] for node in self.path[0]] 

904 chunked_update(origin_model.objects.filter(pk__in=origin_ids), _path=self.pk) 

905 

906 def delete(self, *args, **kwargs): 

907 # Mirror save() - clear _path on origins to prevent stale references 

908 # in table views that render _path.destinations. Only PathEndpoint subclasses carry `_path`. 

909 if self.path: 

910 origin_model = self.origin_type.model_class() 

911 if issubclass(origin_model, PathEndpoint): 

912 origin_ids = [decompile_path_node(node)[1] for node in self.path[0]] 

913 chunked_update(origin_model.objects.filter(pk__in=origin_ids, _path=self.pk), _path=None) 

914 

915 super().delete(*args, **kwargs) 

916 

917 @property 

918 def origin_type(self): 

919 if self.path: 

920 ct_id, _ = decompile_path_node(self.path[0][0]) 

921 return ContentType.objects.get_for_id(ct_id) 

922 return None 

923 

924 @property 

925 def destination_type(self): 

926 if self.is_complete: 

927 ct_id, _ = decompile_path_node(self.path[-1][0]) 

928 return ContentType.objects.get_for_id(ct_id) 

929 return None 

930 

931 @property 

932 def _path_decompiled(self): 

933 res = [] 

934 for step in self.path: 

935 nodes = [] 

936 for node in step: 

937 nodes.append(decompile_path_node(node)) 

938 res.append(nodes) 

939 return res 

940 

941 path_objects = GenericArrayForeignKey("_path_decompiled") 

942 

943 @property 

944 def origins(self): 

945 """ 

946 Return the list of originating objects. 

947 """ 

948 return self.path_objects[0] 

949 

950 @property 

951 def destinations(self): 

952 """ 

953 Return the list of destination objects, if the path is complete. 

954 """ 

955 if not self.is_complete: 

956 return [] 

957 return self.path_objects[-1] 

958 

959 @property 

960 def segment_count(self): 

961 return int(len(self.path) / 3) 

962 

963 @classmethod 

964 def from_origin(cls, terminations): 

965 """ 

966 Create a new CablePath instance as traced from the given termination objects. These can be any object to which a 

967 Cable or WirelessLink connects (interfaces, console ports, circuit termination, etc.). All terminations must be 

968 of the same type and must belong to the same parent object. 

969 """ 

970 from circuits.models import Circuit, CircuitTermination 

971 

972 if not terminations: 

973 return None 

974 

975 # Ensure all originating terminations are attached to the same link 

976 if len(terminations) > 1 and not all(t.link == terminations[0].link for t in terminations[1:]): 

977 raise UnsupportedCablePath(_("All originating terminations must be attached to the same link")) 

978 

979 path = [] 

980 position_stack = [] 

981 is_complete = False 

982 is_active = True 

983 is_split = False 

984 

985 logger.debug(f'Tracing cable path from {terminations}...') 

986 

987 segment = 0 

988 while terminations: 

989 segment += 1 

990 logger.debug(f'[Path segment #{segment}] Position stack: {position_stack}') 

991 logger.debug(f'[Path segment #{segment}] Local terminations: {terminations}') 

992 

993 # Terminations must all be of the same type 

994 if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]): 

995 raise UnsupportedCablePath(_("All mid-span terminations must have the same termination type")) 

996 

997 # All mid-span terminations must all be attached to the same device 

998 if ( 

999 not isinstance(terminations[0], PathEndpoint) and 

1000 not isinstance(terminations[0].parent_object, Circuit) and 

1001 not all(t.parent_object == terminations[0].parent_object for t in terminations[1:]) 

1002 ): 

1003 raise UnsupportedCablePath(_("All mid-span terminations must have the same parent object")) 

1004 

1005 # Check for a split path (e.g. rear port fanning out to multiple front ports with 

1006 # different cables attached) 

1007 if len(set(t.link for t in terminations)) > 1 and ( 

1008 position_stack and len(terminations) != len(position_stack[-1]) 

1009 ): 

1010 is_split = True 

1011 break 

1012 

1013 # Step 1: Record the near-end termination object(s) 

1014 path.append([ 

1015 object_to_path_node(t) for t in terminations 

1016 ]) 

1017 # If not null, push cable positions onto the stack 

1018 if isinstance(terminations[0], PathEndpoint) and terminations[0].cable_positions: 

1019 position_stack.append(list(terminations[0].cable_positions)) 

1020 

1021 # Step 2: Determine the attached links (Cable or WirelessLink), if any 

1022 links = list(dict.fromkeys( 

1023 termination.link for termination in terminations if termination.link is not None 

1024 )) 

1025 logger.debug(f'[Path segment #{segment}] Links: {links}') 

1026 if len(links) == 0: 

1027 if len(path) == 1: 

1028 # If this is the start of the path and no link exists, return None 

1029 return None 

1030 # Otherwise, halt the trace if no link exists 

1031 break 

1032 if not all(type(link) in (Cable, WirelessLink) for link in links): 

1033 raise UnsupportedCablePath(_("All links must be cable or wireless")) 

1034 if not all(isinstance(link, type(links[0])) for link in links): 

1035 raise UnsupportedCablePath(_("All links must match first link type")) 

1036 

1037 # Step 3: Record asymmetric paths as split 

1038 not_connected_terminations = [termination.link for termination in terminations if termination.link is None] 

1039 if len(not_connected_terminations) > 0: 

1040 is_complete = False 

1041 is_split = True 

1042 

1043 # Step 4: Record the links, keeping cables in order to allow for SVG rendering 

1044 cables = [] 

1045 for link in links: 

1046 if object_to_path_node(link) not in cables: 

1047 cables.append(object_to_path_node(link)) 

1048 path.append(cables) 

1049 

1050 # Step 5: Update the path status if a link is not connected 

1051 links_status = [link.status for link in links if link.status != LinkStatusChoices.STATUS_CONNECTED] 

1052 if any([status != LinkStatusChoices.STATUS_CONNECTED for status in links_status]): 

1053 is_active = False 

1054 

1055 # Step 6: Determine the far-end terminations 

1056 if isinstance(links[0], Cable): 

1057 # Profile-based tracing 

1058 if links[0].profile: 

1059 cable_profile = links[0].profile_class() 

1060 if position_stack: 

1061 positions = position_stack.pop() 

1062 else: 

1063 # When the position stack is empty (e.g. the trace reached this 

1064 # profiled cable after crossing single-position pass-through ports 

1065 # which don't push onto the stack), derive positions from each 

1066 # termination's own cable_positions — which were set by this 

1067 # profiled cable when it was saved. 

1068 positions = [ 

1069 pos for term in terminations for pos in (term.cable_positions or []) 

1070 ] 

1071 remote_terminations = [] 

1072 new_positions = [] 

1073 

1074 # Build (termination, position) pairs by matching stacked positions 

1075 # to each termination's cable_positions. This correctly handles 

1076 # multiple terminations on different connectors of the same cable. 

1077 remaining = Counter(positions) 

1078 term_position_pairs = [] 

1079 for term in terminations: 

1080 if term.cable_positions: 

1081 for cp in term.cable_positions: 

1082 if remaining[cp]: 

1083 term_position_pairs.append((term, cp)) 

1084 remaining[cp] -= 1 

1085 

1086 # Fallback for when positions don't match cable_positions 

1087 if not term_position_pairs: 

1088 term_position_pairs = [(terminations[0], pos) for pos in positions or [None]] 

1089 

1090 peer_results = cable_profile.get_peer_terminations(term_position_pairs) 

1091 seen = set() 

1092 for peer, new_pos in peer_results: 

1093 # If the far-end termination is a channelized interface, resolve to the specific channel 

1094 # subinterface bound to the mapped connector position (the far end is channelized on the same 

1095 # physical connector, so the peer lookup returns the parent rather than the channel). A 

1096 # channelized parent is never itself a path endpoint, so an unoccupied position yields no peer. 

1097 if new_pos is not None and getattr(peer, 'channels', None): 

1098 peer = peer.child_interfaces.filter(channel_id=new_pos).first() 

1099 # Deduplicate peer terminations by model type & PK. 

1100 key = None if peer is None else (peer._meta.concrete_model, peer.pk) 

1101 if key not in seen: 

1102 seen.add(key) 

1103 remote_terminations.append(peer) 

1104 new_positions.append(new_pos) 

1105 

1106 # If all peers resolved to None (no far-end terminations exist), 

1107 # treat as an empty result so the path is recorded as incomplete 

1108 # rather than falling through to the endpoint check with a stale 

1109 # None entry. 

1110 if remote_terminations and all(peer is None for peer in remote_terminations): 

1111 remote_terminations = [] 

1112 

1113 position_stack.append(new_positions) 

1114 

1115 # Legacy (positionless) behavior 

1116 else: 

1117 termination_type = ObjectType.objects.get_for_model(terminations[0]) 

1118 local_cable_terminations = CableTermination.objects.filter( 

1119 termination_type=termination_type, 

1120 termination_id__in=[t.pk for t in terminations] 

1121 ) 

1122 

1123 q_filter = Q() 

1124 for lct in local_cable_terminations: 

1125 cable_end = 'A' if lct.cable_end == 'B' else 'B' 

1126 q_filter |= Q(cable=lct.cable, cable_end=cable_end) 

1127 

1128 # Make sure this filter has been populated; if not, we have probably been given invalid data 

1129 if not q_filter: 

1130 break 

1131 

1132 remote_cable_terminations = CableTermination.objects.filter(q_filter).prefetch_related( 

1133 'termination' 

1134 ) 

1135 remote_terminations = [ct.termination for ct in remote_cable_terminations] 

1136 else: 

1137 # WirelessLink 

1138 remote_terminations = [ 

1139 link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links 

1140 ] 

1141 

1142 logger.debug(f'[Path segment #{segment}] Remote terminations: {remote_terminations}') 

1143 

1144 # Remote Terminations must all be of the same type, otherwise return a split path 

1145 if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]): 

1146 is_complete = False 

1147 is_split = True 

1148 logger.debug('Remote termination types differ; aborting trace.') 

1149 break 

1150 

1151 # Step 7: Record the far-end termination object(s) 

1152 path.append([ 

1153 object_to_path_node(t) for t in remote_terminations if t is not None 

1154 ]) 

1155 

1156 # Step 8: Determine the "next hop" terminations, if applicable 

1157 if not remote_terminations: 

1158 break 

1159 

1160 if isinstance(remote_terminations[0], FrontPort): 

1161 # Follow FrontPorts to their corresponding RearPorts 

1162 if remote_terminations[0].positions > 1 and position_stack: 

1163 positions = position_stack.pop() 

1164 q_filter = Q() 

1165 for rt in remote_terminations: 

1166 q_filter |= Q(front_port=rt, front_port_position__in=positions) 

1167 port_mappings = PortMapping.objects.filter(q_filter) 

1168 elif remote_terminations[0].positions > 1: 

1169 is_split = True 

1170 logger.debug( 

1171 'Encountered front port mapped to multiple rear ports but position stack is empty; aborting ' 

1172 'trace.' 

1173 ) 

1174 break 

1175 else: 

1176 port_mappings = PortMapping.objects.filter(front_port__in=remote_terminations) 

1177 if not port_mappings: 

1178 break 

1179 

1180 # Compile the list of RearPorts without duplication or altering their ordering 

1181 terminations = list(dict.fromkeys(mapping.rear_port for mapping in port_mappings)) 

1182 if any(t.positions > 1 for t in terminations): 

1183 position_stack.append([mapping.rear_port_position for mapping in port_mappings]) 

1184 

1185 elif isinstance(remote_terminations[0], RearPort): 

1186 # Follow RearPorts to their corresponding FrontPorts 

1187 if remote_terminations[0].positions > 1 and position_stack: 

1188 positions = position_stack.pop() 

1189 q_filter = Q() 

1190 for rt in remote_terminations: 

1191 q_filter |= Q(rear_port=rt, rear_port_position__in=positions) 

1192 port_mappings = PortMapping.objects.filter(q_filter) 

1193 elif remote_terminations[0].positions > 1: 

1194 is_split = True 

1195 logger.debug( 

1196 'Encountered rear port mapped to multiple front ports but position stack is empty; aborting ' 

1197 'trace.' 

1198 ) 

1199 break 

1200 else: 

1201 port_mappings = PortMapping.objects.filter(rear_port__in=remote_terminations) 

1202 if not port_mappings: 

1203 break 

1204 

1205 # Compile the list of FrontPorts without duplication or altering their ordering 

1206 terminations = list(dict.fromkeys(mapping.front_port for mapping in port_mappings)) 

1207 if any(t.positions > 1 for t in terminations): 

1208 position_stack.append([mapping.front_port_position for mapping in port_mappings]) 

1209 

1210 elif isinstance(remote_terminations[0], CircuitTermination): 

1211 # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa) 

1212 qs = Q() 

1213 for remote_termination in remote_terminations: 

1214 qs |= Q( 

1215 circuit=remote_termination.circuit, 

1216 term_side='Z' if remote_termination.term_side == 'A' else 'A' 

1217 ) 

1218 

1219 # Get all circuit terminations 

1220 circuit_terminations = CircuitTermination.objects.filter(qs) 

1221 

1222 if not circuit_terminations.exists(): 

1223 break 

1224 if all([ct._provider_network for ct in circuit_terminations]): 

1225 # Circuit terminates to a ProviderNetwork 

1226 path.extend([ 

1227 [object_to_path_node(ct) for ct in circuit_terminations], 

1228 [object_to_path_node(ct._provider_network) for ct in circuit_terminations], 

1229 ]) 

1230 is_complete = True 

1231 break 

1232 if all([ct.termination and not ct.cable for ct in circuit_terminations]): 

1233 # Circuit terminates to a Region/Site/etc. 

1234 path.extend([ 

1235 [object_to_path_node(ct) for ct in circuit_terminations], 

1236 [object_to_path_node(ct.termination) for ct in circuit_terminations], 

1237 ]) 

1238 break 

1239 if any([ct.cable in links for ct in circuit_terminations]): 

1240 # No valid path 

1241 is_split = True 

1242 break 

1243 

1244 terminations = circuit_terminations 

1245 

1246 else: 

1247 # Check for non-symmetric path 

1248 if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]): 

1249 is_complete = True 

1250 elif len(remote_terminations) == 0: 

1251 is_complete = False 

1252 else: 

1253 # Unsupported topology, mark as split and exit 

1254 is_complete = False 

1255 is_split = True 

1256 logger.warning('Encountered an unsupported topology; aborting trace.') 

1257 break 

1258 

1259 return cls( 

1260 path=path, 

1261 is_complete=is_complete, 

1262 is_active=is_active, 

1263 is_split=is_split 

1264 ) 

1265 

1266 def retrace(self): 

1267 """ 

1268 Retrace the path from the currently-defined originating termination(s) 

1269 """ 

1270 _new = self.from_origin(self.origins) 

1271 if _new: 

1272 self.path = _new.path 

1273 self.is_complete = _new.is_complete 

1274 self.is_active = _new.is_active 

1275 self.is_split = _new.is_split 

1276 self.save() 

1277 else: 

1278 self.delete() 

1279 retrace.alters_data = True 

1280 

1281 def get_cable_ids(self): 

1282 """ 

1283 Return all Cable IDs within the path. 

1284 """ 

1285 cable_ct = ObjectType.objects.get_for_model(Cable).pk 

1286 cable_ids = [] 

1287 

1288 for node in self._nodes: 

1289 ct, id = decompile_path_node(node) 

1290 if ct == cable_ct: 

1291 cable_ids.append(id) 

1292 

1293 return cable_ids 

1294 

1295 def get_total_length(self): 

1296 """ 

1297 Return a tuple containing the sum of the length of each cable and the distance of each circuit 

1298 crossed by the path, and a flag indicating whether the length is definitive. 

1299 """ 

1300 from circuits.models import CircuitTermination 

1301 

1302 object_types = ObjectType.objects.get_for_models(Cable, CircuitTermination) 

1303 cable_ct = object_types[Cable].pk 

1304 circuit_termination_ct = object_types[CircuitTermination].pk 

1305 

1306 # Pre-cache cable lengths by ID 

1307 cable_ids = self.get_cable_ids() 

1308 cables = { 

1309 cable['pk']: cable['_abs_length'] 

1310 for cable in Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False).values('pk', '_abs_length') 

1311 } 

1312 

1313 # Pre-cache the circuit terminations within the path, along with their circuits 

1314 circuit_termination_ids = [] 

1315 for node in self._nodes: 

1316 ct, pk = decompile_path_node(node) 

1317 if ct == circuit_termination_ct: 

1318 circuit_termination_ids.append(pk) 

1319 circuit_terminations = CircuitTermination.objects.select_related('circuit').in_bulk(circuit_termination_ids) 

1320 

1321 # Iterate through each set of nodes in the path. For cables, add the length of the longest cable to the total 

1322 # length of the path. Also map each set of nodes to its circuit terminations, keyed by circuit ID. 

1323 total_length = 0 

1324 circuit_hops = [] 

1325 for node_set in self.path: 

1326 hop_length = 0 

1327 hop_terminations = {} 

1328 for node in node_set: 

1329 ct, pk = decompile_path_node(node) 

1330 if ct == cable_ct: 

1331 if pk in cables and cables[pk] > hop_length: 

1332 hop_length = cables[pk] 

1333 elif ct == circuit_termination_ct: 

1334 termination = circuit_terminations.get(pk) 

1335 if termination is not None: 

1336 hop_terminations[termination.circuit_id] = termination 

1337 else: 

1338 break # Neither a cable nor a circuit termination 

1339 total_length += hop_length 

1340 circuit_hops.append(hop_terminations) 

1341 

1342 # Unresolvable circuit terminations may conceal a crossing, so they render the total non-definitive 

1343 is_definitive = len(cables) == len(cable_ids) and len(circuit_terminations) == len(set(circuit_termination_ids)) 

1344 

1345 # A circuit crossing appears as two adjacent sets of opposing terminations of the same circuit. For each 

1346 # crossing, add the longest distance among the circuits crossed, mirroring the handling of parallel cables. 

1347 for near_hop, far_hop in itertools.pairwise(circuit_hops): 

1348 crossing_distance = 0 

1349 for circuit_id in near_hop.keys() & far_hop.keys(): 

1350 if near_hop[circuit_id].term_side == far_hop[circuit_id].term_side: 

1351 continue 

1352 distance = near_hop[circuit_id].circuit._abs_distance 

1353 if distance is None: 

1354 is_definitive = False 

1355 elif distance > crossing_distance: 

1356 crossing_distance = distance 

1357 total_length += crossing_distance 

1358 

1359 return total_length, is_definitive 

1360 

1361 def get_split_nodes(self): 

1362 """ 

1363 Return all available next segments in a split cable path. 

1364 """ 

1365 from circuits.models import CircuitTermination 

1366 nodes = self.path_objects[-1] 

1367 

1368 # RearPort splitting to multiple FrontPorts with no stack position 

1369 if type(nodes[0]) is RearPort: 

1370 return [ 

1371 mapping.front_port for mapping in 

1372 PortMapping.objects.filter(rear_port__in=nodes).prefetch_related('front_port') 

1373 ] 

1374 # Cable terminating to multiple FrontPorts mapped to different 

1375 # RearPorts connected to different cables 

1376 if type(nodes[0]) is FrontPort: 

1377 return [ 

1378 mapping.rear_port for mapping in 

1379 PortMapping.objects.filter(front_port__in=nodes).prefetch_related('rear_port') 

1380 ] 

1381 # Cable terminating to multiple CircuitTerminations 

1382 if type(nodes[0]) is CircuitTermination: 

1383 return [ 

1384 ct.get_peer_termination() for ct in nodes 

1385 ] 

1386 return [] 

1387 

1388 def get_asymmetric_nodes(self): 

1389 """ 

1390 Return all available next segments in a split cable path. 

1391 """ 

1392 from circuits.models import CircuitTermination 

1393 asymmetric_nodes = [] 

1394 for nodes in self.path_objects: 

1395 if type(nodes[0]) in [RearPort, FrontPort, CircuitTermination]: 

1396 asymmetric_nodes.extend([node for node in nodes if node.link is None]) 

1397 

1398 return asymmetric_nodes