device_components.py 34 KB

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