device_components.py 33 KB

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