cables.py 58 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407
  1. import itertools
  2. import logging
  3. import threading
  4. from collections import Counter
  5. from django.contrib.contenttypes.fields import GenericForeignKey
  6. from django.contrib.contenttypes.models import ContentType
  7. from django.contrib.postgres.fields import ArrayField
  8. from django.contrib.postgres.indexes import GinIndex
  9. from django.core.exceptions import ValidationError
  10. from django.core.validators import MaxValueValidator, MinValueValidator
  11. from django.db import models, router, transaction
  12. from django.dispatch import Signal
  13. from django.urls import reverse
  14. from django.utils.translation import gettext_lazy as _
  15. from core.models import ObjectType
  16. from dcim.choices import *
  17. from dcim.constants import *
  18. from dcim.exceptions import UnsupportedCablePath
  19. from dcim.fields import PathField
  20. from dcim.utils import decompile_path_node, object_to_path_node, rebuild_cable_paths
  21. from netbox.choices import ColorChoices
  22. from netbox.models import ChangeLoggedModel, PrimaryModel
  23. from utilities.conversion import to_meters
  24. from utilities.data import normalize_update_fields
  25. from utilities.exceptions import AbortRequest
  26. from utilities.fields import ColorField, GenericArrayForeignKey
  27. from utilities.querysets import RestrictedQuerySet, chunked_update
  28. from utilities.serialization import deserialize_object, serialize_object
  29. from wireless.models import WirelessLink
  30. from .device_components import FrontPort, Interface, PathEndpoint, PortMapping, RearPort
  31. __all__ = (
  32. 'Cable',
  33. 'CableBundle',
  34. 'CablePath',
  35. 'CableTermination',
  36. )
  37. logger = logging.getLogger(f'netbox.{__name__}')
  38. trace_paths = Signal()
  39. #
  40. # Cable bundles
  41. #
  42. class CableBundle(PrimaryModel):
  43. """
  44. A logical grouping of individual cables.
  45. """
  46. name = models.CharField(
  47. verbose_name=_('name'),
  48. max_length=100,
  49. unique=True,
  50. )
  51. class Meta:
  52. ordering = ('name',)
  53. verbose_name = _('cable bundle')
  54. verbose_name_plural = _('cable bundles')
  55. def __str__(self):
  56. return self.name
  57. def get_absolute_url(self):
  58. return reverse('dcim:cablebundle', args=[self.pk])
  59. #
  60. # Cables
  61. #
  62. class CableQuerySet(RestrictedQuerySet):
  63. def delete(self):
  64. # Track these Cables as being deleted for the duration, as Cable.delete() does for a single
  65. # instance: a queryset delete never calls it. Between them the two cover every deletion, as a
  66. # Cable is never itself cascade-deleted (nothing points at it with on_delete=CASCADE).
  67. # Resolve the PKs on the DB the delete will use, so read routing can't miss a lagging replica.
  68. using = self._db or router.db_for_write(self.model, **self._hints)
  69. pks = list(self.using(using).values_list('pk', flat=True))
  70. for pk in pks:
  71. Cable._track_deletion(pk)
  72. try:
  73. return super().delete()
  74. finally:
  75. for pk in pks:
  76. Cable._untrack_deletion(pk)
  77. class Cable(PrimaryModel):
  78. """
  79. A physical connection between two endpoints.
  80. """
  81. # Per-thread tracking of Cable PKs currently being deleted; referenced by
  82. # dcim.signals.nullify_connected_endpoints to record the disconnect on each terminating object and
  83. # to skip per-CableTermination path retracing during the cascade (retrace_cable_paths does it once).
  84. _deletion_tracking = threading.local()
  85. type = models.CharField(
  86. verbose_name=_('type'),
  87. max_length=50,
  88. choices=CableTypeChoices,
  89. blank=True,
  90. null=True
  91. )
  92. status = models.CharField(
  93. verbose_name=_('status'),
  94. max_length=50,
  95. choices=LinkStatusChoices,
  96. default=LinkStatusChoices.STATUS_CONNECTED
  97. )
  98. profile = models.CharField(
  99. verbose_name=_('profile'),
  100. max_length=50,
  101. choices=CableProfileChoices,
  102. blank=True,
  103. )
  104. tenant = models.ForeignKey(
  105. to='tenancy.Tenant',
  106. on_delete=models.PROTECT,
  107. related_name='cables',
  108. blank=True,
  109. null=True
  110. )
  111. label = models.CharField(
  112. verbose_name=_('label'),
  113. max_length=100,
  114. blank=True
  115. )
  116. color = ColorField(
  117. verbose_name=_('color'),
  118. blank=True
  119. )
  120. length = models.DecimalField(
  121. verbose_name=_('length'),
  122. max_digits=8,
  123. decimal_places=2,
  124. blank=True,
  125. null=True
  126. )
  127. length_unit = models.CharField(
  128. verbose_name=_('length unit'),
  129. max_length=50,
  130. choices=CableLengthUnitChoices,
  131. blank=True,
  132. null=True
  133. )
  134. # Stores the normalized length (in meters) for database ordering
  135. _abs_length = models.DecimalField(
  136. max_digits=14,
  137. decimal_places=4,
  138. blank=True,
  139. null=True
  140. )
  141. bundle = models.ForeignKey(
  142. to='dcim.CableBundle',
  143. on_delete=models.SET_NULL,
  144. related_name='cables',
  145. blank=True,
  146. null=True,
  147. verbose_name=_('bundle'),
  148. )
  149. clone_fields = ('tenant', 'type', 'profile', 'bundle')
  150. objects = CableQuerySet.as_manager()
  151. class Meta:
  152. ordering = ('pk',)
  153. verbose_name = _('cable')
  154. verbose_name_plural = _('cables')
  155. def __init__(self, *args, a_terminations=None, b_terminations=None, **kwargs):
  156. super().__init__(*args, **kwargs)
  157. # A copy of the PK to be used by __str__ in case the object is deleted
  158. self._pk = self.__dict__.get('id')
  159. # Cache the original profile & status so we can check later whether either has been changed
  160. self._orig_status = self.__dict__.get('status')
  161. self._orig_profile = self.__dict__.get('profile')
  162. self._terminations_modified = False
  163. # Assign or retrieve A/B terminations
  164. if a_terminations:
  165. self.a_terminations = a_terminations
  166. if b_terminations:
  167. self.b_terminations = b_terminations
  168. def __str__(self):
  169. pk = self.pk or self._pk
  170. return self.label or f'#{pk}'
  171. def get_status_color(self):
  172. return LinkStatusChoices.colors.get(self.status)
  173. @property
  174. def profile_class(self):
  175. from dcim import cable_profiles
  176. return {
  177. CableProfileChoices.SINGLE_1C1P: cable_profiles.Single1C1PCableProfile,
  178. CableProfileChoices.SINGLE_1C2P: cable_profiles.Single1C2PCableProfile,
  179. CableProfileChoices.SINGLE_1C4P: cable_profiles.Single1C4PCableProfile,
  180. CableProfileChoices.SINGLE_1C6P: cable_profiles.Single1C6PCableProfile,
  181. CableProfileChoices.SINGLE_1C8P: cable_profiles.Single1C8PCableProfile,
  182. CableProfileChoices.SINGLE_1C12P: cable_profiles.Single1C12PCableProfile,
  183. CableProfileChoices.SINGLE_1C16P: cable_profiles.Single1C16PCableProfile,
  184. CableProfileChoices.TRUNK_2C1P: cable_profiles.Trunk2C1PCableProfile,
  185. CableProfileChoices.TRUNK_2C2P: cable_profiles.Trunk2C2PCableProfile,
  186. CableProfileChoices.TRUNK_2C4P: cable_profiles.Trunk2C4PCableProfile,
  187. CableProfileChoices.TRUNK_2C4P_SHUFFLE: cable_profiles.Trunk2C4PShuffleCableProfile,
  188. CableProfileChoices.TRUNK_2C6P: cable_profiles.Trunk2C6PCableProfile,
  189. CableProfileChoices.TRUNK_2C8P: cable_profiles.Trunk2C8PCableProfile,
  190. CableProfileChoices.TRUNK_2C12P: cable_profiles.Trunk2C12PCableProfile,
  191. CableProfileChoices.TRUNK_4C1P: cable_profiles.Trunk4C1PCableProfile,
  192. CableProfileChoices.TRUNK_4C2P: cable_profiles.Trunk4C2PCableProfile,
  193. CableProfileChoices.TRUNK_4C4P: cable_profiles.Trunk4C4PCableProfile,
  194. CableProfileChoices.TRUNK_4C4P_SHUFFLE: cable_profiles.Trunk4C4PShuffleCableProfile,
  195. CableProfileChoices.TRUNK_4C6P: cable_profiles.Trunk4C6PCableProfile,
  196. CableProfileChoices.TRUNK_4C8P: cable_profiles.Trunk4C8PCableProfile,
  197. CableProfileChoices.TRUNK_8C1P: cable_profiles.Trunk8C1PCableProfile,
  198. CableProfileChoices.TRUNK_8C4P: cable_profiles.Trunk8C4PCableProfile,
  199. CableProfileChoices.TRUNK_12C1P: cable_profiles.Trunk12C1PCableProfile,
  200. CableProfileChoices.TRUNK_24C1P: cable_profiles.Trunk24C1PCableProfile,
  201. CableProfileChoices.BREAKOUT_1C2P_2C1P: cable_profiles.Breakout1C2Px2C1PCableProfile,
  202. CableProfileChoices.BREAKOUT_1C4P_4C1P: cable_profiles.Breakout1C4Px4C1PCableProfile,
  203. CableProfileChoices.BREAKOUT_1C6P_6C1P: cable_profiles.Breakout1C6Px6C1PCableProfile,
  204. CableProfileChoices.BREAKOUT_1C8P_8C1P: cable_profiles.Breakout1C8Px8C1PCableProfile,
  205. CableProfileChoices.BREAKOUT_2C4P_8C1P_SHUFFLE: cable_profiles.Breakout2C4Px8C1PShuffleCableProfile,
  206. }.get(self.profile)
  207. def _get_x_terminations(self, side):
  208. """
  209. Return the terminating objects for the given cable end (A or B).
  210. """
  211. if side not in (CableEndChoices.SIDE_A, CableEndChoices.SIDE_B):
  212. raise ValueError(f"Unknown cable side: {side}")
  213. attr = f'_{side.lower()}_terminations'
  214. if hasattr(self, attr):
  215. # Return a copy so a caller mutating the result cannot defeat change detection
  216. return list(getattr(self, attr))
  217. if not self.pk:
  218. return []
  219. return [
  220. # Query self.terminations.all() to leverage cached results
  221. ct.termination for ct in self.terminations.all() if ct.cable_end == side
  222. ]
  223. def _set_x_terminations(self, side, value):
  224. """
  225. Set the terminating objects for the given cable end (A or B).
  226. """
  227. if side not in (CableEndChoices.SIDE_A, CableEndChoices.SIDE_B):
  228. raise ValueError(f"Unknown cable side: {side}")
  229. _attr = f'_{side.lower()}_terminations'
  230. # Materialize first, so the stored list is our own and a single-pass iterable is read only once
  231. value = list(value)
  232. # If the provided value is a list of CableTermination IDs, resolve them
  233. # to their corresponding termination objects.
  234. if all(isinstance(item, int) for item in value):
  235. value = [
  236. ct.termination for ct in CableTermination.objects.filter(pk__in=value).prefetch_related('termination')
  237. ]
  238. # An uncached end always counts as assigned: replayed rows may already match while paths still need rebuilding
  239. if not self.pk or not hasattr(self, _attr) or getattr(self, _attr) != value:
  240. self._terminations_modified = True
  241. setattr(self, _attr, value)
  242. @property
  243. def a_terminations(self):
  244. return self._get_x_terminations(CableEndChoices.SIDE_A)
  245. @a_terminations.setter
  246. def a_terminations(self, value):
  247. self._set_x_terminations(CableEndChoices.SIDE_A, value)
  248. @property
  249. def b_terminations(self):
  250. return self._get_x_terminations(CableEndChoices.SIDE_B)
  251. @b_terminations.setter
  252. def b_terminations(self, value):
  253. self._set_x_terminations(CableEndChoices.SIDE_B, value)
  254. @property
  255. def color_name(self):
  256. color_name = ""
  257. for hex_code, label in ColorChoices.CHOICES:
  258. if hex_code.lower() == self.color.lower():
  259. color_name = str(label)
  260. return color_name
  261. def clean(self):
  262. super().clean()
  263. # Validate length and length_unit
  264. if self.length is not None and not self.length_unit:
  265. raise ValidationError(_("Must specify a unit when setting a cable length"))
  266. if self._state.adding and self.pk is None and (not self.a_terminations or not self.b_terminations):
  267. raise ValidationError(_("Must define A and B terminations when creating a new cable."))
  268. # Validate terminations against the assigned cable profile (if any)
  269. if self.profile:
  270. self.profile_class().clean(self)
  271. if self._terminations_modified:
  272. # Check that all termination objects for either end are of the same type
  273. for terms in (self.a_terminations, self.b_terminations):
  274. if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]):
  275. raise ValidationError(_("Cannot connect different termination types to same end of cable."))
  276. # Check that termination types are compatible
  277. if self.a_terminations and self.b_terminations:
  278. a_type = self.a_terminations[0]._meta.model_name
  279. b_type = self.b_terminations[0]._meta.model_name
  280. if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
  281. raise ValidationError(
  282. _("Incompatible termination types: {type_a} and {type_b}").format(type_a=a_type, type_b=b_type)
  283. )
  284. if a_type == b_type:
  285. # can't directly use self.a_terminations here as possible they
  286. # don't have pk yet
  287. a_pks = set(obj.pk for obj in self.a_terminations if obj.pk)
  288. b_pks = set(obj.pk for obj in self.b_terminations if obj.pk)
  289. if (a_pks & b_pks):
  290. raise ValidationError(
  291. _("A and B terminations cannot connect to the same object.")
  292. )
  293. # Run clean() on any new CableTerminations
  294. for termination in self.a_terminations:
  295. CableTermination(cable=self, cable_end='A', termination=termination).clean()
  296. for termination in self.b_terminations:
  297. CableTermination(cable=self, cable_end='B', termination=termination).clean()
  298. def save(self, *args, force_insert=False, force_update=False, using=None, update_fields=None):
  299. _created = self.pk is None
  300. save_kwargs = {
  301. 'using': using,
  302. 'update_fields': update_fields,
  303. }
  304. update_fields = normalize_update_fields(save_kwargs)
  305. length_written = update_fields is None or 'length' in update_fields
  306. length_unit_written = update_fields is None or 'length_unit' in update_fields
  307. if length_written or length_unit_written:
  308. if length_written and length_unit_written:
  309. stored = {}
  310. else:
  311. # Read from the database this save will write, so a router cannot split the two
  312. db = using or router.db_for_write(Cable, instance=self)
  313. stored = Cable.objects.using(db).filter(pk=self.pk).values('length', 'length_unit').first() or {}
  314. length = self.length if length_written else stored.get('length')
  315. length_unit = self.length_unit if length_unit_written else stored.get('length_unit')
  316. # Clear length_unit if no length is defined
  317. if length is None and length_unit_written:
  318. self.length_unit = None
  319. # Store the given length (if any) in meters for use in database ordering
  320. if length is not None and length_unit:
  321. self._abs_length = to_meters(length, length_unit)
  322. else:
  323. self._abs_length = None
  324. # _abs_length is a denormalized cache of length and length_unit, so persist them together
  325. if update_fields is not None:
  326. save_kwargs['update_fields'] = update_fields | {'_abs_length'}
  327. # A field counts as changed only when this save actually writes it
  328. status_written = update_fields is None or 'status' in update_fields
  329. profile_written = update_fields is None or 'profile' in update_fields
  330. # If this is a new Cable, save it before attempting to create its CableTerminations
  331. if self._state.adding:
  332. super().save(*args, force_insert=True, **save_kwargs)
  333. # Update the private PK used in __str__()
  334. self._pk = self.pk
  335. if profile_written and self._orig_profile != self.profile:
  336. self.update_terminations(force=True)
  337. elif self._terminations_modified:
  338. self.update_terminations()
  339. super().save(*args, force_update=True, **save_kwargs)
  340. try:
  341. trace_paths.send(Cable, instance=self, created=_created, status_written=status_written)
  342. except UnsupportedCablePath as e:
  343. raise AbortRequest(e)
  344. # Reset change tracking for the next save of this instance
  345. if status_written:
  346. self._orig_status = self.status
  347. if profile_written:
  348. self._orig_profile = self.profile
  349. self._terminations_modified = False
  350. def delete(self, *args, **kwargs):
  351. # Cache the PK locally because super().delete() clears self.pk before the finally block runs; the
  352. # finally also guarantees the PK is discarded if the delete raises. CableQuerySet.delete() tracks
  353. # the same way for a queryset delete, which never calls this.
  354. pk = self.pk
  355. Cable._track_deletion(pk)
  356. try:
  357. return super().delete(*args, **kwargs)
  358. finally:
  359. Cable._untrack_deletion(pk)
  360. @classmethod
  361. def _track_deletion(cls, pk):
  362. """
  363. Track a Cable as being deleted, so that the post_delete handler for its cascaded CableTerminations can
  364. record the disconnect on each terminating object and skip redundant path retracing (retrace_cable_paths()
  365. retraces each affected path once, after the Cable itself is deleted). The tracking set lives on a
  366. threading.local() to isolate concurrent deletions across threads.
  367. """
  368. if not hasattr(cls._deletion_tracking, 'pks'):
  369. cls._deletion_tracking.pks = set()
  370. cls._deletion_tracking.pks.add(pk)
  371. @classmethod
  372. def _untrack_deletion(cls, pk):
  373. if hasattr(cls._deletion_tracking, 'pks'):
  374. cls._deletion_tracking.pks.discard(pk)
  375. @classmethod
  376. def _is_being_deleted(cls, pk):
  377. return pk in getattr(cls._deletion_tracking, 'pks', ())
  378. def clone(self):
  379. """
  380. Return attributes suitable for cloning this cable.
  381. In addition to the fields defined in `clone_fields`, include the termination
  382. type and parent selector fields used by dcim.forms.connections.get_cable_form().
  383. """
  384. attrs = super().clone()
  385. # Mirror dcim.forms.connections.get_cable_form() parent-field logic
  386. for cable_end, terminations in (('a', self.a_terminations), ('b', self.b_terminations)):
  387. if not terminations:
  388. continue
  389. term_cls = type(terminations[0])
  390. term_label = term_cls._meta.label_lower
  391. # Matches CableForm choices: "<app_label>.<model>"
  392. attrs[f'{cable_end}_terminations_type'] = term_label
  393. # Device component
  394. if hasattr(term_cls, 'device'):
  395. device_ids = sorted({t.device_id for t in terminations if t.device_id})
  396. if device_ids:
  397. attrs[f'termination_{cable_end}_device'] = device_ids
  398. # PowerFeed
  399. elif term_label == 'dcim.powerfeed':
  400. powerpanel_ids = sorted({t.power_panel_id for t in terminations if t.power_panel_id})
  401. if powerpanel_ids:
  402. attrs[f'termination_{cable_end}_powerpanel'] = powerpanel_ids
  403. # CircuitTermination
  404. elif term_label == 'circuits.circuittermination':
  405. circuit_ids = sorted({t.circuit_id for t in terminations if t.circuit_id})
  406. if circuit_ids:
  407. attrs[f'termination_{cable_end}_circuit'] = circuit_ids
  408. # Never clone the actual terminations, as they are already occupied
  409. attrs.pop('a_terminations', None)
  410. attrs.pop('b_terminations', None)
  411. return attrs
  412. def serialize_object(self, exclude=None):
  413. data = serialize_object(self, exclude=exclude or [])
  414. # Add A & B terminations to the serialized data
  415. a_terminations, b_terminations = self.get_terminations()
  416. data['a_terminations'] = sorted([ct.pk for ct in a_terminations.values()])
  417. data['b_terminations'] = sorted([ct.pk for ct in b_terminations.values()])
  418. return data
  419. @classmethod
  420. def deserialize_object(cls, data, pk=None):
  421. a_terminations = data.pop('a_terminations', [])
  422. b_terminations = data.pop('b_terminations', [])
  423. instance = deserialize_object(cls, data, pk=pk)
  424. # Assign A & B termination objects to the Cable instance
  425. queryset = CableTermination.objects.prefetch_related('termination')
  426. instance.a_terminations = [
  427. ct.termination for ct in queryset.filter(pk__in=a_terminations)
  428. ]
  429. instance.b_terminations = [
  430. ct.termination for ct in queryset.filter(pk__in=b_terminations)
  431. ]
  432. return instance
  433. def update_dependent_objects(self):
  434. """
  435. Recreate the CablePaths traversing this Cable from its current terminations.
  436. """
  437. with transaction.atomic(using=router.db_for_write(CablePath)):
  438. # Restore channel cable attributes omitted by bulk-update change logging.
  439. for ct in CableTermination.objects.filter(cable=self).prefetch_related('termination'):
  440. if isinstance(ct.termination, Interface) and ct.termination.channels:
  441. ct.termination.propagate_channel_cables()
  442. rebuild_cable_paths(self)
  443. update_dependent_objects.alters_data = True
  444. def get_terminations(self):
  445. """
  446. Return two dictionaries mapping A & B side terminating objects to their corresponding CableTerminations
  447. for this Cable.
  448. """
  449. a_terminations = {}
  450. b_terminations = {}
  451. for ct in CableTermination.objects.filter(cable=self).prefetch_related('termination'):
  452. if ct.cable_end == CableEndChoices.SIDE_A:
  453. a_terminations[ct.termination] = ct
  454. else:
  455. b_terminations[ct.termination] = ct
  456. return a_terminations, b_terminations
  457. def _connectors_reassigned(self, existing, terminations):
  458. """
  459. Return True if any of the given terminating objects already terminates this Cable, but would be
  460. assigned to a different connector than the one it currently occupies.
  461. Args:
  462. existing: Mapping of terminating objects to their current CableTerminations, as returned by
  463. get_terminations()
  464. terminations: The ordered list of terminating objects to be assigned to this end of the Cable
  465. """
  466. if not self.profile:
  467. # Connectors are assigned only for a Cable which has a profile
  468. return False
  469. for connector, termination in enumerate(terminations, start=1):
  470. if (ct := existing.get(termination)) and ct.connector != connector:
  471. return True
  472. return False
  473. def update_terminations(self, force=False):
  474. """
  475. Create/delete CableTerminations for this Cable to reflect its current state.
  476. Args:
  477. force: Force the recreation of all CableTerminations, even if no changes have been made. Needed e.g. when
  478. altering a Cable's assigned profile.
  479. """
  480. a_terminations, b_terminations = self.get_terminations()
  481. # A CableTermination's connector is derived from its position within its end's list of terminating
  482. # objects, so reordering that list (or removing an object from the middle of it) rewires the Cable
  483. # without changing which objects it connects. Recreate the affected end's CableTerminations so that
  484. # each is reassigned to its new connector.
  485. force_a = force or self._connectors_reassigned(a_terminations, self.a_terminations)
  486. force_b = force or self._connectors_reassigned(b_terminations, self.b_terminations)
  487. # Recreating either end's terminations invalidates its paths, even when the endpoints are unchanged
  488. if force_a or force_b:
  489. self._terminations_modified = True
  490. # When force-recreating terminations (e.g. after a profile change), cache the termination objects
  491. # from the database before deleting, so they are available for recreation. Without this, the
  492. # a_terminations/b_terminations properties would query the DB after deletion and return empty lists.
  493. if force_a and not hasattr(self, '_a_terminations'):
  494. self._a_terminations = list(a_terminations.keys())
  495. if force_b and not hasattr(self, '_b_terminations'):
  496. self._b_terminations = list(b_terminations.keys())
  497. # Delete any stale CableTerminations
  498. for termination, ct in a_terminations.items():
  499. if force_a or (termination.pk and termination not in self.a_terminations):
  500. ct.delete()
  501. for termination, ct in b_terminations.items():
  502. if force_b or (termination.pk and termination not in self.b_terminations):
  503. ct.delete()
  504. # Save any new CableTerminations
  505. profile = self.profile_class() if self.profile else None
  506. for i, termination in enumerate(self.a_terminations, start=1):
  507. if force_a or not termination.pk or termination not in a_terminations:
  508. connector = positions = None
  509. if profile:
  510. connector = i
  511. positions = profile.get_position_list(profile.a_connectors[i])
  512. CableTermination(
  513. cable=self,
  514. cable_end=CableEndChoices.SIDE_A,
  515. connector=connector,
  516. positions=positions,
  517. termination=termination
  518. ).save()
  519. for i, termination in enumerate(self.b_terminations, start=1):
  520. if force_b or not termination.pk or termination not in b_terminations:
  521. connector = positions = None
  522. if profile:
  523. connector = i
  524. positions = profile.get_position_list(profile.b_connectors[i])
  525. CableTermination(
  526. cable=self,
  527. cable_end=CableEndChoices.SIDE_B,
  528. connector=connector,
  529. positions=positions,
  530. termination=termination
  531. ).save()
  532. class CableTermination(ChangeLoggedModel):
  533. """
  534. A mapping between side A or B of a Cable and a terminating object (e.g. an Interface or CircuitTermination).
  535. """
  536. cable = models.ForeignKey(
  537. to='dcim.Cable',
  538. on_delete=models.CASCADE,
  539. related_name='terminations'
  540. )
  541. cable_end = models.CharField(
  542. max_length=1,
  543. choices=CableEndChoices,
  544. verbose_name=_('end')
  545. )
  546. termination_type = models.ForeignKey(
  547. to='contenttypes.ContentType',
  548. on_delete=models.PROTECT,
  549. related_name='+'
  550. )
  551. termination_id = models.PositiveBigIntegerField()
  552. termination = GenericForeignKey(
  553. ct_field='termination_type',
  554. fk_field='termination_id'
  555. )
  556. connector = models.PositiveSmallIntegerField(
  557. blank=True,
  558. null=True,
  559. validators=(
  560. MinValueValidator(CABLE_CONNECTOR_MIN),
  561. MaxValueValidator(CABLE_CONNECTOR_MAX)
  562. ),
  563. )
  564. positions = ArrayField(
  565. base_field=models.PositiveSmallIntegerField(
  566. validators=(
  567. MinValueValidator(CABLE_POSITION_MIN),
  568. MaxValueValidator(CABLE_POSITION_MAX)
  569. )
  570. ),
  571. blank=True,
  572. null=True,
  573. )
  574. # Cached associations to enable efficient filtering
  575. _device = models.ForeignKey(
  576. to='dcim.Device',
  577. on_delete=models.CASCADE,
  578. blank=True,
  579. null=True
  580. )
  581. _rack = models.ForeignKey(
  582. to='dcim.Rack',
  583. on_delete=models.CASCADE,
  584. blank=True,
  585. null=True
  586. )
  587. _location = models.ForeignKey(
  588. to='dcim.Location',
  589. on_delete=models.CASCADE,
  590. blank=True,
  591. null=True
  592. )
  593. _site = models.ForeignKey(
  594. to='dcim.Site',
  595. on_delete=models.CASCADE,
  596. blank=True,
  597. null=True
  598. )
  599. objects = RestrictedQuerySet.as_manager()
  600. class Meta:
  601. ordering = ('cable', 'cable_end', 'connector', 'pk')
  602. constraints = (
  603. models.UniqueConstraint(
  604. fields=('termination_type', 'termination_id'),
  605. name='%(app_label)s_%(class)s_unique_termination'
  606. ),
  607. models.UniqueConstraint(
  608. fields=('cable', 'cable_end', 'connector'),
  609. name='%(app_label)s_%(class)s_unique_connector'
  610. ),
  611. )
  612. verbose_name = _('cable termination')
  613. verbose_name_plural = _('cable terminations')
  614. def __str__(self):
  615. return f'Cable {self.cable} to {self.termination}'
  616. def clean(self):
  617. super().clean()
  618. # Disallow connecting a cable to any termination object that is
  619. # explicitly flagged as "mark connected".
  620. termination = getattr(self, 'termination', None)
  621. if termination is not None and getattr(termination, "mark_connected", False):
  622. raise ValidationError(
  623. _("Cannot connect a cable to {obj_parent} > {obj} because it is marked as connected.").format(
  624. obj_parent=termination.parent_object,
  625. obj=termination,
  626. )
  627. )
  628. # Check for existing termination
  629. qs = CableTermination.objects.filter(
  630. termination_type=self.termination_type,
  631. termination_id=self.termination_id
  632. )
  633. if self.cable.pk:
  634. qs = qs.exclude(cable=self.cable)
  635. existing_termination = qs.first()
  636. if existing_termination is not None:
  637. raise ValidationError(
  638. _("Duplicate termination found for {app_label}.{model} {termination_id}: cable {cable_pk}").format(
  639. app_label=self.termination_type.app_label,
  640. model=self.termination_type.model,
  641. termination_id=self.termination_id,
  642. cable_pk=existing_termination.cable.pk
  643. )
  644. )
  645. # A channel subinterface derives its cable from its parent interface and cannot be cabled directly. Checked
  646. # ahead of the generic type validation below (channel is a nonconnectable type) to surface the more specific
  647. # guidance.
  648. if self.termination_type.model == 'interface' and self.termination.channel_id:
  649. raise ValidationError(
  650. _("Cables cannot be terminated directly to a channel subinterface; cable the parent interface instead.")
  651. )
  652. # Validate the interface type (if applicable)
  653. if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
  654. raise ValidationError(
  655. _("Cables cannot be terminated to {type_display} interfaces").format(
  656. type_display=self.termination.get_type_display()
  657. )
  658. )
  659. # A CircuitTermination attached to a ProviderNetwork cannot have a Cable
  660. if self.termination_type.model == 'circuittermination' and self.termination._provider_network is not None:
  661. raise ValidationError(_("Circuit terminations attached to a provider network may not be cabled."))
  662. def save(self, *args, **kwargs):
  663. # Cache objects associated with the terminating object (for filtering)
  664. self.cache_related_objects()
  665. super().save(*args, **kwargs)
  666. # Set the cable on the terminating object
  667. termination = self.termination._meta.model.objects.get(pk=self.termination_id)
  668. termination.snapshot()
  669. termination.set_cable_termination(self)
  670. termination.save()
  671. def delete(self, *args, **kwargs):
  672. # Delete the cable association on the terminating object
  673. termination = self.termination._meta.model.objects.get(pk=self.termination_id)
  674. termination.snapshot()
  675. termination.clear_cable_termination(self)
  676. termination.save()
  677. super().delete(*args, **kwargs)
  678. def cache_related_objects(self):
  679. """
  680. Cache objects related to the termination (e.g. device, rack, site) directly on the object to
  681. enable efficient filtering.
  682. """
  683. if self.termination is None:
  684. raise ValueError(
  685. _("Invalid cable termination: the assigned termination object does not exist.")
  686. )
  687. # Device components
  688. if getattr(self.termination, 'device', None):
  689. self._device = self.termination.device
  690. self._rack = self.termination.device.rack
  691. self._location = self.termination.device.location
  692. self._site = self.termination.device.site
  693. # Power feeds
  694. elif getattr(self.termination, 'rack', None):
  695. self._rack = self.termination.rack
  696. self._location = self.termination.rack.location
  697. self._site = self.termination.rack.site
  698. # Circuit terminations (which cache their own site/location)
  699. elif self.termination._meta.label_lower == 'circuits.circuittermination':
  700. self._site = self.termination._site
  701. self._location = self.termination._location
  702. cache_related_objects.alters_data = True
  703. def to_objectchange(self, action):
  704. objectchange = super().to_objectchange(action)
  705. objectchange.related_object = self.termination
  706. return objectchange
  707. class CablePath(models.Model):
  708. """
  709. A CablePath instance represents the physical path from a set of origin nodes to a set of destination nodes,
  710. including all intermediate elements.
  711. `path` contains the ordered set of nodes, arranged in lists of (type, ID) tuples. (Each cable in the path can
  712. terminate to one or more objects.) For example, consider the following
  713. topology:
  714. A B C
  715. Interface 1 --- Front Port 1 | Rear Port 1 --- Rear Port 2 | Front Port 3 --- Interface 2
  716. Front Port 2 Front Port 4
  717. This path would be expressed as:
  718. CablePath(
  719. path = [
  720. [Interface 1],
  721. [Cable A],
  722. [Front Port 1, Front Port 2],
  723. [Rear Port 1],
  724. [Cable B],
  725. [Rear Port 2],
  726. [Front Port 3, Front Port 4],
  727. [Cable C],
  728. [Interface 2],
  729. ]
  730. )
  731. `is_active` is set to True only if every Cable within the path has a status of "connected". `is_complete` is True
  732. if the instance represents a complete end-to-end path from origin(s) to destination(s). `is_split` is True if the
  733. path diverges across multiple cables.
  734. `_nodes` retains a flattened list of all nodes within the path to enable simple filtering.
  735. """
  736. path = models.JSONField(
  737. verbose_name=_('path'),
  738. default=list
  739. )
  740. is_active = models.BooleanField(
  741. verbose_name=_('is active'),
  742. default=False
  743. )
  744. is_complete = models.BooleanField(
  745. verbose_name=_('is complete'),
  746. default=False
  747. )
  748. is_split = models.BooleanField(
  749. verbose_name=_('is split'),
  750. default=False
  751. )
  752. _nodes = PathField()
  753. _netbox_private = True
  754. class Meta:
  755. indexes = (
  756. # GIN index supports @> operator used by `_nodes__contains` lookups,
  757. # which fire on every cable/termination delete and path retrace.
  758. GinIndex(fields=('_nodes',)),
  759. )
  760. verbose_name = _('cable path')
  761. verbose_name_plural = _('cable paths')
  762. def __str__(self):
  763. return f"Path #{self.pk}: {len(self.path)} hops"
  764. def save(self, *args, **kwargs):
  765. # Save the flattened nodes list
  766. self._nodes = list(itertools.chain(*self.path))
  767. super().save(*args, **kwargs)
  768. # Record a direct reference to this CablePath on its originating object(s). Only PathEndpoint
  769. # subclasses carry the denormalized `_path` back-reference; other valid origins (e.g.
  770. # CircuitTermination) do not, so skip the update for them.
  771. origin_model = self.origin_type.model_class()
  772. if issubclass(origin_model, PathEndpoint):
  773. origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
  774. chunked_update(origin_model.objects.filter(pk__in=origin_ids), _path=self.pk)
  775. def delete(self, *args, **kwargs):
  776. # Mirror save() - clear _path on origins to prevent stale references
  777. # in table views that render _path.destinations. Only PathEndpoint subclasses carry `_path`.
  778. if self.path:
  779. origin_model = self.origin_type.model_class()
  780. if issubclass(origin_model, PathEndpoint):
  781. origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
  782. chunked_update(origin_model.objects.filter(pk__in=origin_ids, _path=self.pk), _path=None)
  783. super().delete(*args, **kwargs)
  784. @property
  785. def origin_type(self):
  786. if self.path:
  787. ct_id, _ = decompile_path_node(self.path[0][0])
  788. return ContentType.objects.get_for_id(ct_id)
  789. return None
  790. @property
  791. def destination_type(self):
  792. if self.is_complete:
  793. ct_id, _ = decompile_path_node(self.path[-1][0])
  794. return ContentType.objects.get_for_id(ct_id)
  795. return None
  796. @property
  797. def _path_decompiled(self):
  798. res = []
  799. for step in self.path:
  800. nodes = []
  801. for node in step:
  802. nodes.append(decompile_path_node(node))
  803. res.append(nodes)
  804. return res
  805. path_objects = GenericArrayForeignKey("_path_decompiled")
  806. @property
  807. def origins(self):
  808. """
  809. Return the list of originating objects.
  810. """
  811. return self.path_objects[0]
  812. @property
  813. def destinations(self):
  814. """
  815. Return the list of destination objects, if the path is complete.
  816. """
  817. if not self.is_complete:
  818. return []
  819. return self.path_objects[-1]
  820. @property
  821. def segment_count(self):
  822. return int(len(self.path) / 3)
  823. @classmethod
  824. def from_origin(cls, terminations):
  825. """
  826. Create a new CablePath instance as traced from the given termination objects. These can be any object to which a
  827. Cable or WirelessLink connects (interfaces, console ports, circuit termination, etc.). All terminations must be
  828. of the same type and must belong to the same parent object.
  829. """
  830. from circuits.models import Circuit, CircuitTermination
  831. if not terminations:
  832. return None
  833. # Ensure all originating terminations are attached to the same link
  834. if len(terminations) > 1 and not all(t.link == terminations[0].link for t in terminations[1:]):
  835. raise UnsupportedCablePath(_("All originating terminations must be attached to the same link"))
  836. path = []
  837. position_stack = []
  838. is_complete = False
  839. is_active = True
  840. is_split = False
  841. logger.debug(f'Tracing cable path from {terminations}...')
  842. segment = 0
  843. while terminations:
  844. segment += 1
  845. logger.debug(f'[Path segment #{segment}] Position stack: {position_stack}')
  846. logger.debug(f'[Path segment #{segment}] Local terminations: {terminations}')
  847. # Terminations must all be of the same type
  848. if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]):
  849. raise UnsupportedCablePath(_("All mid-span terminations must have the same termination type"))
  850. # All mid-span terminations must all be attached to the same device
  851. if (
  852. not isinstance(terminations[0], PathEndpoint) and
  853. not isinstance(terminations[0].parent_object, Circuit) and
  854. not all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
  855. ):
  856. raise UnsupportedCablePath(_("All mid-span terminations must have the same parent object"))
  857. # Check for a split path (e.g. rear port fanning out to multiple front ports with
  858. # different cables attached)
  859. if len(set(t.link for t in terminations)) > 1 and (
  860. position_stack and len(terminations) != len(position_stack[-1])
  861. ):
  862. is_split = True
  863. break
  864. # Step 1: Record the near-end termination object(s)
  865. path.append([
  866. object_to_path_node(t) for t in terminations
  867. ])
  868. # If not null, push cable positions onto the stack
  869. if isinstance(terminations[0], PathEndpoint) and terminations[0].cable_positions:
  870. position_stack.append(list(terminations[0].cable_positions))
  871. # Step 2: Determine the attached links (Cable or WirelessLink), if any
  872. links = list(dict.fromkeys(
  873. termination.link for termination in terminations if termination.link is not None
  874. ))
  875. logger.debug(f'[Path segment #{segment}] Links: {links}')
  876. if len(links) == 0:
  877. if len(path) == 1:
  878. # If this is the start of the path and no link exists, return None
  879. return None
  880. # Otherwise, halt the trace if no link exists
  881. break
  882. if not all(type(link) in (Cable, WirelessLink) for link in links):
  883. raise UnsupportedCablePath(_("All links must be cable or wireless"))
  884. if not all(isinstance(link, type(links[0])) for link in links):
  885. raise UnsupportedCablePath(_("All links must match first link type"))
  886. # Step 3: Record asymmetric paths as split
  887. not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
  888. if len(not_connected_terminations) > 0:
  889. is_complete = False
  890. is_split = True
  891. # Step 4: Record the links, keeping cables in order to allow for SVG rendering
  892. cables = []
  893. for link in links:
  894. if object_to_path_node(link) not in cables:
  895. cables.append(object_to_path_node(link))
  896. path.append(cables)
  897. # Step 5: Update the path status if a link is not connected
  898. links_status = [link.status for link in links if link.status != LinkStatusChoices.STATUS_CONNECTED]
  899. if any([status != LinkStatusChoices.STATUS_CONNECTED for status in links_status]):
  900. is_active = False
  901. # Step 6: Determine the far-end terminations
  902. if isinstance(links[0], Cable):
  903. # Profile-based tracing
  904. if links[0].profile:
  905. cable_profile = links[0].profile_class()
  906. if position_stack:
  907. positions = position_stack.pop()
  908. else:
  909. # When the position stack is empty (e.g. the trace reached this
  910. # profiled cable after crossing single-position pass-through ports
  911. # which don't push onto the stack), derive positions from each
  912. # termination's own cable_positions — which were set by this
  913. # profiled cable when it was saved.
  914. positions = [
  915. pos for term in terminations for pos in (term.cable_positions or [])
  916. ]
  917. remote_terminations = []
  918. new_positions = []
  919. # Build (termination, position) pairs by matching stacked positions
  920. # to each termination's cable_positions. This correctly handles
  921. # multiple terminations on different connectors of the same cable.
  922. remaining = Counter(positions)
  923. term_position_pairs = []
  924. for term in terminations:
  925. if term.cable_positions:
  926. for cp in term.cable_positions:
  927. if remaining[cp]:
  928. term_position_pairs.append((term, cp))
  929. remaining[cp] -= 1
  930. # Fallback for when positions don't match cable_positions
  931. if not term_position_pairs:
  932. term_position_pairs = [(terminations[0], pos) for pos in positions or [None]]
  933. peer_results = cable_profile.get_peer_terminations(term_position_pairs)
  934. seen = set()
  935. for peer, new_pos in peer_results:
  936. # If the far-end termination is a channelized interface, resolve to the specific channel
  937. # subinterface bound to the mapped connector position (the far end is channelized on the same
  938. # physical connector, so the peer lookup returns the parent rather than the channel). A
  939. # channelized parent is never itself a path endpoint, so an unoccupied position yields no peer.
  940. if new_pos is not None and getattr(peer, 'channels', None):
  941. peer = peer.child_interfaces.filter(channel_id=new_pos).first()
  942. # Deduplicate peer terminations by model type & PK.
  943. key = None if peer is None else (peer._meta.concrete_model, peer.pk)
  944. if key not in seen:
  945. seen.add(key)
  946. remote_terminations.append(peer)
  947. new_positions.append(new_pos)
  948. # If all peers resolved to None (no far-end terminations exist),
  949. # treat as an empty result so the path is recorded as incomplete
  950. # rather than falling through to the endpoint check with a stale
  951. # None entry.
  952. if remote_terminations and all(peer is None for peer in remote_terminations):
  953. remote_terminations = []
  954. position_stack.append(new_positions)
  955. # Legacy (positionless) behavior
  956. else:
  957. termination_type = ObjectType.objects.get_for_model(terminations[0])
  958. local_cable_terminations = CableTermination.objects.filter(
  959. termination_type=termination_type,
  960. termination_id__in=[t.pk for t in terminations]
  961. )
  962. q_filter = Q()
  963. for lct in local_cable_terminations:
  964. cable_end = 'A' if lct.cable_end == 'B' else 'B'
  965. q_filter |= Q(cable=lct.cable, cable_end=cable_end)
  966. # Make sure this filter has been populated; if not, we have probably been given invalid data
  967. if not q_filter:
  968. break
  969. remote_cable_terminations = CableTermination.objects.filter(q_filter).prefetch_related(
  970. 'termination'
  971. )
  972. remote_terminations = [ct.termination for ct in remote_cable_terminations]
  973. else:
  974. # WirelessLink
  975. remote_terminations = [
  976. link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links
  977. ]
  978. logger.debug(f'[Path segment #{segment}] Remote terminations: {remote_terminations}')
  979. # Remote Terminations must all be of the same type, otherwise return a split path
  980. if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
  981. is_complete = False
  982. is_split = True
  983. logger.debug('Remote termination types differ; aborting trace.')
  984. break
  985. # Step 7: Record the far-end termination object(s)
  986. path.append([
  987. object_to_path_node(t) for t in remote_terminations if t is not None
  988. ])
  989. # Step 8: Determine the "next hop" terminations, if applicable
  990. if not remote_terminations:
  991. break
  992. if isinstance(remote_terminations[0], FrontPort):
  993. # Follow FrontPorts to their corresponding RearPorts
  994. if remote_terminations[0].positions > 1 and position_stack:
  995. positions = position_stack.pop()
  996. q_filter = Q()
  997. for rt in remote_terminations:
  998. q_filter |= Q(front_port=rt, front_port_position__in=positions)
  999. port_mappings = PortMapping.objects.filter(q_filter)
  1000. elif remote_terminations[0].positions > 1:
  1001. is_split = True
  1002. logger.debug(
  1003. 'Encountered front port mapped to multiple rear ports but position stack is empty; aborting '
  1004. 'trace.'
  1005. )
  1006. break
  1007. else:
  1008. port_mappings = PortMapping.objects.filter(front_port__in=remote_terminations)
  1009. if not port_mappings:
  1010. break
  1011. # Compile the list of RearPorts without duplication or altering their ordering
  1012. terminations = list(dict.fromkeys(mapping.rear_port for mapping in port_mappings))
  1013. if any(t.positions > 1 for t in terminations):
  1014. position_stack.append([mapping.rear_port_position for mapping in port_mappings])
  1015. elif isinstance(remote_terminations[0], RearPort):
  1016. # Follow RearPorts to their corresponding FrontPorts
  1017. if remote_terminations[0].positions > 1 and position_stack:
  1018. positions = position_stack.pop()
  1019. q_filter = Q()
  1020. for rt in remote_terminations:
  1021. q_filter |= Q(rear_port=rt, rear_port_position__in=positions)
  1022. port_mappings = PortMapping.objects.filter(q_filter)
  1023. elif remote_terminations[0].positions > 1:
  1024. is_split = True
  1025. logger.debug(
  1026. 'Encountered rear port mapped to multiple front ports but position stack is empty; aborting '
  1027. 'trace.'
  1028. )
  1029. break
  1030. else:
  1031. port_mappings = PortMapping.objects.filter(rear_port__in=remote_terminations)
  1032. if not port_mappings:
  1033. break
  1034. # Compile the list of FrontPorts without duplication or altering their ordering
  1035. terminations = list(dict.fromkeys(mapping.front_port for mapping in port_mappings))
  1036. if any(t.positions > 1 for t in terminations):
  1037. position_stack.append([mapping.front_port_position for mapping in port_mappings])
  1038. elif isinstance(remote_terminations[0], CircuitTermination):
  1039. # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
  1040. qs = Q()
  1041. for remote_termination in remote_terminations:
  1042. qs |= Q(
  1043. circuit=remote_termination.circuit,
  1044. term_side='Z' if remote_termination.term_side == 'A' else 'A'
  1045. )
  1046. # Get all circuit terminations
  1047. circuit_terminations = CircuitTermination.objects.filter(qs)
  1048. if not circuit_terminations.exists():
  1049. break
  1050. if all([ct._provider_network for ct in circuit_terminations]):
  1051. # Circuit terminates to a ProviderNetwork
  1052. path.extend([
  1053. [object_to_path_node(ct) for ct in circuit_terminations],
  1054. [object_to_path_node(ct._provider_network) for ct in circuit_terminations],
  1055. ])
  1056. is_complete = True
  1057. break
  1058. if all([ct.termination and not ct.cable for ct in circuit_terminations]):
  1059. # Circuit terminates to a Region/Site/etc.
  1060. path.extend([
  1061. [object_to_path_node(ct) for ct in circuit_terminations],
  1062. [object_to_path_node(ct.termination) for ct in circuit_terminations],
  1063. ])
  1064. break
  1065. if any([ct.cable in links for ct in circuit_terminations]):
  1066. # No valid path
  1067. is_split = True
  1068. break
  1069. terminations = circuit_terminations
  1070. else:
  1071. # Check for non-symmetric path
  1072. if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
  1073. is_complete = True
  1074. elif len(remote_terminations) == 0:
  1075. is_complete = False
  1076. else:
  1077. # Unsupported topology, mark as split and exit
  1078. is_complete = False
  1079. is_split = True
  1080. logger.warning('Encountered an unsupported topology; aborting trace.')
  1081. break
  1082. return cls(
  1083. path=path,
  1084. is_complete=is_complete,
  1085. is_active=is_active,
  1086. is_split=is_split
  1087. )
  1088. def retrace(self):
  1089. """
  1090. Retrace the path from the currently-defined originating termination(s)
  1091. """
  1092. _new = self.from_origin(self.origins)
  1093. if _new:
  1094. self.path = _new.path
  1095. self.is_complete = _new.is_complete
  1096. self.is_active = _new.is_active
  1097. self.is_split = _new.is_split
  1098. self.save()
  1099. else:
  1100. self.delete()
  1101. retrace.alters_data = True
  1102. def get_cable_ids(self):
  1103. """
  1104. Return all Cable IDs within the path.
  1105. """
  1106. cable_ct = ObjectType.objects.get_for_model(Cable).pk
  1107. cable_ids = []
  1108. for node in self._nodes:
  1109. ct, id = decompile_path_node(node)
  1110. if ct == cable_ct:
  1111. cable_ids.append(id)
  1112. return cable_ids
  1113. def get_total_length(self):
  1114. """
  1115. Return a tuple containing the sum of the length of each cable and the distance of each circuit
  1116. crossed by the path, and a flag indicating whether the length is definitive.
  1117. """
  1118. from circuits.models import CircuitTermination
  1119. object_types = ObjectType.objects.get_for_models(Cable, CircuitTermination)
  1120. cable_ct = object_types[Cable].pk
  1121. circuit_termination_ct = object_types[CircuitTermination].pk
  1122. # Pre-cache cable lengths by ID
  1123. cable_ids = self.get_cable_ids()
  1124. cables = {
  1125. cable['pk']: cable['_abs_length']
  1126. for cable in Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False).values('pk', '_abs_length')
  1127. }
  1128. # Pre-cache the circuit terminations within the path, along with their circuits
  1129. circuit_termination_ids = []
  1130. for node in self._nodes:
  1131. ct, pk = decompile_path_node(node)
  1132. if ct == circuit_termination_ct:
  1133. circuit_termination_ids.append(pk)
  1134. circuit_terminations = CircuitTermination.objects.select_related('circuit').in_bulk(circuit_termination_ids)
  1135. # Iterate through each set of nodes in the path. For cables, add the length of the longest cable to the total
  1136. # length of the path. Also map each set of nodes to its circuit terminations, keyed by circuit ID.
  1137. total_length = 0
  1138. circuit_hops = []
  1139. for node_set in self.path:
  1140. hop_length = 0
  1141. hop_terminations = {}
  1142. for node in node_set:
  1143. ct, pk = decompile_path_node(node)
  1144. if ct == cable_ct:
  1145. if pk in cables and cables[pk] > hop_length:
  1146. hop_length = cables[pk]
  1147. elif ct == circuit_termination_ct:
  1148. termination = circuit_terminations.get(pk)
  1149. if termination is not None:
  1150. hop_terminations[termination.circuit_id] = termination
  1151. else:
  1152. break # Neither a cable nor a circuit termination
  1153. total_length += hop_length
  1154. circuit_hops.append(hop_terminations)
  1155. # Unresolvable circuit terminations may conceal a crossing, so they render the total non-definitive
  1156. is_definitive = len(cables) == len(cable_ids) and len(circuit_terminations) == len(set(circuit_termination_ids))
  1157. # A circuit crossing appears as two adjacent sets of opposing terminations of the same circuit. For each
  1158. # crossing, add the longest distance among the circuits crossed, mirroring the handling of parallel cables.
  1159. for near_hop, far_hop in itertools.pairwise(circuit_hops):
  1160. crossing_distance = 0
  1161. for circuit_id in near_hop.keys() & far_hop.keys():
  1162. if near_hop[circuit_id].term_side == far_hop[circuit_id].term_side:
  1163. continue
  1164. distance = near_hop[circuit_id].circuit._abs_distance
  1165. if distance is None:
  1166. is_definitive = False
  1167. elif distance > crossing_distance:
  1168. crossing_distance = distance
  1169. total_length += crossing_distance
  1170. return total_length, is_definitive
  1171. def get_split_nodes(self):
  1172. """
  1173. Return all available next segments in a split cable path.
  1174. """
  1175. from circuits.models import CircuitTermination
  1176. nodes = self.path_objects[-1]
  1177. # RearPort splitting to multiple FrontPorts with no stack position
  1178. if type(nodes[0]) is RearPort:
  1179. return [
  1180. mapping.front_port for mapping in
  1181. PortMapping.objects.filter(rear_port__in=nodes).prefetch_related('front_port')
  1182. ]
  1183. # Cable terminating to multiple FrontPorts mapped to different
  1184. # RearPorts connected to different cables
  1185. if type(nodes[0]) is FrontPort:
  1186. return [
  1187. mapping.rear_port for mapping in
  1188. PortMapping.objects.filter(front_port__in=nodes).prefetch_related('rear_port')
  1189. ]
  1190. # Cable terminating to multiple CircuitTerminations
  1191. if type(nodes[0]) is CircuitTermination:
  1192. return [
  1193. ct.get_peer_termination() for ct in nodes
  1194. ]
  1195. return []
  1196. def get_asymmetric_nodes(self):
  1197. """
  1198. Return all available next segments in a split cable path.
  1199. """
  1200. from circuits.models import CircuitTermination
  1201. asymmetric_nodes = []
  1202. for nodes in self.path_objects:
  1203. if type(nodes[0]) in [RearPort, FrontPort, CircuitTermination]:
  1204. asymmetric_nodes.extend([node for node in nodes if node.link is None])
  1205. return asymmetric_nodes