device_components.py 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335
  1. from functools import cached_property
  2. from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
  3. from django.core.exceptions import 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 django.utils.translation import gettext_lazy as _
  9. from mptt.models import MPTTModel, TreeForeignKey
  10. from dcim.choices import *
  11. from dcim.constants import *
  12. from dcim.fields import MACAddressField, WWNField
  13. from netbox.choices import ColorChoices
  14. from netbox.models import OrganizationalModel, NetBoxModel
  15. from utilities.fields import ColorField, NaturalOrderingField
  16. from utilities.mptt import TreeManager
  17. from utilities.ordering import naturalize_interface
  18. from utilities.query_functions import CollateAsChar
  19. from utilities.tracking import TrackingModelMixin
  20. from wireless.choices import *
  21. from wireless.utils import get_channel_attr
  22. __all__ = (
  23. 'BaseInterface',
  24. 'CabledObjectModel',
  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(NetBoxModel):
  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. verbose_name=_('name'),
  49. max_length=64
  50. )
  51. _name = NaturalOrderingField(
  52. target_field='name',
  53. max_length=100,
  54. blank=True
  55. )
  56. label = models.CharField(
  57. verbose_name=_('label'),
  58. max_length=64,
  59. blank=True,
  60. help_text=_('Physical label')
  61. )
  62. description = models.CharField(
  63. verbose_name=_('description'),
  64. max_length=200,
  65. blank=True
  66. )
  67. class Meta:
  68. abstract = True
  69. ordering = ('device', '_name')
  70. constraints = (
  71. models.UniqueConstraint(
  72. fields=('device', 'name'),
  73. name='%(app_label)s_%(class)s_unique_device_name'
  74. ),
  75. )
  76. def __init__(self, *args, **kwargs):
  77. super().__init__(*args, **kwargs)
  78. # Cache the original Device ID for reference under clean()
  79. self._original_device = self.__dict__.get('device_id')
  80. def __str__(self):
  81. if self.label:
  82. return f"{self.name} ({self.label})"
  83. return self.name
  84. def to_objectchange(self, action):
  85. objectchange = super().to_objectchange(action)
  86. objectchange.related_object = self.device
  87. return objectchange
  88. def clean(self):
  89. super().clean()
  90. # Check list of Modules that allow device field to be changed
  91. if (type(self) not in [InventoryItem]) and (self.pk is not None) and (self._original_device != self.device_id):
  92. raise ValidationError({
  93. "device": _("Components cannot be moved to a different device.")
  94. })
  95. @property
  96. def parent_object(self):
  97. return self.device
  98. class ModularComponentModel(ComponentModel):
  99. module = models.ForeignKey(
  100. to='dcim.Module',
  101. on_delete=models.CASCADE,
  102. related_name='%(class)ss',
  103. blank=True,
  104. null=True
  105. )
  106. inventory_items = GenericRelation(
  107. to='dcim.InventoryItem',
  108. content_type_field='component_type',
  109. object_id_field='component_id'
  110. )
  111. class Meta(ComponentModel.Meta):
  112. abstract = True
  113. class CabledObjectModel(models.Model):
  114. """
  115. An abstract model inherited by all models to which a Cable can terminate. Provides the `cable` and `cable_end`
  116. fields for caching cable associations, as well as `mark_connected` to designate "fake" connections.
  117. """
  118. cable = models.ForeignKey(
  119. to='dcim.Cable',
  120. on_delete=models.SET_NULL,
  121. related_name='+',
  122. blank=True,
  123. null=True
  124. )
  125. cable_end = models.CharField(
  126. verbose_name=_('cable end'),
  127. max_length=1,
  128. blank=True,
  129. choices=CableEndChoices
  130. )
  131. mark_connected = models.BooleanField(
  132. verbose_name=_('mark connected'),
  133. default=False,
  134. help_text=_('Treat as if a cable is connected')
  135. )
  136. cable_terminations = GenericRelation(
  137. to='dcim.CableTermination',
  138. content_type_field='termination_type',
  139. object_id_field='termination_id',
  140. related_query_name='%(class)s',
  141. )
  142. class Meta:
  143. abstract = True
  144. def clean(self):
  145. super().clean()
  146. if self.cable and not self.cable_end:
  147. raise ValidationError({
  148. "cable_end": _("Must specify cable end (A or B) when attaching a cable.")
  149. })
  150. if self.cable_end and not self.cable:
  151. raise ValidationError({
  152. "cable_end": _("Cable end must not be set without a cable.")
  153. })
  154. if self.mark_connected and self.cable:
  155. raise ValidationError({
  156. "mark_connected": _("Cannot mark as connected with a cable attached.")
  157. })
  158. @property
  159. def link(self):
  160. """
  161. Generic wrapper for a Cable, WirelessLink, or some other relation to a connected termination.
  162. """
  163. return self.cable
  164. @cached_property
  165. def link_peers(self):
  166. if self.cable:
  167. peers = self.cable.terminations.exclude(cable_end=self.cable_end).prefetch_related('termination')
  168. return [peer.termination for peer in peers]
  169. return []
  170. @property
  171. def _occupied(self):
  172. return bool(self.mark_connected or self.cable_id)
  173. @property
  174. def parent_object(self):
  175. raise NotImplementedError(
  176. _("{class_name} models must declare a parent_object property").format(class_name=self.__class__.__name__)
  177. )
  178. @property
  179. def opposite_cable_end(self):
  180. if not self.cable_end:
  181. return None
  182. return CableEndChoices.SIDE_A if self.cable_end == CableEndChoices.SIDE_B else CableEndChoices.SIDE_B
  183. class PathEndpoint(models.Model):
  184. """
  185. An abstract model inherited by any CabledObjectModel subclass which represents the end of a CablePath; specifically,
  186. these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed.
  187. `_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in
  188. dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
  189. CablePath model. `_path` should not be accessed directly; rather, use the `path` property.
  190. `connected_endpoints()` is a convenience method for returning the destination of the associated CablePath, if any.
  191. """
  192. _path = models.ForeignKey(
  193. to='dcim.CablePath',
  194. on_delete=models.SET_NULL,
  195. null=True,
  196. blank=True
  197. )
  198. class Meta:
  199. abstract = True
  200. def trace(self):
  201. origin = self
  202. path = []
  203. # Construct the complete path (including e.g. bridged interfaces)
  204. while origin is not None:
  205. if origin._path is None:
  206. break
  207. path.extend(origin._path.path_objects)
  208. # If the path ends at a non-connected pass-through port, pad out the link and far-end terminations
  209. if len(path) % 3 == 1:
  210. path.extend(([], []))
  211. # If the path ends at a site or provider network, inject a null "link" to render an attachment
  212. elif len(path) % 3 == 2:
  213. path.insert(-1, [])
  214. # Check for a bridged relationship to continue the trace
  215. destinations = origin._path.destinations
  216. if len(destinations) == 1:
  217. origin = getattr(destinations[0], 'bridge', None)
  218. else:
  219. origin = None
  220. # Return the path as a list of three-tuples (A termination(s), cable(s), B termination(s))
  221. return list(zip(*[iter(path)] * 3))
  222. @property
  223. def path(self):
  224. return self._path
  225. @cached_property
  226. def connected_endpoints(self):
  227. """
  228. Caching accessor for the attached CablePath's destination (if any)
  229. """
  230. return self._path.destinations if self._path else []
  231. #
  232. # Console components
  233. #
  234. class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
  235. """
  236. A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
  237. """
  238. type = models.CharField(
  239. verbose_name=_('type'),
  240. max_length=50,
  241. choices=ConsolePortTypeChoices,
  242. blank=True,
  243. help_text=_('Physical port type')
  244. )
  245. speed = models.PositiveIntegerField(
  246. verbose_name=_('speed'),
  247. choices=ConsolePortSpeedChoices,
  248. blank=True,
  249. null=True,
  250. help_text=_('Port speed in bits per second')
  251. )
  252. clone_fields = ('device', 'module', 'type', 'speed')
  253. class Meta(ModularComponentModel.Meta):
  254. verbose_name = _('console port')
  255. verbose_name_plural = _('console ports')
  256. def get_absolute_url(self):
  257. return reverse('dcim:consoleport', kwargs={'pk': self.pk})
  258. class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
  259. """
  260. A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
  261. """
  262. type = models.CharField(
  263. verbose_name=_('type'),
  264. max_length=50,
  265. choices=ConsolePortTypeChoices,
  266. blank=True,
  267. help_text=_('Physical port type')
  268. )
  269. speed = models.PositiveIntegerField(
  270. verbose_name=_('speed'),
  271. choices=ConsolePortSpeedChoices,
  272. blank=True,
  273. null=True,
  274. help_text=_('Port speed in bits per second')
  275. )
  276. clone_fields = ('device', 'module', 'type', 'speed')
  277. class Meta(ModularComponentModel.Meta):
  278. verbose_name = _('console server port')
  279. verbose_name_plural = _('console server ports')
  280. def get_absolute_url(self):
  281. return reverse('dcim:consoleserverport', kwargs={'pk': self.pk})
  282. #
  283. # Power components
  284. #
  285. class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
  286. """
  287. A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
  288. """
  289. type = models.CharField(
  290. verbose_name=_('type'),
  291. max_length=50,
  292. choices=PowerPortTypeChoices,
  293. blank=True,
  294. help_text=_('Physical port type')
  295. )
  296. maximum_draw = models.PositiveIntegerField(
  297. verbose_name=_('maximum draw'),
  298. blank=True,
  299. null=True,
  300. validators=[MinValueValidator(1)],
  301. help_text=_("Maximum power draw (watts)")
  302. )
  303. allocated_draw = models.PositiveIntegerField(
  304. verbose_name=_('allocated draw'),
  305. blank=True,
  306. null=True,
  307. validators=[MinValueValidator(1)],
  308. help_text=_('Allocated power draw (watts)')
  309. )
  310. clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw')
  311. class Meta(ModularComponentModel.Meta):
  312. verbose_name = _('power port')
  313. verbose_name_plural = _('power ports')
  314. def get_absolute_url(self):
  315. return reverse('dcim:powerport', kwargs={'pk': self.pk})
  316. def clean(self):
  317. super().clean()
  318. if self.maximum_draw is not None and self.allocated_draw is not None:
  319. if self.allocated_draw > self.maximum_draw:
  320. raise ValidationError({
  321. 'allocated_draw': _(
  322. "Allocated draw cannot exceed the maximum draw ({maximum_draw}W)."
  323. ).format(maximum_draw=self.maximum_draw)
  324. })
  325. def get_downstream_powerports(self, leg=None):
  326. """
  327. Return a queryset of all PowerPorts connected via cable to a child PowerOutlet. For example, in the topology
  328. below, PP1.get_downstream_powerports() would return PP2-4.
  329. ---- PO1 <---> PP2
  330. /
  331. PP1 ------- PO2 <---> PP3
  332. \
  333. ---- PO3 <---> PP4
  334. """
  335. poweroutlets = self.poweroutlets.filter(cable__isnull=False)
  336. if leg:
  337. poweroutlets = poweroutlets.filter(feed_leg=leg)
  338. if not poweroutlets:
  339. return PowerPort.objects.none()
  340. q = Q()
  341. for poweroutlet in poweroutlets:
  342. q |= Q(
  343. cable=poweroutlet.cable,
  344. cable_end=poweroutlet.opposite_cable_end
  345. )
  346. return PowerPort.objects.filter(q)
  347. def get_power_draw(self):
  348. """
  349. Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
  350. """
  351. from dcim.models import PowerFeed
  352. # Calculate aggregate draw of all child power outlets if no numbers have been defined manually
  353. if self.allocated_draw is None and self.maximum_draw is None:
  354. utilization = self.get_downstream_powerports().aggregate(
  355. maximum_draw_total=Sum('maximum_draw'),
  356. allocated_draw_total=Sum('allocated_draw'),
  357. )
  358. ret = {
  359. 'allocated': utilization['allocated_draw_total'] or 0,
  360. 'maximum': utilization['maximum_draw_total'] or 0,
  361. 'outlet_count': self.poweroutlets.count(),
  362. 'legs': [],
  363. }
  364. # Calculate per-leg aggregates for three-phase power feeds
  365. if len(self.link_peers) == 1 and isinstance(self.link_peers[0], PowerFeed) and \
  366. self.link_peers[0].phase == PowerFeedPhaseChoices.PHASE_3PHASE:
  367. for leg, leg_name in PowerOutletFeedLegChoices:
  368. utilization = self.get_downstream_powerports(leg=leg).aggregate(
  369. maximum_draw_total=Sum('maximum_draw'),
  370. allocated_draw_total=Sum('allocated_draw'),
  371. )
  372. ret['legs'].append({
  373. 'name': leg_name,
  374. 'allocated': utilization['allocated_draw_total'] or 0,
  375. 'maximum': utilization['maximum_draw_total'] or 0,
  376. 'outlet_count': self.poweroutlets.filter(feed_leg=leg).count(),
  377. })
  378. return ret
  379. # Default to administratively defined values
  380. return {
  381. 'allocated': self.allocated_draw or 0,
  382. 'maximum': self.maximum_draw or 0,
  383. 'outlet_count': self.poweroutlets.count(),
  384. 'legs': [],
  385. }
  386. class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
  387. """
  388. A physical power outlet (output) within a Device which provides power to a PowerPort.
  389. """
  390. type = models.CharField(
  391. verbose_name=_('type'),
  392. max_length=50,
  393. choices=PowerOutletTypeChoices,
  394. blank=True,
  395. help_text=_('Physical port type')
  396. )
  397. power_port = models.ForeignKey(
  398. to='dcim.PowerPort',
  399. on_delete=models.SET_NULL,
  400. blank=True,
  401. null=True,
  402. related_name='poweroutlets'
  403. )
  404. feed_leg = models.CharField(
  405. verbose_name=_('feed leg'),
  406. max_length=50,
  407. choices=PowerOutletFeedLegChoices,
  408. blank=True,
  409. help_text=_('Phase (for three-phase feeds)')
  410. )
  411. clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg')
  412. class Meta(ModularComponentModel.Meta):
  413. verbose_name = _('power outlet')
  414. verbose_name_plural = _('power outlets')
  415. def get_absolute_url(self):
  416. return reverse('dcim:poweroutlet', kwargs={'pk': self.pk})
  417. def clean(self):
  418. super().clean()
  419. # Validate power port assignment
  420. if self.power_port and self.power_port.device != self.device:
  421. raise ValidationError(
  422. _("Parent power port ({power_port}) must belong to the same device").format(power_port=self.power_port)
  423. )
  424. #
  425. # Interfaces
  426. #
  427. class BaseInterface(models.Model):
  428. """
  429. Abstract base class for fields shared by dcim.Interface and virtualization.VMInterface.
  430. """
  431. enabled = models.BooleanField(
  432. verbose_name=_('enabled'),
  433. default=True
  434. )
  435. mac_address = MACAddressField(
  436. null=True,
  437. blank=True,
  438. verbose_name=_('MAC address')
  439. )
  440. mtu = models.PositiveIntegerField(
  441. blank=True,
  442. null=True,
  443. validators=[
  444. MinValueValidator(INTERFACE_MTU_MIN),
  445. MaxValueValidator(INTERFACE_MTU_MAX)
  446. ],
  447. verbose_name=_('MTU')
  448. )
  449. mode = models.CharField(
  450. verbose_name=_('mode'),
  451. max_length=50,
  452. choices=InterfaceModeChoices,
  453. blank=True,
  454. help_text=_('IEEE 802.1Q tagging strategy')
  455. )
  456. parent = models.ForeignKey(
  457. to='self',
  458. on_delete=models.RESTRICT,
  459. related_name='child_interfaces',
  460. null=True,
  461. blank=True,
  462. verbose_name=_('parent interface')
  463. )
  464. bridge = models.ForeignKey(
  465. to='self',
  466. on_delete=models.SET_NULL,
  467. related_name='bridge_interfaces',
  468. null=True,
  469. blank=True,
  470. verbose_name=_('bridge interface')
  471. )
  472. class Meta:
  473. abstract = True
  474. def save(self, *args, **kwargs):
  475. # Remove untagged VLAN assignment for non-802.1Q interfaces
  476. if not self.mode:
  477. self.untagged_vlan = None
  478. # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
  479. if not self._state.adding and self.mode != InterfaceModeChoices.MODE_TAGGED:
  480. self.tagged_vlans.clear()
  481. return super().save(*args, **kwargs)
  482. @property
  483. def tunnel_termination(self):
  484. return self.tunnel_terminations.first()
  485. @property
  486. def count_ipaddresses(self):
  487. return self.ip_addresses.count()
  488. @property
  489. def count_fhrp_groups(self):
  490. return self.fhrp_group_assignments.count()
  491. class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint, TrackingModelMixin):
  492. """
  493. A network interface within a Device. A physical Interface can connect to exactly one other Interface.
  494. """
  495. # Override ComponentModel._name to specify naturalize_interface function
  496. _name = NaturalOrderingField(
  497. target_field='name',
  498. naturalize_function=naturalize_interface,
  499. max_length=100,
  500. blank=True
  501. )
  502. vdcs = models.ManyToManyField(
  503. to='dcim.VirtualDeviceContext',
  504. related_name='interfaces'
  505. )
  506. lag = models.ForeignKey(
  507. to='self',
  508. on_delete=models.SET_NULL,
  509. related_name='member_interfaces',
  510. null=True,
  511. blank=True,
  512. verbose_name=_('parent LAG')
  513. )
  514. type = models.CharField(
  515. verbose_name=_('type'),
  516. max_length=50,
  517. choices=InterfaceTypeChoices
  518. )
  519. mgmt_only = models.BooleanField(
  520. default=False,
  521. verbose_name=_('management only'),
  522. help_text=_('This interface is used only for out-of-band management')
  523. )
  524. speed = models.PositiveIntegerField(
  525. blank=True,
  526. null=True,
  527. verbose_name=_('speed (Kbps)')
  528. )
  529. duplex = models.CharField(
  530. verbose_name=_('duplex'),
  531. max_length=50,
  532. blank=True,
  533. null=True,
  534. choices=InterfaceDuplexChoices
  535. )
  536. wwn = WWNField(
  537. null=True,
  538. blank=True,
  539. verbose_name=_('WWN'),
  540. help_text=_('64-bit World Wide Name')
  541. )
  542. rf_role = models.CharField(
  543. max_length=30,
  544. choices=WirelessRoleChoices,
  545. blank=True,
  546. verbose_name=_('wireless role')
  547. )
  548. rf_channel = models.CharField(
  549. max_length=50,
  550. choices=WirelessChannelChoices,
  551. blank=True,
  552. verbose_name=_('wireless channel')
  553. )
  554. rf_channel_frequency = models.DecimalField(
  555. max_digits=7,
  556. decimal_places=2,
  557. blank=True,
  558. null=True,
  559. verbose_name=_('channel frequency (MHz)'),
  560. help_text=_("Populated by selected channel (if set)")
  561. )
  562. rf_channel_width = models.DecimalField(
  563. max_digits=7,
  564. decimal_places=3,
  565. blank=True,
  566. null=True,
  567. verbose_name=('channel width (MHz)'),
  568. help_text=_("Populated by selected channel (if set)")
  569. )
  570. tx_power = models.PositiveSmallIntegerField(
  571. blank=True,
  572. null=True,
  573. validators=(MaxValueValidator(127),),
  574. verbose_name=_('transmit power (dBm)')
  575. )
  576. poe_mode = models.CharField(
  577. max_length=50,
  578. choices=InterfacePoEModeChoices,
  579. blank=True,
  580. verbose_name=_('PoE mode')
  581. )
  582. poe_type = models.CharField(
  583. max_length=50,
  584. choices=InterfacePoETypeChoices,
  585. blank=True,
  586. verbose_name=_('PoE type')
  587. )
  588. wireless_link = models.ForeignKey(
  589. to='wireless.WirelessLink',
  590. on_delete=models.SET_NULL,
  591. related_name='+',
  592. blank=True,
  593. null=True
  594. )
  595. wireless_lans = models.ManyToManyField(
  596. to='wireless.WirelessLAN',
  597. related_name='interfaces',
  598. blank=True,
  599. verbose_name=_('wireless LANs')
  600. )
  601. untagged_vlan = models.ForeignKey(
  602. to='ipam.VLAN',
  603. on_delete=models.SET_NULL,
  604. related_name='interfaces_as_untagged',
  605. null=True,
  606. blank=True,
  607. verbose_name=_('untagged VLAN')
  608. )
  609. tagged_vlans = models.ManyToManyField(
  610. to='ipam.VLAN',
  611. related_name='interfaces_as_tagged',
  612. blank=True,
  613. verbose_name=_('tagged VLANs')
  614. )
  615. vrf = models.ForeignKey(
  616. to='ipam.VRF',
  617. on_delete=models.SET_NULL,
  618. related_name='interfaces',
  619. null=True,
  620. blank=True,
  621. verbose_name=_('VRF')
  622. )
  623. ip_addresses = GenericRelation(
  624. to='ipam.IPAddress',
  625. content_type_field='assigned_object_type',
  626. object_id_field='assigned_object_id',
  627. related_query_name='interface'
  628. )
  629. fhrp_group_assignments = GenericRelation(
  630. to='ipam.FHRPGroupAssignment',
  631. content_type_field='interface_type',
  632. object_id_field='interface_id',
  633. related_query_name='+'
  634. )
  635. tunnel_terminations = GenericRelation(
  636. to='vpn.TunnelTermination',
  637. content_type_field='termination_type',
  638. object_id_field='termination_id',
  639. related_query_name='interface'
  640. )
  641. l2vpn_terminations = GenericRelation(
  642. to='vpn.L2VPNTermination',
  643. content_type_field='assigned_object_type',
  644. object_id_field='assigned_object_id',
  645. related_query_name='interface',
  646. )
  647. clone_fields = (
  648. 'device', 'module', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'mtu', 'mode', 'speed', 'duplex', 'rf_role',
  649. 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'vrf',
  650. )
  651. class Meta(ModularComponentModel.Meta):
  652. ordering = ('device', CollateAsChar('_name'))
  653. verbose_name = _('interface')
  654. verbose_name_plural = _('interfaces')
  655. def get_absolute_url(self):
  656. return reverse('dcim:interface', kwargs={'pk': self.pk})
  657. def clean(self):
  658. super().clean()
  659. # Virtual Interfaces cannot have a Cable attached
  660. if self.is_virtual and self.cable:
  661. raise ValidationError({
  662. 'type': _("{display_type} interfaces cannot have a cable attached.").format(
  663. display_type=self.get_type_display()
  664. )
  665. })
  666. # Virtual Interfaces cannot be marked as connected
  667. if self.is_virtual and self.mark_connected:
  668. raise ValidationError({
  669. 'mark_connected': _("{display_type} interfaces cannot be marked as connected.".format(
  670. display_type=self.get_type_display())
  671. )
  672. })
  673. # Parent validation
  674. # An interface cannot be its own parent
  675. if self.pk and self.parent_id == self.pk:
  676. raise ValidationError({'parent': _("An interface cannot be its own parent.")})
  677. # A physical interface cannot have a parent interface
  678. if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
  679. raise ValidationError({'parent': _("Only virtual interfaces may be assigned to a parent interface.")})
  680. # An interface's parent must belong to the same device or virtual chassis
  681. if self.parent and self.parent.device != self.device:
  682. if self.device.virtual_chassis is None:
  683. raise ValidationError({
  684. 'parent': _(
  685. "The selected parent interface ({interface}) belongs to a different device ({device})"
  686. ).format(interface=self.parent, device=self.parent.device)
  687. })
  688. elif self.parent.device.virtual_chassis != self.parent.virtual_chassis:
  689. raise ValidationError({
  690. 'parent': _(
  691. "The selected parent interface ({interface}) belongs to {device}, which is not part of "
  692. "virtual chassis {virtual_chassis}."
  693. ).format(
  694. interface=self.parent,
  695. device=self.parent_device,
  696. virtual_chassis=self.device.virtual_chassis
  697. )
  698. })
  699. # Bridge validation
  700. # An interface cannot be bridged to itself
  701. if self.pk and self.bridge_id == self.pk:
  702. raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
  703. # A bridged interface belong to the same device or virtual chassis
  704. if self.bridge and self.bridge.device != self.device:
  705. if self.device.virtual_chassis is None:
  706. raise ValidationError({
  707. 'bridge': _(
  708. "The selected bridge interface ({bridge}) belongs to a different device ({device})."
  709. ).format(bridge=self.bridge, device=self.bridge.device)
  710. })
  711. elif self.bridge.device.virtual_chassis != self.device.virtual_chassis:
  712. raise ValidationError({
  713. 'bridge': _(
  714. "The selected bridge interface ({interface}) belongs to {device}, which is not part of virtual "
  715. "chassis {virtual_chassis}."
  716. ).format(
  717. interface=self.bridge, device=self.bridge.device, virtual_chassis=self.device.virtual_chassis
  718. )
  719. })
  720. # LAG validation
  721. # A virtual interface cannot have a parent LAG
  722. if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
  723. raise ValidationError({'lag': _("Virtual interfaces cannot have a parent LAG interface.")})
  724. # A LAG interface cannot be its own parent
  725. if self.pk and self.lag_id == self.pk:
  726. raise ValidationError({'lag': _("A LAG interface cannot be its own parent.")})
  727. # An interface's LAG must belong to the same device or virtual chassis
  728. if self.lag and self.lag.device != self.device:
  729. if self.device.virtual_chassis is None:
  730. raise ValidationError({
  731. 'lag': _(
  732. "The selected LAG interface ({lag}) belongs to a different device ({device})."
  733. ).format(lag=self.lag, device=self.lag.device)
  734. })
  735. elif self.lag.device.virtual_chassis != self.device.virtual_chassis:
  736. raise ValidationError({
  737. 'lag': _(
  738. "The selected LAG interface ({lag}) belongs to {device}, which is not part of virtual chassis "
  739. "{virtual_chassis}.".format(
  740. lag=self.lag, device=self.lag.device, virtual_chassis=self.device.virtual_chassis)
  741. )
  742. })
  743. # PoE validation
  744. # Only physical interfaces may have a PoE mode/type assigned
  745. if self.poe_mode and self.is_virtual:
  746. raise ValidationError({
  747. 'poe_mode': _("Virtual interfaces cannot have a PoE mode.")
  748. })
  749. if self.poe_type and self.is_virtual:
  750. raise ValidationError({
  751. 'poe_type': _("Virtual interfaces cannot have a PoE type.")
  752. })
  753. # An interface with a PoE type set must also specify a mode
  754. if self.poe_type and not self.poe_mode:
  755. raise ValidationError({
  756. 'poe_type': _("Must specify PoE mode when designating a PoE type.")
  757. })
  758. # Wireless validation
  759. # RF role & channel may only be set for wireless interfaces
  760. if self.rf_role and not self.is_wireless:
  761. raise ValidationError({'rf_role': _("Wireless role may be set only on wireless interfaces.")})
  762. if self.rf_channel and not self.is_wireless:
  763. raise ValidationError({'rf_channel': _("Channel may be set only on wireless interfaces.")})
  764. # Validate channel frequency against interface type and selected channel (if any)
  765. if self.rf_channel_frequency:
  766. if not self.is_wireless:
  767. raise ValidationError({
  768. 'rf_channel_frequency': _("Channel frequency may be set only on wireless interfaces."),
  769. })
  770. if self.rf_channel and self.rf_channel_frequency != get_channel_attr(self.rf_channel, 'frequency'):
  771. raise ValidationError({
  772. 'rf_channel_frequency': _("Cannot specify custom frequency with channel selected."),
  773. })
  774. # Validate channel width against interface type and selected channel (if any)
  775. if self.rf_channel_width:
  776. if not self.is_wireless:
  777. raise ValidationError({'rf_channel_width': _("Channel width may be set only on wireless interfaces.")})
  778. if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
  779. raise ValidationError({'rf_channel_width': _("Cannot specify custom width with channel selected.")})
  780. # VLAN validation
  781. # Validate untagged VLAN
  782. if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
  783. raise ValidationError({
  784. 'untagged_vlan': _(
  785. "The untagged VLAN ({untagged_vlan}) must belong to the same site as the interface's parent "
  786. "device, or it must be global."
  787. ).format(untagged_vlan=self.untagged_vlan)
  788. })
  789. def save(self, *args, **kwargs):
  790. # Set absolute channel attributes from selected options
  791. if self.rf_channel and not self.rf_channel_frequency:
  792. self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
  793. if self.rf_channel and not self.rf_channel_width:
  794. self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
  795. super().save(*args, **kwargs)
  796. @property
  797. def _occupied(self):
  798. return super()._occupied or bool(self.wireless_link_id)
  799. @property
  800. def is_wired(self):
  801. return not self.is_virtual and not self.is_wireless
  802. @property
  803. def is_virtual(self):
  804. return self.type in VIRTUAL_IFACE_TYPES
  805. @property
  806. def is_wireless(self):
  807. return self.type in WIRELESS_IFACE_TYPES
  808. @property
  809. def is_lag(self):
  810. return self.type == InterfaceTypeChoices.TYPE_LAG
  811. @property
  812. def is_bridge(self):
  813. return self.type == InterfaceTypeChoices.TYPE_BRIDGE
  814. @property
  815. def link(self):
  816. return self.cable or self.wireless_link
  817. @cached_property
  818. def link_peers(self):
  819. if self.cable:
  820. return super().link_peers
  821. if self.wireless_link:
  822. # Return the opposite side of the attached wireless link
  823. if self.wireless_link.interface_a == self:
  824. return [self.wireless_link.interface_b]
  825. else:
  826. return [self.wireless_link.interface_a]
  827. return []
  828. @property
  829. def l2vpn_termination(self):
  830. return self.l2vpn_terminations.first()
  831. #
  832. # Pass-through ports
  833. #
  834. class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
  835. """
  836. A pass-through port on the front of a Device.
  837. """
  838. type = models.CharField(
  839. verbose_name=_('type'),
  840. max_length=50,
  841. choices=PortTypeChoices
  842. )
  843. color = ColorField(
  844. verbose_name=_('color'),
  845. blank=True
  846. )
  847. rear_port = models.ForeignKey(
  848. to='dcim.RearPort',
  849. on_delete=models.CASCADE,
  850. related_name='frontports'
  851. )
  852. rear_port_position = models.PositiveSmallIntegerField(
  853. verbose_name=_('rear port position'),
  854. default=1,
  855. validators=[
  856. MinValueValidator(REARPORT_POSITIONS_MIN),
  857. MaxValueValidator(REARPORT_POSITIONS_MAX)
  858. ],
  859. help_text=_('Mapped position on corresponding rear port')
  860. )
  861. clone_fields = ('device', 'type', 'color')
  862. class Meta(ModularComponentModel.Meta):
  863. constraints = (
  864. models.UniqueConstraint(
  865. fields=('device', 'name'),
  866. name='%(app_label)s_%(class)s_unique_device_name'
  867. ),
  868. models.UniqueConstraint(
  869. fields=('rear_port', 'rear_port_position'),
  870. name='%(app_label)s_%(class)s_unique_rear_port_position'
  871. ),
  872. )
  873. verbose_name = _('front port')
  874. verbose_name_plural = _('front ports')
  875. def get_absolute_url(self):
  876. return reverse('dcim:frontport', kwargs={'pk': self.pk})
  877. def clean(self):
  878. super().clean()
  879. if hasattr(self, 'rear_port'):
  880. # Validate rear port assignment
  881. if self.rear_port.device != self.device:
  882. raise ValidationError({
  883. "rear_port": _(
  884. "Rear port ({rear_port}) must belong to the same device"
  885. ).format(rear_port=self.rear_port)
  886. })
  887. # Validate rear port position assignment
  888. if self.rear_port_position > self.rear_port.positions:
  889. raise ValidationError({
  890. "rear_port_position": _(
  891. "Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} "
  892. "positions."
  893. ).format(
  894. rear_port_position=self.rear_port_position,
  895. name=self.rear_port.name,
  896. positions=self.rear_port.positions
  897. )
  898. })
  899. class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
  900. """
  901. A pass-through port on the rear of a Device.
  902. """
  903. type = models.CharField(
  904. verbose_name=_('type'),
  905. max_length=50,
  906. choices=PortTypeChoices
  907. )
  908. color = ColorField(
  909. verbose_name=_('color'),
  910. blank=True
  911. )
  912. positions = models.PositiveSmallIntegerField(
  913. verbose_name=_('positions'),
  914. default=1,
  915. validators=[
  916. MinValueValidator(REARPORT_POSITIONS_MIN),
  917. MaxValueValidator(REARPORT_POSITIONS_MAX)
  918. ],
  919. help_text=_('Number of front ports which may be mapped')
  920. )
  921. clone_fields = ('device', 'type', 'color', 'positions')
  922. class Meta(ModularComponentModel.Meta):
  923. verbose_name = _('rear port')
  924. verbose_name_plural = _('rear ports')
  925. def get_absolute_url(self):
  926. return reverse('dcim:rearport', kwargs={'pk': self.pk})
  927. def clean(self):
  928. super().clean()
  929. # Check that positions count is greater than or equal to the number of associated FrontPorts
  930. if not self._state.adding:
  931. frontport_count = self.frontports.count()
  932. if self.positions < frontport_count:
  933. raise ValidationError({
  934. "positions": _(
  935. "The number of positions cannot be less than the number of mapped front ports "
  936. "({frontport_count})"
  937. ).format(frontport_count=frontport_count)
  938. })
  939. #
  940. # Bays
  941. #
  942. class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
  943. """
  944. An empty space within a Device which can house a child device
  945. """
  946. parent = TreeForeignKey(
  947. to='self',
  948. on_delete=models.CASCADE,
  949. related_name='children',
  950. blank=True,
  951. null=True,
  952. editable=False,
  953. db_index=True
  954. )
  955. position = models.CharField(
  956. verbose_name=_('position'),
  957. max_length=30,
  958. blank=True,
  959. help_text=_('Identifier to reference when renaming installed components')
  960. )
  961. objects = TreeManager()
  962. clone_fields = ('device',)
  963. class Meta(ModularComponentModel.Meta):
  964. constraints = (
  965. models.UniqueConstraint(
  966. fields=('device', 'module', 'name'),
  967. name='%(app_label)s_%(class)s_unique_device_module_name'
  968. ),
  969. )
  970. verbose_name = _('module bay')
  971. verbose_name_plural = _('module bays')
  972. class MPTTMeta:
  973. order_insertion_by = ('module',)
  974. def get_absolute_url(self):
  975. return reverse('dcim:modulebay', kwargs={'pk': self.pk})
  976. def clean(self):
  977. super().clean()
  978. # Check for recursion
  979. if module := self.module:
  980. module_bays = [self.pk]
  981. modules = []
  982. while module:
  983. if module.pk in modules or module.module_bay.pk in module_bays:
  984. raise ValidationError(_("A module bay cannot belong to a module installed within it."))
  985. modules.append(module.pk)
  986. module_bays.append(module.module_bay.pk)
  987. module = module.module_bay.module if module.module_bay else None
  988. def save(self, *args, **kwargs):
  989. if self.module:
  990. self.parent = self.module.module_bay
  991. super().save(*args, **kwargs)
  992. class DeviceBay(ComponentModel, TrackingModelMixin):
  993. """
  994. An empty space within a Device which can house a child device
  995. """
  996. installed_device = models.OneToOneField(
  997. to='dcim.Device',
  998. on_delete=models.SET_NULL,
  999. related_name='parent_bay',
  1000. blank=True,
  1001. null=True
  1002. )
  1003. clone_fields = ('device',)
  1004. class Meta(ComponentModel.Meta):
  1005. verbose_name = _('device bay')
  1006. verbose_name_plural = _('device bays')
  1007. def get_absolute_url(self):
  1008. return reverse('dcim:devicebay', kwargs={'pk': self.pk})
  1009. def clean(self):
  1010. super().clean()
  1011. # Validate that the parent Device can have DeviceBays
  1012. if hasattr(self, 'device') and not self.device.device_type.is_parent_device:
  1013. raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format(
  1014. device_type=self.device.device_type
  1015. ))
  1016. # Cannot install a device into itself, obviously
  1017. if self.installed_device and getattr(self, 'device', None) == self.installed_device:
  1018. raise ValidationError(_("Cannot install a device into itself."))
  1019. # Check that the installed device is not already installed elsewhere
  1020. if self.installed_device:
  1021. current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
  1022. if current_bay and current_bay != self:
  1023. raise ValidationError({
  1024. 'installed_device': _(
  1025. "Cannot install the specified device; device is already installed in {bay}."
  1026. ).format(bay=current_bay)
  1027. })
  1028. #
  1029. # Inventory items
  1030. #
  1031. class InventoryItemRole(OrganizationalModel):
  1032. """
  1033. Inventory items may optionally be assigned a functional role.
  1034. """
  1035. color = ColorField(
  1036. verbose_name=_('color'),
  1037. default=ColorChoices.COLOR_GREY
  1038. )
  1039. class Meta:
  1040. ordering = ('name',)
  1041. verbose_name = _('inventory item role')
  1042. verbose_name_plural = _('inventory item roles')
  1043. def get_absolute_url(self):
  1044. return reverse('dcim:inventoryitemrole', args=[self.pk])
  1045. class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
  1046. """
  1047. An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
  1048. InventoryItems are used only for inventory purposes.
  1049. """
  1050. parent = TreeForeignKey(
  1051. to='self',
  1052. on_delete=models.CASCADE,
  1053. related_name='child_items',
  1054. blank=True,
  1055. null=True,
  1056. db_index=True
  1057. )
  1058. component_type = models.ForeignKey(
  1059. to='contenttypes.ContentType',
  1060. limit_choices_to=MODULAR_COMPONENT_MODELS,
  1061. on_delete=models.PROTECT,
  1062. related_name='+',
  1063. blank=True,
  1064. null=True
  1065. )
  1066. component_id = models.PositiveBigIntegerField(
  1067. blank=True,
  1068. null=True
  1069. )
  1070. component = GenericForeignKey(
  1071. ct_field='component_type',
  1072. fk_field='component_id'
  1073. )
  1074. role = models.ForeignKey(
  1075. to='dcim.InventoryItemRole',
  1076. on_delete=models.PROTECT,
  1077. related_name='inventory_items',
  1078. blank=True,
  1079. null=True
  1080. )
  1081. manufacturer = models.ForeignKey(
  1082. to='dcim.Manufacturer',
  1083. on_delete=models.PROTECT,
  1084. related_name='inventory_items',
  1085. blank=True,
  1086. null=True
  1087. )
  1088. part_id = models.CharField(
  1089. max_length=50,
  1090. verbose_name=_('part ID'),
  1091. blank=True,
  1092. help_text=_('Manufacturer-assigned part identifier')
  1093. )
  1094. serial = models.CharField(
  1095. max_length=50,
  1096. verbose_name=_('serial number'),
  1097. blank=True
  1098. )
  1099. asset_tag = models.CharField(
  1100. max_length=50,
  1101. unique=True,
  1102. blank=True,
  1103. null=True,
  1104. verbose_name=_('asset tag'),
  1105. help_text=_('A unique tag used to identify this item')
  1106. )
  1107. discovered = models.BooleanField(
  1108. verbose_name=_('discovered'),
  1109. default=False,
  1110. help_text=_('This item was automatically discovered')
  1111. )
  1112. objects = TreeManager()
  1113. clone_fields = ('device', 'parent', 'role', 'manufacturer', 'part_id',)
  1114. class Meta:
  1115. ordering = ('device__id', 'parent__id', '_name')
  1116. indexes = (
  1117. models.Index(fields=('component_type', 'component_id')),
  1118. )
  1119. constraints = (
  1120. models.UniqueConstraint(
  1121. fields=('device', 'parent', 'name'),
  1122. name='%(app_label)s_%(class)s_unique_device_parent_name'
  1123. ),
  1124. )
  1125. verbose_name = _('inventory item')
  1126. verbose_name_plural = _('inventory items')
  1127. def get_absolute_url(self):
  1128. return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})
  1129. def clean(self):
  1130. super().clean()
  1131. # An InventoryItem cannot be its own parent
  1132. if self.pk and self.parent_id == self.pk:
  1133. raise ValidationError({
  1134. "parent": _("Cannot assign self as parent.")
  1135. })
  1136. # Validation for moving InventoryItems
  1137. if not self._state.adding:
  1138. # Cannot move an InventoryItem to another device if it has a parent
  1139. if self.parent and self.parent.device != self.device:
  1140. raise ValidationError({
  1141. "parent": _("Parent inventory item does not belong to the same device.")
  1142. })
  1143. # Prevent moving InventoryItems with children
  1144. first_child = self.get_children().first()
  1145. if first_child and first_child.device != self.device:
  1146. raise ValidationError(_("Cannot move an inventory item with dependent children"))
  1147. # When moving an InventoryItem to another device, remove any associated component
  1148. if self.component and self.component.device != self.device:
  1149. self.component = None
  1150. else:
  1151. if self.component and self.component.device != self.device:
  1152. raise ValidationError({
  1153. "device": _("Cannot assign inventory item to component on another device")
  1154. })