cables.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  1. import itertools
  2. from collections import defaultdict
  3. from django.contrib.contenttypes.fields import GenericForeignKey
  4. from django.contrib.contenttypes.models import ContentType
  5. from django.core.exceptions import ValidationError
  6. from django.db import models
  7. from django.db.models import Sum
  8. from django.dispatch import Signal
  9. from django.urls import reverse
  10. from dcim.choices import *
  11. from dcim.constants import *
  12. from dcim.fields import PathField
  13. from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
  14. from netbox.models import NetBoxModel
  15. from utilities.fields import ColorField
  16. from utilities.querysets import RestrictedQuerySet
  17. from utilities.utils import to_meters
  18. from wireless.models import WirelessLink
  19. from .device_components import FrontPort, RearPort
  20. __all__ = (
  21. 'Cable',
  22. 'CablePath',
  23. 'CableTermination',
  24. )
  25. trace_paths = Signal()
  26. #
  27. # Cables
  28. #
  29. class Cable(NetBoxModel):
  30. """
  31. A physical connection between two endpoints.
  32. """
  33. type = models.CharField(
  34. max_length=50,
  35. choices=CableTypeChoices,
  36. blank=True
  37. )
  38. status = models.CharField(
  39. max_length=50,
  40. choices=LinkStatusChoices,
  41. default=LinkStatusChoices.STATUS_CONNECTED
  42. )
  43. tenant = models.ForeignKey(
  44. to='tenancy.Tenant',
  45. on_delete=models.PROTECT,
  46. related_name='cables',
  47. blank=True,
  48. null=True
  49. )
  50. label = models.CharField(
  51. max_length=100,
  52. blank=True
  53. )
  54. color = ColorField(
  55. blank=True
  56. )
  57. length = models.DecimalField(
  58. max_digits=8,
  59. decimal_places=2,
  60. blank=True,
  61. null=True
  62. )
  63. length_unit = models.CharField(
  64. max_length=50,
  65. choices=CableLengthUnitChoices,
  66. blank=True,
  67. )
  68. # Stores the normalized length (in meters) for database ordering
  69. _abs_length = models.DecimalField(
  70. max_digits=10,
  71. decimal_places=4,
  72. blank=True,
  73. null=True
  74. )
  75. class Meta:
  76. ordering = ('pk',)
  77. def __init__(self, *args, a_terminations=None, b_terminations=None, **kwargs):
  78. super().__init__(*args, **kwargs)
  79. # A copy of the PK to be used by __str__ in case the object is deleted
  80. self._pk = self.pk
  81. # Cache the original status so we can check later if it's been changed
  82. self._orig_status = self.status
  83. self._terminations_modified = False
  84. # Assign or retrieve A/B terminations
  85. if a_terminations:
  86. self.a_terminations = a_terminations
  87. if b_terminations:
  88. self.b_terminations = b_terminations
  89. def __str__(self):
  90. pk = self.pk or self._pk
  91. return self.label or f'#{pk}'
  92. def get_absolute_url(self):
  93. return reverse('dcim:cable', args=[self.pk])
  94. @property
  95. def a_terminations(self):
  96. if hasattr(self, '_a_terminations'):
  97. return self._a_terminations
  98. # Query self.terminations.all() to leverage cached results
  99. return [
  100. ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_A
  101. ]
  102. @a_terminations.setter
  103. def a_terminations(self, value):
  104. self._terminations_modified = True
  105. self._a_terminations = value
  106. @property
  107. def b_terminations(self):
  108. if hasattr(self, '_b_terminations'):
  109. return self._b_terminations
  110. # Query self.terminations.all() to leverage cached results
  111. return [
  112. ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_B
  113. ]
  114. @b_terminations.setter
  115. def b_terminations(self, value):
  116. self._terminations_modified = True
  117. self._b_terminations = value
  118. def clean(self):
  119. super().clean()
  120. # Validate length and length_unit
  121. if self.length is not None and not self.length_unit:
  122. raise ValidationError("Must specify a unit when setting a cable length")
  123. elif self.length is None:
  124. self.length_unit = ''
  125. if self.pk is None and (not self.a_terminations or not self.b_terminations):
  126. raise ValidationError("Must define A and B terminations when creating a new cable.")
  127. if self._terminations_modified:
  128. # Check that all termination objects for either end are of the same type
  129. for terms in (self.a_terminations, self.b_terminations):
  130. if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]):
  131. raise ValidationError("Cannot connect different termination types to same end of cable.")
  132. # Check that termination types are compatible
  133. if self.a_terminations and self.b_terminations:
  134. a_type = self.a_terminations[0]._meta.model_name
  135. b_type = self.b_terminations[0]._meta.model_name
  136. if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
  137. raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
  138. # Run clean() on any new CableTerminations
  139. for termination in self.a_terminations:
  140. CableTermination(cable=self, cable_end='A', termination=termination).clean()
  141. for termination in self.b_terminations:
  142. CableTermination(cable=self, cable_end='B', termination=termination).clean()
  143. def save(self, *args, **kwargs):
  144. _created = self.pk is None
  145. # Store the given length (if any) in meters for use in database ordering
  146. if self.length and self.length_unit:
  147. self._abs_length = to_meters(self.length, self.length_unit)
  148. else:
  149. self._abs_length = None
  150. super().save(*args, **kwargs)
  151. # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
  152. self._pk = self.pk
  153. # Retrieve existing A/B terminations for the Cable
  154. a_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='A')}
  155. b_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='B')}
  156. # Delete stale CableTerminations
  157. if self._terminations_modified:
  158. for termination, ct in a_terminations.items():
  159. if termination.pk and termination not in self.a_terminations:
  160. ct.delete()
  161. for termination, ct in b_terminations.items():
  162. if termination.pk and termination not in self.b_terminations:
  163. ct.delete()
  164. # Save new CableTerminations (if any)
  165. if self._terminations_modified:
  166. for termination in self.a_terminations:
  167. if not termination.pk or termination not in a_terminations:
  168. CableTermination(cable=self, cable_end='A', termination=termination).save()
  169. for termination in self.b_terminations:
  170. if not termination.pk or termination not in b_terminations:
  171. CableTermination(cable=self, cable_end='B', termination=termination).save()
  172. trace_paths.send(Cable, instance=self, created=_created)
  173. def get_status_color(self):
  174. return LinkStatusChoices.colors.get(self.status)
  175. class CableTermination(models.Model):
  176. """
  177. A mapping between side A or B of a Cable and a terminating object (e.g. an Interface or CircuitTermination).
  178. """
  179. cable = models.ForeignKey(
  180. to='dcim.Cable',
  181. on_delete=models.CASCADE,
  182. related_name='terminations'
  183. )
  184. cable_end = models.CharField(
  185. max_length=1,
  186. choices=CableEndChoices,
  187. verbose_name='End'
  188. )
  189. termination_type = models.ForeignKey(
  190. to=ContentType,
  191. limit_choices_to=CABLE_TERMINATION_MODELS,
  192. on_delete=models.PROTECT,
  193. related_name='+'
  194. )
  195. termination_id = models.PositiveBigIntegerField()
  196. termination = GenericForeignKey(
  197. ct_field='termination_type',
  198. fk_field='termination_id'
  199. )
  200. # Cached associations to enable efficient filtering
  201. _device = models.ForeignKey(
  202. to='dcim.Device',
  203. on_delete=models.CASCADE,
  204. blank=True,
  205. null=True
  206. )
  207. _rack = models.ForeignKey(
  208. to='dcim.Rack',
  209. on_delete=models.CASCADE,
  210. blank=True,
  211. null=True
  212. )
  213. _location = models.ForeignKey(
  214. to='dcim.Location',
  215. on_delete=models.CASCADE,
  216. blank=True,
  217. null=True
  218. )
  219. _site = models.ForeignKey(
  220. to='dcim.Site',
  221. on_delete=models.CASCADE,
  222. blank=True,
  223. null=True
  224. )
  225. objects = RestrictedQuerySet.as_manager()
  226. class Meta:
  227. ordering = ('cable', 'cable_end', 'pk')
  228. constraints = (
  229. models.UniqueConstraint(
  230. fields=('termination_type', 'termination_id'),
  231. name='dcim_cable_termination_unique_termination'
  232. ),
  233. )
  234. def __str__(self):
  235. return f'Cable {self.cable} to {self.termination}'
  236. def clean(self):
  237. super().clean()
  238. # Validate interface type (if applicable)
  239. if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
  240. raise ValidationError({
  241. 'termination': f'Cables cannot be terminated to {self.termination.get_type_display()} interfaces'
  242. })
  243. # A CircuitTermination attached to a ProviderNetwork cannot have a Cable
  244. if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
  245. raise ValidationError({
  246. 'termination': "Circuit terminations attached to a provider network may not be cabled."
  247. })
  248. def save(self, *args, **kwargs):
  249. # Cache objects associated with the terminating object (for filtering)
  250. self.cache_related_objects()
  251. super().save(*args, **kwargs)
  252. # Set the cable on the terminating object
  253. termination_model = self.termination._meta.model
  254. termination_model.objects.filter(pk=self.termination_id).update(
  255. cable=self.cable,
  256. cable_end=self.cable_end
  257. )
  258. def delete(self, *args, **kwargs):
  259. # Delete the cable association on the terminating object
  260. termination_model = self.termination._meta.model
  261. termination_model.objects.filter(pk=self.termination_id).update(
  262. cable=None,
  263. cable_end=''
  264. )
  265. super().delete(*args, **kwargs)
  266. def cache_related_objects(self):
  267. """
  268. Cache objects related to the termination (e.g. device, rack, site) directly on the object to
  269. enable efficient filtering.
  270. """
  271. assert self.termination is not None
  272. # Device components
  273. if getattr(self.termination, 'device', None):
  274. self._device = self.termination.device
  275. self._rack = self.termination.device.rack
  276. self._location = self.termination.device.location
  277. self._site = self.termination.device.site
  278. # Power feeds
  279. elif getattr(self.termination, 'rack', None):
  280. self._rack = self.termination.rack
  281. self._location = self.termination.rack.location
  282. self._site = self.termination.rack.site
  283. # Circuit terminations
  284. elif getattr(self.termination, 'site', None):
  285. self._site = self.termination.site
  286. class CablePath(models.Model):
  287. """
  288. A CablePath instance represents the physical path from a set of origin nodes to a set of destination nodes,
  289. including all intermediate elements.
  290. `path` contains the ordered set of nodes, arranged in lists of (type, ID) tuples. (Each cable in the path can
  291. terminate to one or more objects.) For example, consider the following
  292. topology:
  293. A B C
  294. Interface 1 --- Front Port 1 | Rear Port 1 --- Rear Port 2 | Front Port 3 --- Interface 2
  295. Front Port 2 Front Port 4
  296. This path would be expressed as:
  297. CablePath(
  298. path = [
  299. [Interface 1],
  300. [Cable A],
  301. [Front Port 1, Front Port 2],
  302. [Rear Port 1],
  303. [Cable B],
  304. [Rear Port 2],
  305. [Front Port 3, Front Port 4],
  306. [Cable C],
  307. [Interface 2],
  308. ]
  309. )
  310. `is_active` is set to True only if every Cable within the path has a status of "connected". `is_complete` is True
  311. if the instance represents a complete end-to-end path from origin(s) to destination(s). `is_split` is True if the
  312. path diverges across multiple cables.
  313. `_nodes` retains a flattened list of all nodes within the path to enable simple filtering.
  314. """
  315. path = models.JSONField(
  316. default=list
  317. )
  318. is_active = models.BooleanField(
  319. default=False
  320. )
  321. is_complete = models.BooleanField(
  322. default=False
  323. )
  324. is_split = models.BooleanField(
  325. default=False
  326. )
  327. _nodes = PathField()
  328. def __str__(self):
  329. return f"Path #{self.pk}: {len(self.path)} hops"
  330. def save(self, *args, **kwargs):
  331. # Save the flattened nodes list
  332. self._nodes = list(itertools.chain(*self.path))
  333. super().save(*args, **kwargs)
  334. # Record a direct reference to this CablePath on its originating object(s)
  335. origin_model = self.origin_type.model_class()
  336. origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
  337. origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk)
  338. @property
  339. def origin_type(self):
  340. if self.path:
  341. ct_id, _ = decompile_path_node(self.path[0][0])
  342. return ContentType.objects.get_for_id(ct_id)
  343. @property
  344. def destination_type(self):
  345. if self.is_complete:
  346. ct_id, _ = decompile_path_node(self.path[-1][0])
  347. return ContentType.objects.get_for_id(ct_id)
  348. @property
  349. def path_objects(self):
  350. """
  351. Cache and return the complete path as lists of objects, derived from their annotation within the path.
  352. """
  353. if not hasattr(self, '_path_objects'):
  354. self._path_objects = self._get_path()
  355. return self._path_objects
  356. @property
  357. def origins(self):
  358. """
  359. Return the list of originating objects.
  360. """
  361. if hasattr(self, '_path_objects'):
  362. return self.path_objects[0]
  363. return [
  364. path_node_to_object(node) for node in self.path[0]
  365. ]
  366. @property
  367. def destinations(self):
  368. """
  369. Return the list of destination objects, if the path is complete.
  370. """
  371. if not self.is_complete:
  372. return []
  373. if hasattr(self, '_path_objects'):
  374. return self.path_objects[-1]
  375. return [
  376. path_node_to_object(node) for node in self.path[-1]
  377. ]
  378. @property
  379. def segment_count(self):
  380. return int(len(self.path) / 3)
  381. @classmethod
  382. def from_origin(cls, terminations):
  383. """
  384. Create a new CablePath instance as traced from the given termination objects. These can be any object to which a
  385. Cable or WirelessLink connects (interfaces, console ports, circuit termination, etc.). All terminations must be
  386. of the same type and must belong to the same parent object.
  387. """
  388. from circuits.models import CircuitTermination
  389. path = []
  390. position_stack = []
  391. is_complete = False
  392. is_active = True
  393. is_split = False
  394. while terminations:
  395. # Terminations must all be of the same type
  396. assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
  397. # Step 1: Record the near-end termination object(s)
  398. path.append([
  399. object_to_path_node(t) for t in terminations
  400. ])
  401. # Step 2: Determine the attached link (Cable or WirelessLink), if any
  402. link = terminations[0].link
  403. assert all(t.link == link for t in terminations[1:])
  404. if link is None and len(path) == 1:
  405. # If this is the start of the path and no link exists, return None
  406. return None
  407. elif link is None:
  408. # Otherwise, halt the trace if no link exists
  409. break
  410. assert type(link) in (Cable, WirelessLink)
  411. # Step 3: Record the link and update path status if not "connected"
  412. path.append([object_to_path_node(link)])
  413. if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED:
  414. is_active = False
  415. # Step 4: Determine the far-end terminations
  416. if isinstance(link, Cable):
  417. termination_type = ContentType.objects.get_for_model(terminations[0])
  418. local_cable_terminations = CableTermination.objects.filter(
  419. termination_type=termination_type,
  420. termination_id__in=[t.pk for t in terminations]
  421. )
  422. # Terminations must all belong to same end of Cable
  423. local_cable_end = local_cable_terminations[0].cable_end
  424. assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:])
  425. remote_cable_terminations = CableTermination.objects.filter(
  426. cable=link,
  427. cable_end='A' if local_cable_end == 'B' else 'B'
  428. )
  429. remote_terminations = [ct.termination for ct in remote_cable_terminations]
  430. else:
  431. # WirelessLink
  432. remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a]
  433. # Step 5: Record the far-end termination object(s)
  434. path.append([
  435. object_to_path_node(t) for t in remote_terminations
  436. ])
  437. # Step 6: Determine the "next hop" terminations, if applicable
  438. if isinstance(remote_terminations[0], FrontPort):
  439. # Follow FrontPorts to their corresponding RearPorts
  440. rear_ports = RearPort.objects.filter(
  441. pk__in=[t.rear_port_id for t in remote_terminations]
  442. )
  443. if len(rear_ports) > 1:
  444. assert all(rp.positions == 1 for rp in rear_ports)
  445. elif rear_ports[0].positions > 1:
  446. position_stack.append([fp.rear_port_position for fp in remote_terminations])
  447. terminations = rear_ports
  448. elif isinstance(remote_terminations[0], RearPort):
  449. if len(remote_terminations) > 1 or remote_terminations[0].positions == 1:
  450. front_ports = FrontPort.objects.filter(
  451. rear_port_id__in=[rp.pk for rp in remote_terminations],
  452. rear_port_position=1
  453. )
  454. elif position_stack:
  455. front_ports = FrontPort.objects.filter(
  456. rear_port_id=remote_terminations[0].pk,
  457. rear_port_position__in=position_stack.pop()
  458. )
  459. else:
  460. # No position indicated: path has split, so we stop at the RearPorts
  461. is_split = True
  462. break
  463. terminations = front_ports
  464. elif isinstance(remote_terminations[0], CircuitTermination):
  465. # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
  466. term_side = remote_terminations[0].term_side
  467. assert all(ct.term_side == term_side for ct in remote_terminations[1:])
  468. circuit_termination = CircuitTermination.objects.filter(
  469. circuit=remote_terminations[0].circuit,
  470. term_side='Z' if term_side == 'A' else 'A'
  471. ).first()
  472. if circuit_termination is None:
  473. break
  474. elif circuit_termination.provider_network:
  475. # Circuit terminates to a ProviderNetwork
  476. path.extend([
  477. [object_to_path_node(circuit_termination)],
  478. [object_to_path_node(circuit_termination.provider_network)],
  479. ])
  480. break
  481. elif circuit_termination.site and not circuit_termination.cable:
  482. # Circuit terminates to a Site
  483. path.extend([
  484. [object_to_path_node(circuit_termination)],
  485. [object_to_path_node(circuit_termination.site)],
  486. ])
  487. break
  488. terminations = [circuit_termination]
  489. # Anything else marks the end of the path
  490. else:
  491. is_complete = True
  492. break
  493. return cls(
  494. path=path,
  495. is_complete=is_complete,
  496. is_active=is_active,
  497. is_split=is_split
  498. )
  499. def retrace(self):
  500. """
  501. Retrace the path from the currently-defined originating termination(s)
  502. """
  503. _new = self.from_origin(self.origins)
  504. if _new:
  505. self.path = _new.path
  506. self.is_complete = _new.is_complete
  507. self.is_active = _new.is_active
  508. self.is_split = _new.is_split
  509. self.save()
  510. else:
  511. self.delete()
  512. def _get_path(self):
  513. """
  514. Return the path as a list of prefetched objects.
  515. """
  516. # Compile a list of IDs to prefetch for each type of model in the path
  517. to_prefetch = defaultdict(list)
  518. for node in self._nodes:
  519. ct_id, object_id = decompile_path_node(node)
  520. to_prefetch[ct_id].append(object_id)
  521. # Prefetch path objects using one query per model type. Prefetch related devices where appropriate.
  522. prefetched = {}
  523. for ct_id, object_ids in to_prefetch.items():
  524. model_class = ContentType.objects.get_for_id(ct_id).model_class()
  525. queryset = model_class.objects.filter(pk__in=object_ids)
  526. if hasattr(model_class, 'device'):
  527. queryset = queryset.prefetch_related('device')
  528. prefetched[ct_id] = {
  529. obj.id: obj for obj in queryset
  530. }
  531. # Replicate the path using the prefetched objects.
  532. path = []
  533. for step in self.path:
  534. nodes = []
  535. for node in step:
  536. ct_id, object_id = decompile_path_node(node)
  537. nodes.append(prefetched[ct_id][object_id])
  538. path.append(nodes)
  539. return path
  540. def get_cable_ids(self):
  541. """
  542. Return all Cable IDs within the path.
  543. """
  544. cable_ct = ContentType.objects.get_for_model(Cable).pk
  545. cable_ids = []
  546. for node in self._nodes:
  547. ct, id = decompile_path_node(node)
  548. if ct == cable_ct:
  549. cable_ids.append(id)
  550. return cable_ids
  551. def get_total_length(self):
  552. """
  553. Return a tuple containing the sum of the length of each cable in the path
  554. and a flag indicating whether the length is definitive.
  555. """
  556. cable_ids = self.get_cable_ids()
  557. cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False)
  558. total_length = cables.aggregate(total=Sum('_abs_length'))['total']
  559. is_definitive = len(cables) == len(cable_ids)
  560. return total_length, is_definitive
  561. def get_split_nodes(self):
  562. """
  563. Return all available next segments in a split cable path.
  564. """
  565. rearport = path_node_to_object(self._nodes[-1])
  566. return FrontPort.objects.filter(rear_port=rearport)