cables.py 45 KB

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