cables.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. from collections import defaultdict
  2. from django.contrib.contenttypes.fields import GenericForeignKey
  3. from django.contrib.contenttypes.models import ContentType
  4. from django.core.exceptions import ObjectDoesNotExist, ValidationError
  5. from django.db import models
  6. from django.db.models import Sum
  7. from django.urls import reverse
  8. from dcim.choices import *
  9. from dcim.constants import *
  10. from dcim.fields import PathField
  11. from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
  12. from extras.utils import extras_features
  13. from netbox.models import BigIDModel, PrimaryModel
  14. from utilities.fields import ColorField
  15. from utilities.querysets import RestrictedQuerySet
  16. from utilities.utils import to_meters
  17. from .devices import Device
  18. from .device_components import FrontPort, RearPort
  19. __all__ = (
  20. 'Cable',
  21. 'CablePath',
  22. )
  23. #
  24. # Cables
  25. #
  26. @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
  27. class Cable(PrimaryModel):
  28. """
  29. A physical connection between two endpoints.
  30. """
  31. termination_a_type = models.ForeignKey(
  32. to=ContentType,
  33. limit_choices_to=CABLE_TERMINATION_MODELS,
  34. on_delete=models.PROTECT,
  35. related_name='+'
  36. )
  37. termination_a_id = models.PositiveIntegerField()
  38. termination_a = GenericForeignKey(
  39. ct_field='termination_a_type',
  40. fk_field='termination_a_id'
  41. )
  42. termination_b_type = models.ForeignKey(
  43. to=ContentType,
  44. limit_choices_to=CABLE_TERMINATION_MODELS,
  45. on_delete=models.PROTECT,
  46. related_name='+'
  47. )
  48. termination_b_id = models.PositiveIntegerField()
  49. termination_b = GenericForeignKey(
  50. ct_field='termination_b_type',
  51. fk_field='termination_b_id'
  52. )
  53. type = models.CharField(
  54. max_length=50,
  55. choices=CableTypeChoices,
  56. blank=True
  57. )
  58. status = models.CharField(
  59. max_length=50,
  60. choices=CableStatusChoices,
  61. default=CableStatusChoices.STATUS_CONNECTED
  62. )
  63. label = models.CharField(
  64. max_length=100,
  65. blank=True
  66. )
  67. color = ColorField(
  68. blank=True
  69. )
  70. length = models.DecimalField(
  71. max_digits=8,
  72. decimal_places=2,
  73. blank=True,
  74. null=True
  75. )
  76. length_unit = models.CharField(
  77. max_length=50,
  78. choices=CableLengthUnitChoices,
  79. blank=True,
  80. )
  81. # Stores the normalized length (in meters) for database ordering
  82. _abs_length = models.DecimalField(
  83. max_digits=10,
  84. decimal_places=4,
  85. blank=True,
  86. null=True
  87. )
  88. # Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
  89. # their associated Devices.
  90. _termination_a_device = models.ForeignKey(
  91. to=Device,
  92. on_delete=models.CASCADE,
  93. related_name='+',
  94. blank=True,
  95. null=True
  96. )
  97. _termination_b_device = models.ForeignKey(
  98. to=Device,
  99. on_delete=models.CASCADE,
  100. related_name='+',
  101. blank=True,
  102. null=True
  103. )
  104. objects = RestrictedQuerySet.as_manager()
  105. class Meta:
  106. ordering = ['pk']
  107. unique_together = (
  108. ('termination_a_type', 'termination_a_id'),
  109. ('termination_b_type', 'termination_b_id'),
  110. )
  111. def __init__(self, *args, **kwargs):
  112. super().__init__(*args, **kwargs)
  113. # A copy of the PK to be used by __str__ in case the object is deleted
  114. self._pk = self.pk
  115. # Cache the original status so we can check later if it's been changed
  116. self._orig_status = self.status
  117. @classmethod
  118. def from_db(cls, db, field_names, values):
  119. """
  120. Cache the original A and B terminations of existing Cable instances for later reference inside clean().
  121. """
  122. instance = super().from_db(db, field_names, values)
  123. instance._orig_termination_a_type_id = instance.termination_a_type_id
  124. instance._orig_termination_a_id = instance.termination_a_id
  125. instance._orig_termination_b_type_id = instance.termination_b_type_id
  126. instance._orig_termination_b_id = instance.termination_b_id
  127. return instance
  128. def __str__(self):
  129. pk = self.pk or self._pk
  130. return self.label or f'#{pk}'
  131. def get_absolute_url(self):
  132. return reverse('dcim:cable', args=[self.pk])
  133. def clean(self):
  134. from circuits.models import CircuitTermination
  135. super().clean()
  136. # Validate that termination A exists
  137. if not hasattr(self, 'termination_a_type'):
  138. raise ValidationError('Termination A type has not been specified')
  139. try:
  140. self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
  141. except ObjectDoesNotExist:
  142. raise ValidationError({
  143. 'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
  144. })
  145. # Validate that termination B exists
  146. if not hasattr(self, 'termination_b_type'):
  147. raise ValidationError('Termination B type has not been specified')
  148. try:
  149. self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
  150. except ObjectDoesNotExist:
  151. raise ValidationError({
  152. 'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
  153. })
  154. # If editing an existing Cable instance, check that neither termination has been modified.
  155. if self.pk:
  156. err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
  157. if (
  158. self.termination_a_type_id != self._orig_termination_a_type_id or
  159. self.termination_a_id != self._orig_termination_a_id
  160. ):
  161. raise ValidationError({
  162. 'termination_a': err_msg
  163. })
  164. if (
  165. self.termination_b_type_id != self._orig_termination_b_type_id or
  166. self.termination_b_id != self._orig_termination_b_id
  167. ):
  168. raise ValidationError({
  169. 'termination_b': err_msg
  170. })
  171. type_a = self.termination_a_type.model
  172. type_b = self.termination_b_type.model
  173. # Validate interface types
  174. if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
  175. raise ValidationError({
  176. 'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
  177. self.termination_a.get_type_display()
  178. )
  179. })
  180. if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
  181. raise ValidationError({
  182. 'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
  183. self.termination_b.get_type_display()
  184. )
  185. })
  186. # Check that termination types are compatible
  187. if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
  188. raise ValidationError(
  189. f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
  190. )
  191. # Check that two connected RearPorts have the same number of positions (if both are >1)
  192. if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
  193. if self.termination_a.positions > 1 and self.termination_b.positions > 1:
  194. if self.termination_a.positions != self.termination_b.positions:
  195. raise ValidationError(
  196. f"{self.termination_a} has {self.termination_a.positions} position(s) but "
  197. f"{self.termination_b} has {self.termination_b.positions}. "
  198. f"Both terminations must have the same number of positions (if greater than one)."
  199. )
  200. # A termination point cannot be connected to itself
  201. if self.termination_a == self.termination_b:
  202. raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
  203. # A front port cannot be connected to its corresponding rear port
  204. if (
  205. type_a in ['frontport', 'rearport'] and
  206. type_b in ['frontport', 'rearport'] and
  207. (
  208. getattr(self.termination_a, 'rear_port', None) == self.termination_b or
  209. getattr(self.termination_b, 'rear_port', None) == self.termination_a
  210. )
  211. ):
  212. raise ValidationError("A front port cannot be connected to it corresponding rear port")
  213. # A CircuitTermination attached to a ProviderNetwork cannot have a Cable
  214. if isinstance(self.termination_a, CircuitTermination) and self.termination_a.provider_network is not None:
  215. raise ValidationError({
  216. 'termination_a_id': "Circuit terminations attached to a provider network may not be cabled."
  217. })
  218. if isinstance(self.termination_b, CircuitTermination) and self.termination_b.provider_network is not None:
  219. raise ValidationError({
  220. 'termination_b_id': "Circuit terminations attached to a provider network may not be cabled."
  221. })
  222. # Check for an existing Cable connected to either termination object
  223. if self.termination_a.cable not in (None, self):
  224. raise ValidationError("{} already has a cable attached (#{})".format(
  225. self.termination_a, self.termination_a.cable_id
  226. ))
  227. if self.termination_b.cable not in (None, self):
  228. raise ValidationError("{} already has a cable attached (#{})".format(
  229. self.termination_b, self.termination_b.cable_id
  230. ))
  231. # Validate length and length_unit
  232. if self.length is not None and not self.length_unit:
  233. raise ValidationError("Must specify a unit when setting a cable length")
  234. elif self.length is None:
  235. self.length_unit = ''
  236. def save(self, *args, **kwargs):
  237. # Store the given length (if any) in meters for use in database ordering
  238. if self.length and self.length_unit:
  239. self._abs_length = to_meters(self.length, self.length_unit)
  240. else:
  241. self._abs_length = None
  242. # Store the parent Device for the A and B terminations (if applicable) to enable filtering
  243. if hasattr(self.termination_a, 'device'):
  244. self._termination_a_device = self.termination_a.device
  245. if hasattr(self.termination_b, 'device'):
  246. self._termination_b_device = self.termination_b.device
  247. super().save(*args, **kwargs)
  248. # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
  249. self._pk = self.pk
  250. def get_status_class(self):
  251. return CableStatusChoices.CSS_CLASSES.get(self.status)
  252. def get_compatible_types(self):
  253. """
  254. Return all termination types compatible with termination A.
  255. """
  256. if self.termination_a is None:
  257. return
  258. return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
  259. class CablePath(BigIDModel):
  260. """
  261. A CablePath instance represents the physical path from an origin to a destination, including all intermediate
  262. elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do
  263. not terminate on a PathEndpoint).
  264. `path` contains a list of nodes within the path, each represented by a tuple of (type, ID). The first element in the
  265. path must be a Cable instance, followed by a pair of pass-through ports. For example, consider the following
  266. topology:
  267. 1 2 3
  268. Interface A --- Front Port A | Rear Port A --- Rear Port B | Front Port B --- Interface B
  269. This path would be expressed as:
  270. CablePath(
  271. origin = Interface A
  272. destination = Interface B
  273. path = [Cable 1, Front Port A, Rear Port A, Cable 2, Rear Port B, Front Port B, Cable 3]
  274. )
  275. `is_active` is set to True only if 1) `destination` is not null, and 2) every Cable within the path has a status of
  276. "connected".
  277. """
  278. origin_type = models.ForeignKey(
  279. to=ContentType,
  280. on_delete=models.CASCADE,
  281. related_name='+'
  282. )
  283. origin_id = models.PositiveIntegerField()
  284. origin = GenericForeignKey(
  285. ct_field='origin_type',
  286. fk_field='origin_id'
  287. )
  288. destination_type = models.ForeignKey(
  289. to=ContentType,
  290. on_delete=models.CASCADE,
  291. related_name='+',
  292. blank=True,
  293. null=True
  294. )
  295. destination_id = models.PositiveIntegerField(
  296. blank=True,
  297. null=True
  298. )
  299. destination = GenericForeignKey(
  300. ct_field='destination_type',
  301. fk_field='destination_id'
  302. )
  303. path = PathField()
  304. is_active = models.BooleanField(
  305. default=False
  306. )
  307. is_split = models.BooleanField(
  308. default=False
  309. )
  310. class Meta:
  311. unique_together = ('origin_type', 'origin_id')
  312. def __str__(self):
  313. status = ' (active)' if self.is_active else ' (split)' if self.is_split else ''
  314. return f"Path #{self.pk}: {self.origin} to {self.destination} via {len(self.path)} nodes{status}"
  315. def save(self, *args, **kwargs):
  316. super().save(*args, **kwargs)
  317. # Record a direct reference to this CablePath on its originating object
  318. model = self.origin._meta.model
  319. model.objects.filter(pk=self.origin.pk).update(_path=self.pk)
  320. @property
  321. def segment_count(self):
  322. total_length = 1 + len(self.path) + (1 if self.destination else 0)
  323. return int(total_length / 3)
  324. @classmethod
  325. def from_origin(cls, origin):
  326. """
  327. Create a new CablePath instance as traced from the given path origin.
  328. """
  329. from circuits.models import CircuitTermination
  330. if origin is None or origin.cable is None:
  331. return None
  332. destination = None
  333. path = []
  334. position_stack = []
  335. is_active = True
  336. is_split = False
  337. node = origin
  338. while node.cable is not None:
  339. if node.cable.status != CableStatusChoices.STATUS_CONNECTED:
  340. is_active = False
  341. # Follow the cable to its far-end termination
  342. path.append(object_to_path_node(node.cable))
  343. peer_termination = node.get_cable_peer()
  344. # Follow a FrontPort to its corresponding RearPort
  345. if isinstance(peer_termination, FrontPort):
  346. path.append(object_to_path_node(peer_termination))
  347. node = peer_termination.rear_port
  348. if node.positions > 1:
  349. position_stack.append(peer_termination.rear_port_position)
  350. path.append(object_to_path_node(node))
  351. # Follow a RearPort to its corresponding FrontPort (if any)
  352. elif isinstance(peer_termination, RearPort):
  353. path.append(object_to_path_node(peer_termination))
  354. # Determine the peer FrontPort's position
  355. if peer_termination.positions == 1:
  356. position = 1
  357. elif position_stack:
  358. position = position_stack.pop()
  359. else:
  360. # No position indicated: path has split, so we stop at the RearPort
  361. is_split = True
  362. break
  363. try:
  364. node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
  365. path.append(object_to_path_node(node))
  366. except ObjectDoesNotExist:
  367. # No corresponding FrontPort found for the RearPort
  368. break
  369. # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
  370. elif isinstance(peer_termination, CircuitTermination):
  371. path.append(object_to_path_node(peer_termination))
  372. # Get peer CircuitTermination
  373. node = peer_termination.get_peer_termination()
  374. if node:
  375. path.append(object_to_path_node(node))
  376. if node.provider_network:
  377. destination = node.provider_network
  378. break
  379. elif node.site and not node.cable:
  380. destination = node.site
  381. break
  382. else:
  383. # No peer CircuitTermination exists; halt the trace
  384. break
  385. # Anything else marks the end of the path
  386. else:
  387. destination = peer_termination
  388. break
  389. if destination is None:
  390. is_active = False
  391. return cls(
  392. origin=origin,
  393. destination=destination,
  394. path=path,
  395. is_active=is_active,
  396. is_split=is_split
  397. )
  398. def get_path(self):
  399. """
  400. Return the path as a list of prefetched objects.
  401. """
  402. # Compile a list of IDs to prefetch for each type of model in the path
  403. to_prefetch = defaultdict(list)
  404. for node in self.path:
  405. ct_id, object_id = decompile_path_node(node)
  406. to_prefetch[ct_id].append(object_id)
  407. # Prefetch path objects using one query per model type. Prefetch related devices where appropriate.
  408. prefetched = {}
  409. for ct_id, object_ids in to_prefetch.items():
  410. model_class = ContentType.objects.get_for_id(ct_id).model_class()
  411. queryset = model_class.objects.filter(pk__in=object_ids)
  412. if hasattr(model_class, 'device'):
  413. queryset = queryset.prefetch_related('device')
  414. prefetched[ct_id] = {
  415. obj.id: obj for obj in queryset
  416. }
  417. # Replicate the path using the prefetched objects.
  418. path = []
  419. for node in self.path:
  420. ct_id, object_id = decompile_path_node(node)
  421. path.append(prefetched[ct_id][object_id])
  422. return path
  423. @property
  424. def last_node(self):
  425. """
  426. Return either the destination or the last node within the path.
  427. """
  428. return self.destination or path_node_to_object(self.path[-1])
  429. def get_cable_ids(self):
  430. """
  431. Return all Cable IDs within the path.
  432. """
  433. cable_ct = ContentType.objects.get_for_model(Cable).pk
  434. cable_ids = []
  435. for node in self.path:
  436. ct, id = decompile_path_node(node)
  437. if ct == cable_ct:
  438. cable_ids.append(id)
  439. return cable_ids
  440. def get_total_length(self):
  441. """
  442. Return a tuple containing the sum of the length of each cable in the path
  443. and a flag indicating whether the length is definitive.
  444. """
  445. cable_ids = self.get_cable_ids()
  446. cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False)
  447. total_length = cables.aggregate(total=Sum('_abs_length'))['total']
  448. is_definitive = len(cables) == len(cable_ids)
  449. return total_length, is_definitive
  450. def get_split_nodes(self):
  451. """
  452. Return all available next segments in a split cable path.
  453. """
  454. rearport = path_node_to_object(self.path[-1])
  455. return FrontPort.objects.filter(rear_port=rearport)