device_components.py 33 KB

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