device_components.py 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045
  1. import logging
  2. from django.contrib.contenttypes.fields import GenericRelation
  3. from django.core.exceptions import ObjectDoesNotExist, ValidationError
  4. from django.core.validators import MaxValueValidator, MinValueValidator
  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.exceptions import CableTraceSplit
  12. from dcim.fields import MACAddressField
  13. from extras.models import ObjectChange, TaggedItem
  14. from extras.utils import extras_features
  15. from utilities.fields import NaturalOrderingField
  16. from utilities.ordering import naturalize_interface
  17. from utilities.querysets import RestrictedQuerySet
  18. from utilities.query_functions import CollateAsChar
  19. from utilities.utils import serialize_object
  20. __all__ = (
  21. 'BaseInterface',
  22. 'CableTermination',
  23. 'ConsolePort',
  24. 'ConsoleServerPort',
  25. 'DeviceBay',
  26. 'FrontPort',
  27. 'Interface',
  28. 'InventoryItem',
  29. 'PowerOutlet',
  30. 'PowerPort',
  31. 'RearPort',
  32. )
  33. class ComponentModel(models.Model):
  34. device = models.ForeignKey(
  35. to='dcim.Device',
  36. on_delete=models.CASCADE,
  37. related_name='%(class)ss'
  38. )
  39. name = models.CharField(
  40. max_length=64
  41. )
  42. _name = NaturalOrderingField(
  43. target_field='name',
  44. max_length=100,
  45. blank=True
  46. )
  47. label = models.CharField(
  48. max_length=64,
  49. blank=True,
  50. help_text="Physical label"
  51. )
  52. description = models.CharField(
  53. max_length=200,
  54. blank=True
  55. )
  56. objects = RestrictedQuerySet.as_manager()
  57. class Meta:
  58. abstract = True
  59. def __str__(self):
  60. if self.label:
  61. return f"{self.name} ({self.label})"
  62. return self.name
  63. def to_objectchange(self, action):
  64. # Annotate the parent Device
  65. try:
  66. device = self.device
  67. except ObjectDoesNotExist:
  68. # The parent Device has already been deleted
  69. device = None
  70. return ObjectChange(
  71. changed_object=self,
  72. object_repr=str(self),
  73. action=action,
  74. related_object=device,
  75. object_data=serialize_object(self)
  76. )
  77. @property
  78. def parent(self):
  79. return getattr(self, 'device', None)
  80. class CableTermination(models.Model):
  81. cable = models.ForeignKey(
  82. to='dcim.Cable',
  83. on_delete=models.SET_NULL,
  84. related_name='+',
  85. blank=True,
  86. null=True
  87. )
  88. # Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted.
  89. _cabled_as_a = GenericRelation(
  90. to='dcim.Cable',
  91. content_type_field='termination_a_type',
  92. object_id_field='termination_a_id'
  93. )
  94. _cabled_as_b = GenericRelation(
  95. to='dcim.Cable',
  96. content_type_field='termination_b_type',
  97. object_id_field='termination_b_id'
  98. )
  99. class Meta:
  100. abstract = True
  101. def trace(self):
  102. """
  103. Return three items: the traceable portion of a cable path, the termination points where it splits (if any), and
  104. the remaining positions on the position stack (if any). Splits occur when the trace is initiated from a midpoint
  105. along a path which traverses a RearPort. In cases where the originating endpoint is unknown, it is not possible
  106. to know which corresponding FrontPort to follow. Remaining positions occur when tracing a path that traverses
  107. a FrontPort without traversing a RearPort again.
  108. The path is a list representing a complete cable path, with each individual segment represented as a
  109. three-tuple:
  110. [
  111. (termination A, cable, termination B),
  112. (termination C, cable, termination D),
  113. (termination E, cable, termination F)
  114. ]
  115. """
  116. endpoint = self
  117. path = []
  118. position_stack = []
  119. def get_peer_port(termination):
  120. from circuits.models import CircuitTermination
  121. # Map a front port to its corresponding rear port
  122. if isinstance(termination, FrontPort):
  123. # Retrieve the corresponding RearPort from database to ensure we have an up-to-date instance
  124. peer_port = RearPort.objects.get(pk=termination.rear_port.pk)
  125. # Don't use the stack for RearPorts with a single position. Only remember the position at
  126. # many-to-one points so we can select the correct FrontPort when we reach the corresponding
  127. # one-to-many point.
  128. if peer_port.positions > 1:
  129. position_stack.append(termination)
  130. return peer_port
  131. # Map a rear port/position to its corresponding front port
  132. elif isinstance(termination, RearPort):
  133. if termination.positions > 1:
  134. # Can't map to a FrontPort without a position if there are multiple options
  135. if not position_stack:
  136. raise CableTraceSplit(termination)
  137. front_port = position_stack.pop()
  138. position = front_port.rear_port_position
  139. # Validate the position
  140. if position not in range(1, termination.positions + 1):
  141. raise Exception("Invalid position for {} ({} positions): {})".format(
  142. termination, termination.positions, position
  143. ))
  144. else:
  145. # Don't use the stack for RearPorts with a single position. The only possible position is 1.
  146. position = 1
  147. try:
  148. peer_port = FrontPort.objects.get(
  149. rear_port=termination,
  150. rear_port_position=position,
  151. )
  152. return peer_port
  153. except ObjectDoesNotExist:
  154. return None
  155. # Follow a circuit to its other termination
  156. elif isinstance(termination, CircuitTermination):
  157. peer_termination = termination.get_peer_termination()
  158. if peer_termination is None:
  159. return None
  160. return peer_termination
  161. # Termination is not a pass-through port
  162. else:
  163. return None
  164. logger = logging.getLogger('netbox.dcim.cable.trace')
  165. logger.debug("Tracing cable from {} {}".format(self.parent, self))
  166. while endpoint is not None:
  167. # No cable connected; nothing to trace
  168. if not endpoint.cable:
  169. path.append((endpoint, None, None))
  170. logger.debug("No cable connected")
  171. return path, None, position_stack
  172. # Check for loops
  173. if endpoint.cable in [segment[1] for segment in path]:
  174. logger.debug("Loop detected!")
  175. return path, None, position_stack
  176. # Record the current segment in the path
  177. far_end = endpoint.get_cable_peer()
  178. path.append((endpoint, endpoint.cable, far_end))
  179. logger.debug("{}[{}] --- Cable {} ---> {}[{}]".format(
  180. endpoint.parent, endpoint, endpoint.cable.pk, far_end.parent, far_end
  181. ))
  182. # Get the peer port of the far end termination
  183. try:
  184. endpoint = get_peer_port(far_end)
  185. except CableTraceSplit as e:
  186. return path, e.termination.frontports.all(), position_stack
  187. if endpoint is None:
  188. return path, None, position_stack
  189. def get_cable_peer(self):
  190. if self.cable is None:
  191. return None
  192. if self._cabled_as_a.exists():
  193. return self.cable.termination_b
  194. if self._cabled_as_b.exists():
  195. return self.cable.termination_a
  196. def get_path_endpoints(self):
  197. """
  198. Return all endpoints of paths which traverse this object.
  199. """
  200. endpoints = []
  201. # Get the far end of the last path segment
  202. path, split_ends, position_stack = self.trace()
  203. endpoint = path[-1][2]
  204. if split_ends is not None:
  205. for termination in split_ends:
  206. endpoints.extend(termination.get_path_endpoints())
  207. elif endpoint is not None:
  208. endpoints.append(endpoint)
  209. return endpoints
  210. #
  211. # Console ports
  212. #
  213. @extras_features('export_templates', 'webhooks')
  214. class ConsolePort(CableTermination, ComponentModel):
  215. """
  216. A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
  217. """
  218. type = models.CharField(
  219. max_length=50,
  220. choices=ConsolePortTypeChoices,
  221. blank=True,
  222. help_text='Physical port type'
  223. )
  224. connected_endpoint = models.OneToOneField(
  225. to='dcim.ConsoleServerPort',
  226. on_delete=models.SET_NULL,
  227. related_name='connected_endpoint',
  228. blank=True,
  229. null=True
  230. )
  231. connection_status = models.BooleanField(
  232. choices=CONNECTION_STATUS_CHOICES,
  233. blank=True,
  234. null=True
  235. )
  236. tags = TaggableManager(through=TaggedItem)
  237. csv_headers = ['device', 'name', 'label', 'type', 'description']
  238. class Meta:
  239. ordering = ('device', '_name')
  240. unique_together = ('device', 'name')
  241. def get_absolute_url(self):
  242. return reverse('dcim:consoleport', kwargs={'pk': self.pk})
  243. def to_csv(self):
  244. return (
  245. self.device.identifier,
  246. self.name,
  247. self.label,
  248. self.type,
  249. self.description,
  250. )
  251. #
  252. # Console server ports
  253. #
  254. @extras_features('webhooks')
  255. class ConsoleServerPort(CableTermination, ComponentModel):
  256. """
  257. A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
  258. """
  259. type = models.CharField(
  260. max_length=50,
  261. choices=ConsolePortTypeChoices,
  262. blank=True,
  263. help_text='Physical port type'
  264. )
  265. connection_status = models.BooleanField(
  266. choices=CONNECTION_STATUS_CHOICES,
  267. blank=True,
  268. null=True
  269. )
  270. tags = TaggableManager(through=TaggedItem)
  271. csv_headers = ['device', 'name', 'label', 'type', 'description']
  272. class Meta:
  273. ordering = ('device', '_name')
  274. unique_together = ('device', 'name')
  275. def get_absolute_url(self):
  276. return reverse('dcim:consoleserverport', kwargs={'pk': self.pk})
  277. def to_csv(self):
  278. return (
  279. self.device.identifier,
  280. self.name,
  281. self.label,
  282. self.type,
  283. self.description,
  284. )
  285. #
  286. # Power ports
  287. #
  288. @extras_features('export_templates', 'webhooks')
  289. class PowerPort(CableTermination, ComponentModel):
  290. """
  291. A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
  292. """
  293. type = models.CharField(
  294. max_length=50,
  295. choices=PowerPortTypeChoices,
  296. blank=True,
  297. help_text='Physical port type'
  298. )
  299. maximum_draw = models.PositiveSmallIntegerField(
  300. blank=True,
  301. null=True,
  302. validators=[MinValueValidator(1)],
  303. help_text="Maximum power draw (watts)"
  304. )
  305. allocated_draw = models.PositiveSmallIntegerField(
  306. blank=True,
  307. null=True,
  308. validators=[MinValueValidator(1)],
  309. help_text="Allocated power draw (watts)"
  310. )
  311. _connected_poweroutlet = models.OneToOneField(
  312. to='dcim.PowerOutlet',
  313. on_delete=models.SET_NULL,
  314. related_name='connected_endpoint',
  315. blank=True,
  316. null=True
  317. )
  318. _connected_powerfeed = models.OneToOneField(
  319. to='dcim.PowerFeed',
  320. on_delete=models.SET_NULL,
  321. related_name='+',
  322. blank=True,
  323. null=True
  324. )
  325. connection_status = models.BooleanField(
  326. choices=CONNECTION_STATUS_CHOICES,
  327. blank=True,
  328. null=True
  329. )
  330. tags = TaggableManager(through=TaggedItem)
  331. csv_headers = ['device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description']
  332. class Meta:
  333. ordering = ('device', '_name')
  334. unique_together = ('device', 'name')
  335. def get_absolute_url(self):
  336. return reverse('dcim:powerport', kwargs={'pk': self.pk})
  337. def to_csv(self):
  338. return (
  339. self.device.identifier,
  340. self.name,
  341. self.label,
  342. self.get_type_display(),
  343. self.maximum_draw,
  344. self.allocated_draw,
  345. self.description,
  346. )
  347. def clean(self):
  348. if self.maximum_draw is not None and self.allocated_draw is not None:
  349. if self.allocated_draw > self.maximum_draw:
  350. raise ValidationError({
  351. 'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
  352. })
  353. @property
  354. def connected_endpoint(self):
  355. """
  356. Return the connected PowerOutlet, if it exists, or the connected PowerFeed, if it exists. We have to check for
  357. ObjectDoesNotExist in case the referenced object has been deleted from the database.
  358. """
  359. try:
  360. if self._connected_poweroutlet:
  361. return self._connected_poweroutlet
  362. except ObjectDoesNotExist:
  363. pass
  364. try:
  365. if self._connected_powerfeed:
  366. return self._connected_powerfeed
  367. except ObjectDoesNotExist:
  368. pass
  369. return None
  370. @connected_endpoint.setter
  371. def connected_endpoint(self, value):
  372. # TODO: Fix circular import
  373. from . import PowerFeed
  374. if value is None:
  375. self._connected_poweroutlet = None
  376. self._connected_powerfeed = None
  377. elif isinstance(value, PowerOutlet):
  378. self._connected_poweroutlet = value
  379. self._connected_powerfeed = None
  380. elif isinstance(value, PowerFeed):
  381. self._connected_poweroutlet = None
  382. self._connected_powerfeed = value
  383. else:
  384. raise ValueError(
  385. "Connected endpoint must be a PowerOutlet or PowerFeed, not {}.".format(type(value))
  386. )
  387. def get_power_draw(self):
  388. """
  389. Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
  390. """
  391. # Calculate aggregate draw of all child power outlets if no numbers have been defined manually
  392. if self.allocated_draw is None and self.maximum_draw is None:
  393. outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
  394. utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate(
  395. maximum_draw_total=Sum('maximum_draw'),
  396. allocated_draw_total=Sum('allocated_draw'),
  397. )
  398. ret = {
  399. 'allocated': utilization['allocated_draw_total'] or 0,
  400. 'maximum': utilization['maximum_draw_total'] or 0,
  401. 'outlet_count': len(outlet_ids),
  402. 'legs': [],
  403. }
  404. # Calculate per-leg aggregates for three-phase feeds
  405. if self._connected_powerfeed and self._connected_powerfeed.phase == PowerFeedPhaseChoices.PHASE_3PHASE:
  406. for leg, leg_name in PowerOutletFeedLegChoices:
  407. outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
  408. utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate(
  409. maximum_draw_total=Sum('maximum_draw'),
  410. allocated_draw_total=Sum('allocated_draw'),
  411. )
  412. ret['legs'].append({
  413. 'name': leg_name,
  414. 'allocated': utilization['allocated_draw_total'] or 0,
  415. 'maximum': utilization['maximum_draw_total'] or 0,
  416. 'outlet_count': len(outlet_ids),
  417. })
  418. return ret
  419. # Default to administratively defined values
  420. return {
  421. 'allocated': self.allocated_draw or 0,
  422. 'maximum': self.maximum_draw or 0,
  423. 'outlet_count': PowerOutlet.objects.filter(power_port=self).count(),
  424. 'legs': [],
  425. }
  426. #
  427. # Power outlets
  428. #
  429. @extras_features('webhooks')
  430. class PowerOutlet(CableTermination, ComponentModel):
  431. """
  432. A physical power outlet (output) within a Device which provides power to a PowerPort.
  433. """
  434. type = models.CharField(
  435. max_length=50,
  436. choices=PowerOutletTypeChoices,
  437. blank=True,
  438. help_text='Physical port type'
  439. )
  440. power_port = models.ForeignKey(
  441. to='dcim.PowerPort',
  442. on_delete=models.SET_NULL,
  443. blank=True,
  444. null=True,
  445. related_name='poweroutlets'
  446. )
  447. feed_leg = models.CharField(
  448. max_length=50,
  449. choices=PowerOutletFeedLegChoices,
  450. blank=True,
  451. help_text="Phase (for three-phase feeds)"
  452. )
  453. connection_status = models.BooleanField(
  454. choices=CONNECTION_STATUS_CHOICES,
  455. blank=True,
  456. null=True
  457. )
  458. tags = TaggableManager(through=TaggedItem)
  459. csv_headers = ['device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description']
  460. class Meta:
  461. ordering = ('device', '_name')
  462. unique_together = ('device', 'name')
  463. def get_absolute_url(self):
  464. return reverse('dcim:poweroutlet', kwargs={'pk': self.pk})
  465. def to_csv(self):
  466. return (
  467. self.device.identifier,
  468. self.name,
  469. self.label,
  470. self.get_type_display(),
  471. self.power_port.name if self.power_port else None,
  472. self.get_feed_leg_display(),
  473. self.description,
  474. )
  475. def clean(self):
  476. # Validate power port assignment
  477. if self.power_port and self.power_port.device != self.device:
  478. raise ValidationError(
  479. "Parent power port ({}) must belong to the same device".format(self.power_port)
  480. )
  481. #
  482. # Interfaces
  483. #
  484. class BaseInterface(models.Model):
  485. """
  486. Abstract base class for fields shared by dcim.Interface and virtualization.VMInterface.
  487. """
  488. enabled = models.BooleanField(
  489. default=True
  490. )
  491. mac_address = MACAddressField(
  492. null=True,
  493. blank=True,
  494. verbose_name='MAC Address'
  495. )
  496. mtu = models.PositiveIntegerField(
  497. blank=True,
  498. null=True,
  499. validators=[MinValueValidator(1), MaxValueValidator(65536)],
  500. verbose_name='MTU'
  501. )
  502. mode = models.CharField(
  503. max_length=50,
  504. choices=InterfaceModeChoices,
  505. blank=True
  506. )
  507. class Meta:
  508. abstract = True
  509. @extras_features('graphs', 'export_templates', 'webhooks')
  510. class Interface(CableTermination, ComponentModel, BaseInterface):
  511. """
  512. A network interface within a Device. A physical Interface can connect to exactly one other Interface.
  513. """
  514. # Override ComponentModel._name to specify naturalize_interface function
  515. _name = NaturalOrderingField(
  516. target_field='name',
  517. naturalize_function=naturalize_interface,
  518. max_length=100,
  519. blank=True
  520. )
  521. _connected_interface = models.OneToOneField(
  522. to='self',
  523. on_delete=models.SET_NULL,
  524. related_name='+',
  525. blank=True,
  526. null=True
  527. )
  528. _connected_circuittermination = models.OneToOneField(
  529. to='circuits.CircuitTermination',
  530. on_delete=models.SET_NULL,
  531. related_name='+',
  532. blank=True,
  533. null=True
  534. )
  535. connection_status = models.BooleanField(
  536. choices=CONNECTION_STATUS_CHOICES,
  537. blank=True,
  538. null=True
  539. )
  540. lag = models.ForeignKey(
  541. to='self',
  542. on_delete=models.SET_NULL,
  543. related_name='member_interfaces',
  544. null=True,
  545. blank=True,
  546. verbose_name='Parent LAG'
  547. )
  548. type = models.CharField(
  549. max_length=50,
  550. choices=InterfaceTypeChoices
  551. )
  552. mgmt_only = models.BooleanField(
  553. default=False,
  554. verbose_name='OOB Management',
  555. help_text='This interface is used only for out-of-band management'
  556. )
  557. untagged_vlan = models.ForeignKey(
  558. to='ipam.VLAN',
  559. on_delete=models.SET_NULL,
  560. related_name='interfaces_as_untagged',
  561. null=True,
  562. blank=True,
  563. verbose_name='Untagged VLAN'
  564. )
  565. tagged_vlans = models.ManyToManyField(
  566. to='ipam.VLAN',
  567. related_name='interfaces_as_tagged',
  568. blank=True,
  569. verbose_name='Tagged VLANs'
  570. )
  571. ip_addresses = GenericRelation(
  572. to='ipam.IPAddress',
  573. content_type_field='assigned_object_type',
  574. object_id_field='assigned_object_id',
  575. related_query_name='interface'
  576. )
  577. tags = TaggableManager(through=TaggedItem)
  578. csv_headers = [
  579. 'device', 'name', 'label', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode',
  580. ]
  581. class Meta:
  582. ordering = ('device', CollateAsChar('_name'))
  583. unique_together = ('device', 'name')
  584. def get_absolute_url(self):
  585. return reverse('dcim:interface', kwargs={'pk': self.pk})
  586. def to_csv(self):
  587. return (
  588. self.device.identifier if self.device else None,
  589. self.name,
  590. self.label,
  591. self.lag.name if self.lag else None,
  592. self.get_type_display(),
  593. self.enabled,
  594. self.mac_address,
  595. self.mtu,
  596. self.mgmt_only,
  597. self.description,
  598. self.get_mode_display(),
  599. )
  600. def clean(self):
  601. # Virtual interfaces cannot be connected
  602. if self.type in NONCONNECTABLE_IFACE_TYPES and (
  603. self.cable or getattr(self, 'circuit_termination', False)
  604. ):
  605. raise ValidationError({
  606. 'type': "Virtual and wireless interfaces cannot be connected to another interface or circuit. "
  607. "Disconnect the interface or choose a suitable type."
  608. })
  609. # An interface's LAG must belong to the same device or virtual chassis
  610. if self.lag and self.lag.device != self.device:
  611. if self.device.virtual_chassis is None:
  612. raise ValidationError({
  613. 'lag': f"The selected LAG interface ({self.lag}) belongs to a different device ({self.lag.device})."
  614. })
  615. elif self.lag.device.virtual_chassis != self.device.virtual_chassis:
  616. raise ValidationError({
  617. 'lag': f"The selected LAG interface ({self.lag}) belongs to {self.lag.device}, which is not part "
  618. f"of virtual chassis {self.device.virtual_chassis}."
  619. })
  620. # A virtual interface cannot have a parent LAG
  621. if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
  622. raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."})
  623. # A LAG interface cannot be its own parent
  624. if self.pk and self.lag_id == self.pk:
  625. raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
  626. # Validate untagged VLAN
  627. if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
  628. raise ValidationError({
  629. 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
  630. "device, or it must be global".format(self.untagged_vlan)
  631. })
  632. def save(self, *args, **kwargs):
  633. # Remove untagged VLAN assignment for non-802.1Q interfaces
  634. if self.mode is None:
  635. self.untagged_vlan = None
  636. # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
  637. if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED:
  638. self.tagged_vlans.clear()
  639. return super().save(*args, **kwargs)
  640. @property
  641. def connected_endpoint(self):
  642. """
  643. Return the connected Interface, if it exists, or the connected CircuitTermination, if it exists. We have to
  644. check for ObjectDoesNotExist in case the referenced object has been deleted from the database.
  645. """
  646. try:
  647. if self._connected_interface:
  648. return self._connected_interface
  649. except ObjectDoesNotExist:
  650. pass
  651. try:
  652. if self._connected_circuittermination:
  653. return self._connected_circuittermination
  654. except ObjectDoesNotExist:
  655. pass
  656. return None
  657. @connected_endpoint.setter
  658. def connected_endpoint(self, value):
  659. from circuits.models import CircuitTermination
  660. if value is None:
  661. self._connected_interface = None
  662. self._connected_circuittermination = None
  663. elif isinstance(value, Interface):
  664. self._connected_interface = value
  665. self._connected_circuittermination = None
  666. elif isinstance(value, CircuitTermination):
  667. self._connected_interface = None
  668. self._connected_circuittermination = value
  669. else:
  670. raise ValueError(
  671. "Connected endpoint must be an Interface or CircuitTermination, not {}.".format(type(value))
  672. )
  673. @property
  674. def parent(self):
  675. return self.device
  676. @property
  677. def is_connectable(self):
  678. return self.type not in NONCONNECTABLE_IFACE_TYPES
  679. @property
  680. def is_virtual(self):
  681. return self.type in VIRTUAL_IFACE_TYPES
  682. @property
  683. def is_wireless(self):
  684. return self.type in WIRELESS_IFACE_TYPES
  685. @property
  686. def is_lag(self):
  687. return self.type == InterfaceTypeChoices.TYPE_LAG
  688. @property
  689. def count_ipaddresses(self):
  690. return self.ip_addresses.count()
  691. #
  692. # Pass-through ports
  693. #
  694. @extras_features('webhooks')
  695. class FrontPort(CableTermination, ComponentModel):
  696. """
  697. A pass-through port on the front of a Device.
  698. """
  699. type = models.CharField(
  700. max_length=50,
  701. choices=PortTypeChoices
  702. )
  703. rear_port = models.ForeignKey(
  704. to='dcim.RearPort',
  705. on_delete=models.CASCADE,
  706. related_name='frontports'
  707. )
  708. rear_port_position = models.PositiveSmallIntegerField(
  709. default=1,
  710. validators=[
  711. MinValueValidator(REARPORT_POSITIONS_MIN),
  712. MaxValueValidator(REARPORT_POSITIONS_MAX)
  713. ]
  714. )
  715. tags = TaggableManager(through=TaggedItem)
  716. csv_headers = ['device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description']
  717. class Meta:
  718. ordering = ('device', '_name')
  719. unique_together = (
  720. ('device', 'name'),
  721. ('rear_port', 'rear_port_position'),
  722. )
  723. def get_absolute_url(self):
  724. return reverse('dcim:frontport', kwargs={'pk': self.pk})
  725. def to_csv(self):
  726. return (
  727. self.device.identifier,
  728. self.name,
  729. self.label,
  730. self.get_type_display(),
  731. self.rear_port.name,
  732. self.rear_port_position,
  733. self.description,
  734. )
  735. def clean(self):
  736. # Validate rear port assignment
  737. if self.rear_port.device != self.device:
  738. raise ValidationError({
  739. "rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
  740. })
  741. # Validate rear port position assignment
  742. if self.rear_port_position > self.rear_port.positions:
  743. raise ValidationError({
  744. "rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
  745. f"{self.rear_port.name} has only {self.rear_port.positions} positions"
  746. })
  747. @extras_features('webhooks')
  748. class RearPort(CableTermination, ComponentModel):
  749. """
  750. A pass-through port on the rear of a Device.
  751. """
  752. type = models.CharField(
  753. max_length=50,
  754. choices=PortTypeChoices
  755. )
  756. positions = models.PositiveSmallIntegerField(
  757. default=1,
  758. validators=[
  759. MinValueValidator(REARPORT_POSITIONS_MIN),
  760. MaxValueValidator(REARPORT_POSITIONS_MAX)
  761. ]
  762. )
  763. tags = TaggableManager(through=TaggedItem)
  764. csv_headers = ['device', 'name', 'label', 'type', 'positions', 'description']
  765. class Meta:
  766. ordering = ('device', '_name')
  767. unique_together = ('device', 'name')
  768. def get_absolute_url(self):
  769. return reverse('dcim:rearport', kwargs={'pk': self.pk})
  770. def clean(self):
  771. # Check that positions count is greater than or equal to the number of associated FrontPorts
  772. frontport_count = self.frontports.count()
  773. if self.positions < frontport_count:
  774. raise ValidationError({
  775. "positions": f"The number of positions cannot be less than the number of mapped front ports "
  776. f"({frontport_count})"
  777. })
  778. def to_csv(self):
  779. return (
  780. self.device.identifier,
  781. self.name,
  782. self.label,
  783. self.get_type_display(),
  784. self.positions,
  785. self.description,
  786. )
  787. #
  788. # Device bays
  789. #
  790. @extras_features('webhooks')
  791. class DeviceBay(ComponentModel):
  792. """
  793. An empty space within a Device which can house a child device
  794. """
  795. installed_device = models.OneToOneField(
  796. to='dcim.Device',
  797. on_delete=models.SET_NULL,
  798. related_name='parent_bay',
  799. blank=True,
  800. null=True
  801. )
  802. tags = TaggableManager(through=TaggedItem)
  803. csv_headers = ['device', 'name', 'label', 'installed_device', 'description']
  804. class Meta:
  805. ordering = ('device', '_name')
  806. unique_together = ('device', 'name')
  807. def get_absolute_url(self):
  808. return reverse('dcim:devicebay', kwargs={'pk': self.pk})
  809. def to_csv(self):
  810. return (
  811. self.device.identifier,
  812. self.name,
  813. self.label,
  814. self.installed_device.identifier if self.installed_device else None,
  815. self.description,
  816. )
  817. def clean(self):
  818. # Validate that the parent Device can have DeviceBays
  819. if not self.device.device_type.is_parent_device:
  820. raise ValidationError("This type of device ({}) does not support device bays.".format(
  821. self.device.device_type
  822. ))
  823. # Cannot install a device into itself, obviously
  824. if self.device == self.installed_device:
  825. raise ValidationError("Cannot install a device into itself.")
  826. # Check that the installed device is not already installed elsewhere
  827. if self.installed_device:
  828. current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
  829. if current_bay and current_bay != self:
  830. raise ValidationError({
  831. 'installed_device': "Cannot install the specified device; device is already installed in {}".format(
  832. current_bay
  833. )
  834. })
  835. #
  836. # Inventory items
  837. #
  838. @extras_features('export_templates', 'webhooks')
  839. class InventoryItem(ComponentModel):
  840. """
  841. An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
  842. InventoryItems are used only for inventory purposes.
  843. """
  844. parent = models.ForeignKey(
  845. to='self',
  846. on_delete=models.CASCADE,
  847. related_name='child_items',
  848. blank=True,
  849. null=True
  850. )
  851. manufacturer = models.ForeignKey(
  852. to='dcim.Manufacturer',
  853. on_delete=models.PROTECT,
  854. related_name='inventory_items',
  855. blank=True,
  856. null=True
  857. )
  858. part_id = models.CharField(
  859. max_length=50,
  860. verbose_name='Part ID',
  861. blank=True,
  862. help_text='Manufacturer-assigned part identifier'
  863. )
  864. serial = models.CharField(
  865. max_length=50,
  866. verbose_name='Serial number',
  867. blank=True
  868. )
  869. asset_tag = models.CharField(
  870. max_length=50,
  871. unique=True,
  872. blank=True,
  873. null=True,
  874. verbose_name='Asset tag',
  875. help_text='A unique tag used to identify this item'
  876. )
  877. discovered = models.BooleanField(
  878. default=False,
  879. help_text='This item was automatically discovered'
  880. )
  881. tags = TaggableManager(through=TaggedItem)
  882. csv_headers = [
  883. 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
  884. ]
  885. class Meta:
  886. ordering = ('device__id', 'parent__id', '_name')
  887. unique_together = ('device', 'parent', 'name')
  888. def get_absolute_url(self):
  889. return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})
  890. def to_csv(self):
  891. return (
  892. self.device.name or '{{{}}}'.format(self.device.pk),
  893. self.name,
  894. self.label,
  895. self.manufacturer.name if self.manufacturer else None,
  896. self.part_id,
  897. self.serial,
  898. self.asset_tag,
  899. self.discovered,
  900. self.description,
  901. )