2
0

cables.py 19 KB

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