device_components.py 35 KB

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