cables.py 18 KB

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