device_components.py 29 KB

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