cables.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978
  1. import itertools
  2. from django.contrib.contenttypes.fields import GenericForeignKey
  3. from django.contrib.contenttypes.models import ContentType
  4. from django.core.exceptions import ValidationError
  5. from django.core.validators import MaxValueValidator, MinValueValidator
  6. from django.db import models
  7. from django.dispatch import Signal
  8. from django.utils.translation import gettext_lazy as _
  9. from core.models import ObjectType
  10. from dcim.choices import *
  11. from dcim.constants import *
  12. from dcim.exceptions import UnsupportedCablePath
  13. from dcim.fields import PathField
  14. from dcim.utils import decompile_path_node, object_to_path_node
  15. from netbox.choices import ColorChoices
  16. from netbox.models import ChangeLoggedModel, PrimaryModel
  17. from utilities.conversion import to_meters
  18. from utilities.exceptions import AbortRequest
  19. from utilities.fields import ColorField, GenericArrayForeignKey
  20. from utilities.querysets import RestrictedQuerySet
  21. from utilities.serialization import deserialize_object, serialize_object
  22. from wireless.models import WirelessLink
  23. from .device_components import FrontPort, PathEndpoint, RearPort
  24. __all__ = (
  25. 'Cable',
  26. 'CablePath',
  27. 'CableTermination',
  28. )
  29. trace_paths = Signal()
  30. #
  31. # Cables
  32. #
  33. class Cable(PrimaryModel):
  34. """
  35. A physical connection between two endpoints.
  36. """
  37. type = models.CharField(
  38. verbose_name=_('type'),
  39. max_length=50,
  40. choices=CableTypeChoices,
  41. blank=True,
  42. null=True
  43. )
  44. status = models.CharField(
  45. verbose_name=_('status'),
  46. max_length=50,
  47. choices=LinkStatusChoices,
  48. default=LinkStatusChoices.STATUS_CONNECTED
  49. )
  50. profile = models.CharField(
  51. verbose_name=_('profile'),
  52. max_length=50,
  53. choices=CableProfileChoices,
  54. blank=True,
  55. )
  56. tenant = models.ForeignKey(
  57. to='tenancy.Tenant',
  58. on_delete=models.PROTECT,
  59. related_name='cables',
  60. blank=True,
  61. null=True
  62. )
  63. label = models.CharField(
  64. verbose_name=_('label'),
  65. max_length=100,
  66. blank=True
  67. )
  68. color = ColorField(
  69. verbose_name=_('color'),
  70. blank=True
  71. )
  72. length = models.DecimalField(
  73. verbose_name=_('length'),
  74. max_digits=8,
  75. decimal_places=2,
  76. blank=True,
  77. null=True
  78. )
  79. length_unit = models.CharField(
  80. verbose_name=_('length unit'),
  81. max_length=50,
  82. choices=CableLengthUnitChoices,
  83. blank=True,
  84. null=True
  85. )
  86. # Stores the normalized length (in meters) for database ordering
  87. _abs_length = models.DecimalField(
  88. max_digits=10,
  89. decimal_places=4,
  90. blank=True,
  91. null=True
  92. )
  93. clone_fields = ('tenant', 'type', 'profile')
  94. class Meta:
  95. ordering = ('pk',)
  96. verbose_name = _('cable')
  97. verbose_name_plural = _('cables')
  98. def __init__(self, *args, a_terminations=None, b_terminations=None, **kwargs):
  99. super().__init__(*args, **kwargs)
  100. # A copy of the PK to be used by __str__ in case the object is deleted
  101. self._pk = self.__dict__.get('id')
  102. # Cache the original status so we can check later if it's been changed
  103. self._orig_status = self.__dict__.get('status')
  104. self._terminations_modified = False
  105. # Assign or retrieve A/B terminations
  106. if a_terminations:
  107. self.a_terminations = a_terminations
  108. if b_terminations:
  109. self.b_terminations = b_terminations
  110. def __str__(self):
  111. pk = self.pk or self._pk
  112. return self.label or f'#{pk}'
  113. def get_status_color(self):
  114. return LinkStatusChoices.colors.get(self.status)
  115. @property
  116. def profile_class(self):
  117. from dcim import cable_profiles
  118. return {
  119. CableProfileChoices.STRAIGHT_SINGLE: cable_profiles.StraightSingleCableProfile,
  120. CableProfileChoices.STRAIGHT_MULTI: cable_profiles.StraightMultiCableProfile,
  121. CableProfileChoices.SHUFFLE_2X2_MPO8: cable_profiles.Shuffle2x2MPO8CableProfile,
  122. CableProfileChoices.SHUFFLE_4X4_MPO8: cable_profiles.Shuffle4x4MPO8CableProfile,
  123. }.get(self.profile)
  124. def _get_x_terminations(self, side):
  125. """
  126. Return the terminating objects for the given cable end (A or B).
  127. """
  128. if side not in (CableEndChoices.SIDE_A, CableEndChoices.SIDE_B):
  129. raise ValueError(f"Unknown cable side: {side}")
  130. attr = f'_{side.lower()}_terminations'
  131. if hasattr(self, attr):
  132. return getattr(self, attr)
  133. if not self.pk:
  134. return []
  135. return [
  136. # Query self.terminations.all() to leverage cached results
  137. ct.termination for ct in self.terminations.all() if ct.cable_end == side
  138. ]
  139. def _set_x_terminations(self, side, value):
  140. """
  141. Set the terminating objects for the given cable end (A or B).
  142. """
  143. if side not in (CableEndChoices.SIDE_A, CableEndChoices.SIDE_B):
  144. raise ValueError(f"Unknown cable side: {side}")
  145. _attr = f'_{side.lower()}_terminations'
  146. # If the provided value is a list of CableTermination IDs, resolve them
  147. # to their corresponding termination objects.
  148. if all(isinstance(item, int) for item in value):
  149. value = [
  150. ct.termination for ct in CableTermination.objects.filter(pk__in=value).prefetch_related('termination')
  151. ]
  152. if not self.pk or getattr(self, _attr, []) != list(value):
  153. self._terminations_modified = True
  154. setattr(self, _attr, value)
  155. @property
  156. def a_terminations(self):
  157. return self._get_x_terminations(CableEndChoices.SIDE_A)
  158. @a_terminations.setter
  159. def a_terminations(self, value):
  160. self._set_x_terminations(CableEndChoices.SIDE_A, value)
  161. @property
  162. def b_terminations(self):
  163. return self._get_x_terminations(CableEndChoices.SIDE_B)
  164. @b_terminations.setter
  165. def b_terminations(self, value):
  166. self._set_x_terminations(CableEndChoices.SIDE_B, value)
  167. @property
  168. def color_name(self):
  169. color_name = ""
  170. for hex_code, label in ColorChoices.CHOICES:
  171. if hex_code.lower() == self.color.lower():
  172. color_name = str(label)
  173. return color_name
  174. def clean(self):
  175. super().clean()
  176. # Validate length and length_unit
  177. if self.length is not None and not self.length_unit:
  178. raise ValidationError(_("Must specify a unit when setting a cable length"))
  179. if self._state.adding and self.pk is None and (not self.a_terminations or not self.b_terminations):
  180. raise ValidationError(_("Must define A and B terminations when creating a new cable."))
  181. # Validate terminations against the assigned cable profile (if any)
  182. if self.profile:
  183. self.profile_class().clean(self)
  184. if self._terminations_modified:
  185. # Check that all termination objects for either end are of the same type
  186. for terms in (self.a_terminations, self.b_terminations):
  187. if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]):
  188. raise ValidationError(_("Cannot connect different termination types to same end of cable."))
  189. # Check that termination types are compatible
  190. if self.a_terminations and self.b_terminations:
  191. a_type = self.a_terminations[0]._meta.model_name
  192. b_type = self.b_terminations[0]._meta.model_name
  193. if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
  194. raise ValidationError(
  195. _("Incompatible termination types: {type_a} and {type_b}").format(type_a=a_type, type_b=b_type)
  196. )
  197. if a_type == b_type:
  198. # can't directly use self.a_terminations here as possible they
  199. # don't have pk yet
  200. a_pks = set(obj.pk for obj in self.a_terminations if obj.pk)
  201. b_pks = set(obj.pk for obj in self.b_terminations if obj.pk)
  202. if (a_pks & b_pks):
  203. raise ValidationError(
  204. _("A and B terminations cannot connect to the same object.")
  205. )
  206. # Run clean() on any new CableTerminations
  207. for termination in self.a_terminations:
  208. CableTermination(cable=self, cable_end='A', termination=termination).clean()
  209. for termination in self.b_terminations:
  210. CableTermination(cable=self, cable_end='B', termination=termination).clean()
  211. def save(self, *args, force_insert=False, force_update=False, using=None, update_fields=None):
  212. _created = self.pk is None
  213. # Store the given length (if any) in meters for use in database ordering
  214. if self.length is not None and self.length_unit:
  215. self._abs_length = to_meters(self.length, self.length_unit)
  216. else:
  217. self._abs_length = None
  218. # Clear length_unit if no length is defined
  219. if self.length is None:
  220. self.length_unit = None
  221. # If this is a new Cable, save it before attempting to create its CableTerminations
  222. if self._state.adding:
  223. super().save(*args, force_insert=True, using=using, update_fields=update_fields)
  224. # Update the private PK used in __str__()
  225. self._pk = self.pk
  226. if self._terminations_modified:
  227. self.update_terminations()
  228. super().save(*args, force_update=True, using=using, update_fields=update_fields)
  229. try:
  230. trace_paths.send(Cable, instance=self, created=_created)
  231. except UnsupportedCablePath as e:
  232. raise AbortRequest(e)
  233. def serialize_object(self, exclude=None):
  234. data = serialize_object(self, exclude=exclude or [])
  235. # Add A & B terminations to the serialized data
  236. a_terminations, b_terminations = self.get_terminations()
  237. data['a_terminations'] = sorted([ct.pk for ct in a_terminations.values()])
  238. data['b_terminations'] = sorted([ct.pk for ct in b_terminations.values()])
  239. return data
  240. @classmethod
  241. def deserialize_object(cls, data, pk=None):
  242. a_terminations = data.pop('a_terminations', [])
  243. b_terminations = data.pop('b_terminations', [])
  244. instance = deserialize_object(cls, data, pk=pk)
  245. # Assign A & B termination objects to the Cable instance
  246. queryset = CableTermination.objects.prefetch_related('termination')
  247. instance.a_terminations = [
  248. ct.termination for ct in queryset.filter(pk__in=a_terminations)
  249. ]
  250. instance.b_terminations = [
  251. ct.termination for ct in queryset.filter(pk__in=b_terminations)
  252. ]
  253. return instance
  254. def get_terminations(self):
  255. """
  256. Return two dictionaries mapping A & B side terminating objects to their corresponding CableTerminations
  257. for this Cable.
  258. """
  259. a_terminations = {}
  260. b_terminations = {}
  261. for ct in CableTermination.objects.filter(cable=self).prefetch_related('termination'):
  262. if ct.cable_end == CableEndChoices.SIDE_A:
  263. a_terminations[ct.termination] = ct
  264. else:
  265. b_terminations[ct.termination] = ct
  266. return a_terminations, b_terminations
  267. def update_terminations(self):
  268. """
  269. Create/delete CableTerminations for this Cable to reflect its current state.
  270. """
  271. a_terminations, b_terminations = self.get_terminations()
  272. # Delete any stale CableTerminations
  273. for termination, ct in a_terminations.items():
  274. if termination.pk and termination not in self.a_terminations:
  275. ct.delete()
  276. for termination, ct in b_terminations.items():
  277. if termination.pk and termination not in self.b_terminations:
  278. ct.delete()
  279. # Save any new CableTerminations
  280. for i, termination in enumerate(self.a_terminations, start=1):
  281. if not termination.pk or termination not in a_terminations:
  282. position = i if self.profile and isinstance(termination, PathEndpoint) else None
  283. CableTermination(cable=self, cable_end='A', position=position, termination=termination).save()
  284. for i, termination in enumerate(self.b_terminations, start=1):
  285. if not termination.pk or termination not in b_terminations:
  286. position = i if self.profile and isinstance(termination, PathEndpoint) else None
  287. CableTermination(cable=self, cable_end='B', position=position, termination=termination).save()
  288. class CableTermination(ChangeLoggedModel):
  289. """
  290. A mapping between side A or B of a Cable and a terminating object (e.g. an Interface or CircuitTermination).
  291. """
  292. cable = models.ForeignKey(
  293. to='dcim.Cable',
  294. on_delete=models.CASCADE,
  295. related_name='terminations'
  296. )
  297. cable_end = models.CharField(
  298. max_length=1,
  299. choices=CableEndChoices,
  300. verbose_name=_('end')
  301. )
  302. termination_type = models.ForeignKey(
  303. to='contenttypes.ContentType',
  304. on_delete=models.PROTECT,
  305. related_name='+'
  306. )
  307. termination_id = models.PositiveBigIntegerField()
  308. termination = GenericForeignKey(
  309. ct_field='termination_type',
  310. fk_field='termination_id'
  311. )
  312. position = models.PositiveIntegerField(
  313. blank=True,
  314. null=True,
  315. validators=(
  316. MinValueValidator(CABLE_POSITION_MIN),
  317. MaxValueValidator(CABLE_POSITION_MAX)
  318. )
  319. )
  320. # Cached associations to enable efficient filtering
  321. _device = models.ForeignKey(
  322. to='dcim.Device',
  323. on_delete=models.CASCADE,
  324. blank=True,
  325. null=True
  326. )
  327. _rack = models.ForeignKey(
  328. to='dcim.Rack',
  329. on_delete=models.CASCADE,
  330. blank=True,
  331. null=True
  332. )
  333. _location = models.ForeignKey(
  334. to='dcim.Location',
  335. on_delete=models.CASCADE,
  336. blank=True,
  337. null=True
  338. )
  339. _site = models.ForeignKey(
  340. to='dcim.Site',
  341. on_delete=models.CASCADE,
  342. blank=True,
  343. null=True
  344. )
  345. objects = RestrictedQuerySet.as_manager()
  346. class Meta:
  347. ordering = ('cable', 'cable_end', 'position', 'pk')
  348. constraints = (
  349. models.UniqueConstraint(
  350. fields=('termination_type', 'termination_id'),
  351. name='%(app_label)s_%(class)s_unique_termination'
  352. ),
  353. models.UniqueConstraint(
  354. fields=('cable', 'cable_end', 'position'),
  355. name='%(app_label)s_%(class)s_unique_position'
  356. ),
  357. )
  358. verbose_name = _('cable termination')
  359. verbose_name_plural = _('cable terminations')
  360. def __str__(self):
  361. return f'Cable {self.cable} to {self.termination}'
  362. def clean(self):
  363. super().clean()
  364. # Disallow connecting a cable to any termination object that is
  365. # explicitly flagged as "mark connected".
  366. termination = getattr(self, 'termination', None)
  367. if termination is not None and getattr(termination, "mark_connected", False):
  368. raise ValidationError(
  369. _("Cannot connect a cable to {obj_parent} > {obj} because it is marked as connected.").format(
  370. obj_parent=termination.parent_object,
  371. obj=termination,
  372. )
  373. )
  374. # Check for existing termination
  375. qs = CableTermination.objects.filter(
  376. termination_type=self.termination_type,
  377. termination_id=self.termination_id
  378. )
  379. if self.cable.pk:
  380. qs = qs.exclude(cable=self.cable)
  381. existing_termination = qs.first()
  382. if existing_termination is not None:
  383. raise ValidationError(
  384. _("Duplicate termination found for {app_label}.{model} {termination_id}: cable {cable_pk}").format(
  385. app_label=self.termination_type.app_label,
  386. model=self.termination_type.model,
  387. termination_id=self.termination_id,
  388. cable_pk=existing_termination.cable.pk
  389. )
  390. )
  391. # Validate the interface type (if applicable)
  392. if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
  393. raise ValidationError(
  394. _("Cables cannot be terminated to {type_display} interfaces").format(
  395. type_display=self.termination.get_type_display()
  396. )
  397. )
  398. # A CircuitTermination attached to a ProviderNetwork cannot have a Cable
  399. if self.termination_type.model == 'circuittermination' and self.termination._provider_network is not None:
  400. raise ValidationError(_("Circuit terminations attached to a provider network may not be cabled."))
  401. def save(self, *args, **kwargs):
  402. # Cache objects associated with the terminating object (for filtering)
  403. self.cache_related_objects()
  404. super().save(*args, **kwargs)
  405. # Set the cable on the terminating object
  406. termination = self.termination._meta.model.objects.get(pk=self.termination_id)
  407. termination.snapshot()
  408. termination.cable = self.cable
  409. termination.cable_end = self.cable_end
  410. termination.cable_position = self.position
  411. termination.save()
  412. def delete(self, *args, **kwargs):
  413. # Delete the cable association on the terminating object
  414. termination = self.termination._meta.model.objects.get(pk=self.termination_id)
  415. termination.snapshot()
  416. termination.cable = None
  417. termination.cable_end = None
  418. termination.cable_position = None
  419. termination.save()
  420. super().delete(*args, **kwargs)
  421. def cache_related_objects(self):
  422. """
  423. Cache objects related to the termination (e.g. device, rack, site) directly on the object to
  424. enable efficient filtering.
  425. """
  426. assert self.termination is not None
  427. # Device components
  428. if getattr(self.termination, 'device', None):
  429. self._device = self.termination.device
  430. self._rack = self.termination.device.rack
  431. self._location = self.termination.device.location
  432. self._site = self.termination.device.site
  433. # Power feeds
  434. elif getattr(self.termination, 'rack', None):
  435. self._rack = self.termination.rack
  436. self._location = self.termination.rack.location
  437. self._site = self.termination.rack.site
  438. # Circuit terminations
  439. elif getattr(self.termination, 'site', None):
  440. self._site = self.termination.site
  441. cache_related_objects.alters_data = True
  442. def to_objectchange(self, action):
  443. objectchange = super().to_objectchange(action)
  444. objectchange.related_object = self.termination
  445. return objectchange
  446. class CablePath(models.Model):
  447. """
  448. A CablePath instance represents the physical path from a set of origin nodes to a set of destination nodes,
  449. including all intermediate elements.
  450. `path` contains the ordered set of nodes, arranged in lists of (type, ID) tuples. (Each cable in the path can
  451. terminate to one or more objects.) For example, consider the following
  452. topology:
  453. A B C
  454. Interface 1 --- Front Port 1 | Rear Port 1 --- Rear Port 2 | Front Port 3 --- Interface 2
  455. Front Port 2 Front Port 4
  456. This path would be expressed as:
  457. CablePath(
  458. path = [
  459. [Interface 1],
  460. [Cable A],
  461. [Front Port 1, Front Port 2],
  462. [Rear Port 1],
  463. [Cable B],
  464. [Rear Port 2],
  465. [Front Port 3, Front Port 4],
  466. [Cable C],
  467. [Interface 2],
  468. ]
  469. )
  470. `is_active` is set to True only if every Cable within the path has a status of "connected". `is_complete` is True
  471. if the instance represents a complete end-to-end path from origin(s) to destination(s). `is_split` is True if the
  472. path diverges across multiple cables.
  473. `_nodes` retains a flattened list of all nodes within the path to enable simple filtering.
  474. """
  475. path = models.JSONField(
  476. verbose_name=_('path'),
  477. default=list
  478. )
  479. is_active = models.BooleanField(
  480. verbose_name=_('is active'),
  481. default=False
  482. )
  483. is_complete = models.BooleanField(
  484. verbose_name=_('is complete'),
  485. default=False
  486. )
  487. is_split = models.BooleanField(
  488. verbose_name=_('is split'),
  489. default=False
  490. )
  491. _nodes = PathField()
  492. _netbox_private = True
  493. class Meta:
  494. verbose_name = _('cable path')
  495. verbose_name_plural = _('cable paths')
  496. def __str__(self):
  497. return f"Path #{self.pk}: {len(self.path)} hops"
  498. def save(self, *args, **kwargs):
  499. # Save the flattened nodes list
  500. self._nodes = list(itertools.chain(*self.path))
  501. super().save(*args, **kwargs)
  502. # Record a direct reference to this CablePath on its originating object(s)
  503. origin_model = self.origin_type.model_class()
  504. origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
  505. origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk)
  506. @property
  507. def origin_type(self):
  508. if self.path:
  509. ct_id, _ = decompile_path_node(self.path[0][0])
  510. return ContentType.objects.get_for_id(ct_id)
  511. @property
  512. def destination_type(self):
  513. if self.is_complete:
  514. ct_id, _ = decompile_path_node(self.path[-1][0])
  515. return ContentType.objects.get_for_id(ct_id)
  516. @property
  517. def _path_decompiled(self):
  518. res = []
  519. for step in self.path:
  520. nodes = []
  521. for node in step:
  522. nodes.append(decompile_path_node(node))
  523. res.append(nodes)
  524. return res
  525. path_objects = GenericArrayForeignKey("_path_decompiled")
  526. @property
  527. def origins(self):
  528. """
  529. Return the list of originating objects.
  530. """
  531. return self.path_objects[0]
  532. @property
  533. def destinations(self):
  534. """
  535. Return the list of destination objects, if the path is complete.
  536. """
  537. if not self.is_complete:
  538. return []
  539. return self.path_objects[-1]
  540. @property
  541. def segment_count(self):
  542. return int(len(self.path) / 3)
  543. @classmethod
  544. def from_origin(cls, terminations):
  545. """
  546. Create a new CablePath instance as traced from the given termination objects. These can be any object to which a
  547. Cable or WirelessLink connects (interfaces, console ports, circuit termination, etc.). All terminations must be
  548. of the same type and must belong to the same parent object.
  549. """
  550. from circuits.models import CircuitTermination, Circuit
  551. if not terminations:
  552. return None
  553. # Ensure all originating terminations are attached to the same link
  554. if len(terminations) > 1 and not all(t.link == terminations[0].link for t in terminations[1:]):
  555. raise UnsupportedCablePath(_("All originating terminations must be attached to the same link"))
  556. path = []
  557. position_stack = []
  558. is_complete = False
  559. is_active = True
  560. is_split = False
  561. while terminations:
  562. # Terminations must all be of the same type
  563. if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]):
  564. raise UnsupportedCablePath(_("All mid-span terminations must have the same termination type"))
  565. # All mid-span terminations must all be attached to the same device
  566. if (
  567. not isinstance(terminations[0], PathEndpoint) and
  568. not isinstance(terminations[0].parent_object, Circuit) and
  569. not all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
  570. ):
  571. raise UnsupportedCablePath(_("All mid-span terminations must have the same parent object"))
  572. # Check for a split path (e.g. rear port fanning out to multiple front ports with
  573. # different cables attached)
  574. if len(set(t.link for t in terminations)) > 1 and (
  575. position_stack and len(terminations) != len(position_stack[-1])
  576. ):
  577. is_split = True
  578. break
  579. # Step 1: Record the near-end termination object(s)
  580. path.append([
  581. object_to_path_node(t) for t in terminations
  582. ])
  583. # If not null, push cable_position onto the stack
  584. if terminations[0].cable_position is not None:
  585. position_stack.append([terminations[0].cable_position])
  586. # Step 2: Determine the attached links (Cable or WirelessLink), if any
  587. links = [termination.link for termination in terminations if termination.link is not None]
  588. if len(links) == 0:
  589. if len(path) == 1:
  590. # If this is the start of the path and no link exists, return None
  591. return None
  592. # Otherwise, halt the trace if no link exists
  593. break
  594. if not all(type(link) in (Cable, WirelessLink) for link in links):
  595. raise UnsupportedCablePath(_("All links must be cable or wireless"))
  596. if not all(isinstance(link, type(links[0])) for link in links):
  597. raise UnsupportedCablePath(_("All links must match first link type"))
  598. # Step 3: Record asymmetric paths as split
  599. not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
  600. if len(not_connected_terminations) > 0:
  601. is_complete = False
  602. is_split = True
  603. # Step 4: Record the links, keeping cables in order to allow for SVG rendering
  604. cables = []
  605. for link in links:
  606. if object_to_path_node(link) not in cables:
  607. cables.append(object_to_path_node(link))
  608. path.append(cables)
  609. # Step 5: Update the path status if a link is not connected
  610. links_status = [link.status for link in links if link.status != LinkStatusChoices.STATUS_CONNECTED]
  611. if any([status != LinkStatusChoices.STATUS_CONNECTED for status in links_status]):
  612. is_active = False
  613. # Step 6: Determine the far-end terminations
  614. if isinstance(links[0], Cable):
  615. # Profile-based tracing
  616. if links[0].profile:
  617. cable_profile = links[0].profile_class()
  618. peer_cable_terminations = cable_profile.get_peer_terminations(terminations, position_stack)
  619. remote_terminations = [ct.termination for ct in peer_cable_terminations]
  620. # Legacy (positionless) behavior
  621. else:
  622. termination_type = ObjectType.objects.get_for_model(terminations[0])
  623. local_cable_terminations = CableTermination.objects.filter(
  624. termination_type=termination_type,
  625. termination_id__in=[t.pk for t in terminations]
  626. )
  627. q_filter = Q()
  628. for lct in local_cable_terminations:
  629. cable_end = 'A' if lct.cable_end == 'B' else 'B'
  630. q_filter |= Q(cable=lct.cable, cable_end=cable_end)
  631. # Make sure this filter has been populated; if not, we have probably been given invalid data
  632. if not q_filter:
  633. break
  634. remote_cable_terminations = CableTermination.objects.filter(q_filter)
  635. remote_terminations = [ct.termination for ct in remote_cable_terminations]
  636. else:
  637. # WirelessLink
  638. remote_terminations = [
  639. link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links
  640. ]
  641. # Remote Terminations must all be of the same type, otherwise return a split path
  642. if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
  643. is_complete = False
  644. is_split = True
  645. break
  646. # Step 7: Record the far-end termination object(s)
  647. path.append([
  648. object_to_path_node(t) for t in remote_terminations if t is not None
  649. ])
  650. # Step 8: Determine the "next hop" terminations, if applicable
  651. if not remote_terminations:
  652. break
  653. if isinstance(remote_terminations[0], FrontPort):
  654. # Follow FrontPorts to their corresponding RearPorts
  655. rear_ports = RearPort.objects.filter(
  656. pk__in=[t.rear_port_id for t in remote_terminations]
  657. )
  658. if len(rear_ports) > 1 or rear_ports[0].positions > 1:
  659. position_stack.append([fp.rear_port_position for fp in remote_terminations])
  660. terminations = rear_ports
  661. elif isinstance(remote_terminations[0], RearPort):
  662. if len(remote_terminations) == 1 and remote_terminations[0].positions == 1:
  663. front_ports = FrontPort.objects.filter(
  664. rear_port_id__in=[rp.pk for rp in remote_terminations],
  665. rear_port_position=1
  666. )
  667. # Obtain the individual front ports based on the termination and all positions
  668. elif len(remote_terminations) > 1 and position_stack:
  669. positions = position_stack.pop()
  670. # Ensure we have a number of positions equal to the amount of remote terminations
  671. if len(remote_terminations) != len(positions):
  672. raise UnsupportedCablePath(
  673. _("All positions counts within the path on opposite ends of links must match")
  674. )
  675. # Get our front ports
  676. q_filter = Q()
  677. for rt in remote_terminations:
  678. position = positions.pop()
  679. q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
  680. if q_filter is Q():
  681. raise UnsupportedCablePath(_("Remote termination position filter is missing"))
  682. front_ports = FrontPort.objects.filter(q_filter)
  683. # Obtain the individual front ports based on the termination and position
  684. elif position_stack:
  685. front_ports = FrontPort.objects.filter(
  686. rear_port_id=remote_terminations[0].pk,
  687. rear_port_position__in=position_stack.pop()
  688. )
  689. # If all rear ports have a single position, we can just get the front ports
  690. elif all([rp.positions == 1 for rp in remote_terminations]):
  691. front_ports = FrontPort.objects.filter(rear_port_id__in=[rp.pk for rp in remote_terminations])
  692. if len(front_ports) != len(remote_terminations):
  693. # Some rear ports does not have a front port
  694. is_split = True
  695. break
  696. else:
  697. # No position indicated: path has split, so we stop at the RearPorts
  698. is_split = True
  699. break
  700. terminations = front_ports
  701. elif isinstance(remote_terminations[0], CircuitTermination):
  702. # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
  703. qs = Q()
  704. for remote_termination in remote_terminations:
  705. qs |= Q(
  706. circuit=remote_termination.circuit,
  707. term_side='Z' if remote_termination.term_side == 'A' else 'A'
  708. )
  709. # Get all circuit terminations
  710. circuit_terminations = CircuitTermination.objects.filter(qs)
  711. if not circuit_terminations.exists():
  712. break
  713. elif all([ct._provider_network for ct in circuit_terminations]):
  714. # Circuit terminates to a ProviderNetwork
  715. path.extend([
  716. [object_to_path_node(ct) for ct in circuit_terminations],
  717. [object_to_path_node(ct._provider_network) for ct in circuit_terminations],
  718. ])
  719. is_complete = True
  720. break
  721. elif all([ct.termination and not ct.cable for ct in circuit_terminations]):
  722. # Circuit terminates to a Region/Site/etc.
  723. path.extend([
  724. [object_to_path_node(ct) for ct in circuit_terminations],
  725. [object_to_path_node(ct.termination) for ct in circuit_terminations],
  726. ])
  727. break
  728. elif any([ct.cable in links for ct in circuit_terminations]):
  729. # No valid path
  730. is_split = True
  731. break
  732. terminations = circuit_terminations
  733. else:
  734. # Check for non-symmetric path
  735. if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
  736. is_complete = True
  737. elif len(remote_terminations) == 0:
  738. is_complete = False
  739. else:
  740. # Unsupported topology, mark as split and exit
  741. is_complete = False
  742. is_split = True
  743. break
  744. return cls(
  745. path=path,
  746. is_complete=is_complete,
  747. is_active=is_active,
  748. is_split=is_split
  749. )
  750. def retrace(self):
  751. """
  752. Retrace the path from the currently-defined originating termination(s)
  753. """
  754. _new = self.from_origin(self.origins)
  755. if _new:
  756. self.path = _new.path
  757. self.is_complete = _new.is_complete
  758. self.is_active = _new.is_active
  759. self.is_split = _new.is_split
  760. self.save()
  761. else:
  762. self.delete()
  763. retrace.alters_data = True
  764. def get_cable_ids(self):
  765. """
  766. Return all Cable IDs within the path.
  767. """
  768. cable_ct = ObjectType.objects.get_for_model(Cable).pk
  769. cable_ids = []
  770. for node in self._nodes:
  771. ct, id = decompile_path_node(node)
  772. if ct == cable_ct:
  773. cable_ids.append(id)
  774. return cable_ids
  775. def get_total_length(self):
  776. """
  777. Return a tuple containing the sum of the length of each cable in the path
  778. and a flag indicating whether the length is definitive.
  779. """
  780. cable_ct = ObjectType.objects.get_for_model(Cable).pk
  781. # Pre-cache cable lengths by ID
  782. cable_ids = self.get_cable_ids()
  783. cables = {
  784. cable['pk']: cable['_abs_length']
  785. for cable in Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False).values('pk', '_abs_length')
  786. }
  787. # Iterate through each set of nodes in the path. For cables, add the length of the longest cable to the total
  788. # length of the path.
  789. total_length = 0
  790. for node_set in self.path:
  791. hop_length = 0
  792. for node in node_set:
  793. ct, pk = decompile_path_node(node)
  794. if ct != cable_ct:
  795. break # Not a cable
  796. if pk in cables and cables[pk] > hop_length:
  797. hop_length = cables[pk]
  798. total_length += hop_length
  799. is_definitive = len(cables) == len(cable_ids)
  800. return total_length, is_definitive
  801. def get_split_nodes(self):
  802. """
  803. Return all available next segments in a split cable path.
  804. """
  805. from circuits.models import CircuitTermination
  806. nodes = self.path_objects[-1]
  807. # RearPort splitting to multiple FrontPorts with no stack position
  808. if type(nodes[0]) is RearPort:
  809. return FrontPort.objects.filter(rear_port__in=nodes)
  810. # Cable terminating to multiple FrontPorts mapped to different
  811. # RearPorts connected to different cables
  812. elif type(nodes[0]) is FrontPort:
  813. return RearPort.objects.filter(pk__in=[fp.rear_port_id for fp in nodes])
  814. # Cable terminating to multiple CircuitTerminations
  815. elif type(nodes[0]) is CircuitTermination:
  816. return [
  817. ct.get_peer_termination() for ct in nodes
  818. ]
  819. def get_asymmetric_nodes(self):
  820. """
  821. Return all available next segments in a split cable path.
  822. """
  823. from circuits.models import CircuitTermination
  824. asymmetric_nodes = []
  825. for nodes in self.path_objects:
  826. if type(nodes[0]) in [RearPort, FrontPort, CircuitTermination]:
  827. asymmetric_nodes.extend([node for node in nodes if node.link is None])
  828. return asymmetric_nodes