device_components.py 69 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937
  1. from functools import cached_property
  2. from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
  3. from django.contrib.postgres.fields import ArrayField
  4. from django.contrib.postgres.indexes import GistIndex
  5. from django.core.exceptions import ObjectDoesNotExist, ValidationError
  6. from django.core.validators import MaxValueValidator, MinValueValidator
  7. from django.db import models, router, transaction
  8. from django.utils.translation import gettext_lazy as _
  9. from dcim.choices import *
  10. from dcim.constants import *
  11. from dcim.fields import WWNField
  12. from dcim.models.base import PortMappingBase
  13. from dcim.models.mixins import (
  14. CoolingLoopValidationMixin,
  15. DiameterMixin,
  16. InterfaceChannelRenameMixin,
  17. InterfaceValidationMixin,
  18. MaxFlowMixin,
  19. )
  20. from netbox.choices import ColorChoices
  21. from netbox.models import NetBoxModel, OrganizationalModel
  22. from netbox.models.features import ChangeLoggingMixin
  23. from netbox.models.ltree import LtreeManager, LtreeModel, SortPathField
  24. from netbox.models.mixins import OwnerMixin
  25. from utilities.fields import ColorField, NaturalOrderingField
  26. from utilities.ordering import naturalize_interface
  27. from utilities.query_functions import CollateAsChar
  28. from utilities.tracking import TrackingModelMixin
  29. from wireless.choices import *
  30. from wireless.utils import get_channel_attr
  31. __all__ = (
  32. 'BaseInterface',
  33. 'CabledObjectModel',
  34. 'ConsolePort',
  35. 'ConsoleServerPort',
  36. 'CoolingIntake',
  37. 'CoolingOutflow',
  38. 'DeviceBay',
  39. 'FrontPort',
  40. 'Interface',
  41. 'InventoryItem',
  42. 'InventoryItemRole',
  43. 'ModuleBay',
  44. 'PathEndpoint',
  45. 'PortMapping',
  46. 'PowerOutlet',
  47. 'PowerPort',
  48. 'RearPort',
  49. )
  50. class ComponentModel(OwnerMixin, NetBoxModel):
  51. """
  52. An abstract model inherited by any model which has a parent Device.
  53. """
  54. device = models.ForeignKey(
  55. to='dcim.Device',
  56. on_delete=models.CASCADE,
  57. related_name='%(class)ss'
  58. )
  59. name = models.CharField(
  60. verbose_name=_('name'),
  61. max_length=64,
  62. db_collation="natural_sort"
  63. )
  64. label = models.CharField(
  65. verbose_name=_('label'),
  66. max_length=64,
  67. blank=True,
  68. help_text=_('Physical label')
  69. )
  70. description = models.CharField(
  71. verbose_name=_('description'),
  72. max_length=200,
  73. blank=True
  74. )
  75. # Denormalized references replicated from the parent Device
  76. _site = models.ForeignKey(
  77. to='dcim.Site',
  78. on_delete=models.SET_NULL,
  79. related_name='+',
  80. blank=True,
  81. null=True,
  82. )
  83. _location = models.ForeignKey(
  84. to='dcim.Location',
  85. on_delete=models.SET_NULL,
  86. related_name='+',
  87. blank=True,
  88. null=True,
  89. )
  90. _rack = models.ForeignKey(
  91. to='dcim.Rack',
  92. on_delete=models.SET_NULL,
  93. related_name='+',
  94. blank=True,
  95. null=True,
  96. )
  97. class Meta:
  98. abstract = True
  99. ordering = ('device', 'name')
  100. constraints = (
  101. models.UniqueConstraint(
  102. fields=('device', 'name'),
  103. name='%(app_label)s_%(class)s_unique_device_name'
  104. ),
  105. )
  106. def __init__(self, *args, **kwargs):
  107. super().__init__(*args, **kwargs)
  108. # Cache the original Device ID for reference under clean()
  109. self._original_device = self.__dict__.get('device_id')
  110. def __str__(self):
  111. if self.label:
  112. return f"{self.name} ({self.label})"
  113. return self.name
  114. def to_objectchange(self, action):
  115. objectchange = super().to_objectchange(action)
  116. objectchange.related_object = self.device
  117. return objectchange
  118. def clean(self):
  119. super().clean()
  120. # Check list of Modules that allow device field to be changed
  121. if (type(self) not in [InventoryItem]) and (self.pk is not None) and (self._original_device != self.device_id):
  122. raise ValidationError({
  123. "device": _("Components cannot be moved to a different device.")
  124. })
  125. def save(self, *args, **kwargs):
  126. # Save denormalized references
  127. self._site = self.device.site
  128. self._location = self.device.location
  129. self._rack = self.device.rack
  130. super().save(*args, **kwargs)
  131. @property
  132. def parent_object(self):
  133. return self.device
  134. class ModularComponentModel(ComponentModel):
  135. module = models.ForeignKey(
  136. to='dcim.Module',
  137. on_delete=models.CASCADE,
  138. related_name='%(class)ss',
  139. blank=True,
  140. null=True
  141. )
  142. inventory_items = GenericRelation(
  143. to='dcim.InventoryItem',
  144. content_type_field='component_type',
  145. object_id_field='component_id'
  146. )
  147. class Meta(ComponentModel.Meta):
  148. abstract = True
  149. class CabledObjectModel(models.Model):
  150. """
  151. An abstract model inherited by all models to which a Cable can terminate. Provides the `cable` and `cable_end`
  152. fields for caching cable associations, as well as `mark_connected` to designate "fake" connections.
  153. """
  154. cable = models.ForeignKey(
  155. to='dcim.Cable',
  156. on_delete=models.SET_NULL,
  157. related_name='+',
  158. blank=True,
  159. null=True
  160. )
  161. cable_end = models.CharField(
  162. verbose_name=_('cable end'),
  163. max_length=1,
  164. choices=CableEndChoices,
  165. blank=True,
  166. null=True
  167. )
  168. cable_connector = models.PositiveSmallIntegerField(
  169. blank=True,
  170. null=True,
  171. validators=(
  172. MinValueValidator(CABLE_CONNECTOR_MIN),
  173. MaxValueValidator(CABLE_CONNECTOR_MAX)
  174. ),
  175. )
  176. cable_positions = ArrayField(
  177. base_field=models.PositiveSmallIntegerField(
  178. validators=(
  179. MinValueValidator(CABLE_POSITION_MIN),
  180. MaxValueValidator(CABLE_POSITION_MAX)
  181. )
  182. ),
  183. blank=True,
  184. null=True,
  185. )
  186. mark_connected = models.BooleanField(
  187. verbose_name=_('mark connected'),
  188. default=False,
  189. help_text=_('Treat as if a cable is connected')
  190. )
  191. cable_terminations = GenericRelation(
  192. to='dcim.CableTermination',
  193. content_type_field='termination_type',
  194. object_id_field='termination_id',
  195. related_query_name='%(class)s',
  196. )
  197. class Meta:
  198. abstract = True
  199. def clean(self):
  200. super().clean()
  201. if self.cable:
  202. if not self.cable_end:
  203. raise ValidationError({
  204. "cable_end": _("Must specify cable end (A or B) when attaching a cable.")
  205. })
  206. if self.cable_connector and not self.cable_positions:
  207. raise ValidationError({
  208. "cable_positions": _("Must specify position(s) when specifying a cable connector.")
  209. })
  210. if self.cable_positions and not self.cable_connector:
  211. raise ValidationError({
  212. "cable_positions": _("Cable positions cannot be set without a cable connector.")
  213. })
  214. if self.mark_connected:
  215. raise ValidationError({
  216. "mark_connected": _("Cannot mark as connected with a cable attached.")
  217. })
  218. else:
  219. if self.cable_end:
  220. raise ValidationError({
  221. "cable_end": _("Cable end must not be set without a cable.")
  222. })
  223. if self.cable_connector:
  224. raise ValidationError({
  225. "cable_connector": _("Cable connector must not be set without a cable.")
  226. })
  227. if self.cable_positions:
  228. raise ValidationError({
  229. "cable_positions": _("Cable termination positions must not be set without a cable.")
  230. })
  231. @property
  232. def link(self):
  233. """
  234. Generic wrapper for a Cable, WirelessLink, or some other relation to a connected termination.
  235. """
  236. return self.cable
  237. @cached_property
  238. def link_peers(self):
  239. if not self.cable:
  240. return []
  241. if self.cable.profile:
  242. return self._get_profile_link_peers()
  243. return [peer.termination for peer in self.cable.terminations.all() if peer.cable_end != self.cable_end]
  244. def _get_profile_link_peers(self):
  245. if self.cable_end is None or self.cable_connector is None or not self.cable_positions:
  246. return []
  247. profile = self.cable.profile_class()
  248. peer_terminations = {
  249. (peer.connector, position): peer.termination
  250. for peer in self.cable.terminations.all()
  251. if peer.cable_end == self.opposite_cable_end and peer.connector is not None
  252. for position in peer.positions or []
  253. }
  254. link_peers = []
  255. for position in self.cable_positions:
  256. mapped_position = profile.get_mapped_position(self.cable_end, self.cable_connector, position)
  257. if mapped_position is None:
  258. continue
  259. peer = peer_terminations.get(mapped_position)
  260. if peer is not None and peer not in link_peers:
  261. link_peers.append(peer)
  262. return link_peers
  263. @property
  264. def _occupied(self):
  265. return bool(self.mark_connected or self.cable_id)
  266. @property
  267. def parent_object(self):
  268. raise NotImplementedError(
  269. _("{class_name} models must declare a parent_object property").format(class_name=self.__class__.__name__)
  270. )
  271. @property
  272. def opposite_cable_end(self):
  273. if not self.cable_end:
  274. return None
  275. return CableEndChoices.SIDE_A if self.cable_end == CableEndChoices.SIDE_B else CableEndChoices.SIDE_B
  276. def set_cable_termination(self, termination):
  277. """Save attributes from the given CableTermination on the terminating object."""
  278. self.cable = termination.cable
  279. self.cable_end = termination.cable_end
  280. self.cable_connector = termination.connector
  281. self.cable_positions = termination.positions
  282. set_cable_termination.alters_data = True
  283. def clear_cable_termination(self, termination):
  284. """Clear all cable termination attributes from the terminating object."""
  285. self.cable = None
  286. self.cable_end = None
  287. self.cable_connector = None
  288. self.cable_positions = None
  289. clear_cable_termination.alters_data = True
  290. class PathEndpoint(models.Model):
  291. """
  292. An abstract model inherited by any CabledObjectModel subclass which represents the end of a CablePath; specifically,
  293. these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed.
  294. `_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in
  295. dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
  296. CablePath model. `_path` should not be accessed directly; rather, use the `path` property.
  297. `connected_endpoints()` is a convenience method for returning the destination of the associated CablePath, if any.
  298. """
  299. _path = models.ForeignKey(
  300. to='dcim.CablePath',
  301. on_delete=models.SET_NULL,
  302. null=True,
  303. blank=True,
  304. )
  305. class Meta:
  306. abstract = True
  307. def trace(self):
  308. origin = self
  309. path = []
  310. # Construct the complete path (including e.g. bridged interfaces)
  311. while origin is not None:
  312. # Go through the public accessor rather than dereferencing `_path`
  313. # directly. During cable edits, CablePath rows can be deleted and
  314. # recreated while this endpoint instance is still in memory.
  315. cable_path = origin.path
  316. if cable_path is None:
  317. break
  318. path.extend(cable_path.path_objects)
  319. # If the path ends at a non-connected pass-through port, pad out the link and far-end terminations
  320. if len(path) % 3 == 1:
  321. path.extend(([], []))
  322. # If the path ends at a site or provider network, inject a null "link" to render an attachment
  323. elif len(path) % 3 == 2:
  324. path.insert(-1, [])
  325. # Check for a bridged relationship to continue the trace.
  326. destinations = cable_path.destinations
  327. if len(destinations) == 1:
  328. origin = getattr(destinations[0], 'bridge', None)
  329. else:
  330. origin = None
  331. # Return the path as a list of three-tuples (A termination(s), cable(s), B termination(s))
  332. return list(zip(*[iter(path)] * 3))
  333. @property
  334. def path(self):
  335. """
  336. Return this endpoint's current CablePath, if any.
  337. `_path` is a denormalized reference that is updated from CablePath
  338. save/delete handlers, including queryset.update() calls on origin
  339. endpoints. That means an already-instantiated endpoint can briefly hold
  340. a stale in-memory `_path` relation while the database already points to
  341. a different CablePath (or to no path at all).
  342. Two stale cases are repaired by refreshing only the `_path` field
  343. from the database:
  344. 1. The endpoint is linked (by cable or wireless link) but `_path` is
  345. unset, because the instance was loaded before its path was traced
  346. (e.g. while queued for event serialization during link creation).
  347. 2. The cached relation points to a CablePath row that has just been
  348. deleted.
  349. Repairing case 1 costs one query per access for a linked endpoint
  350. whose path is genuinely absent in the database. That state is
  351. transient outside of tracing failures, so no result caching is
  352. attempted here.
  353. """
  354. if self._path_id is None:
  355. has_link = self.cable_id is not None or getattr(self, 'wireless_link_id', None) is not None
  356. if self.pk and has_link:
  357. self.refresh_from_db(fields=['_path'])
  358. if self._path_id is None:
  359. return None
  360. try:
  361. return self._path
  362. except ObjectDoesNotExist:
  363. # Refresh only the denormalized FK instead of the whole model.
  364. # The expected problem here is in-memory staleness during path
  365. # rebuilds, not persistent database corruption.
  366. self.refresh_from_db(fields=['_path'])
  367. return self._path if self._path_id else None
  368. @cached_property
  369. def connected_endpoints(self):
  370. """
  371. Caching accessor for the attached CablePath's destinations (if any).
  372. Always route through `path` so stale in-memory `_path` references are
  373. repaired before we cache the result for the lifetime of this instance.
  374. """
  375. if cable_path := self.path:
  376. return cable_path.destinations
  377. return []
  378. #
  379. # Console components
  380. #
  381. class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
  382. """
  383. A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
  384. """
  385. type = models.CharField(
  386. verbose_name=_('type'),
  387. max_length=50,
  388. choices=ConsolePortTypeChoices,
  389. blank=True,
  390. null=True,
  391. help_text=_('Physical port type')
  392. )
  393. speed = models.PositiveIntegerField(
  394. verbose_name=_('speed'),
  395. choices=ConsolePortSpeedChoices,
  396. blank=True,
  397. null=True,
  398. help_text=_('Port speed in bits per second')
  399. )
  400. clone_fields = ('device', 'module', 'type', 'speed')
  401. class Meta(ModularComponentModel.Meta):
  402. verbose_name = _('console port')
  403. verbose_name_plural = _('console ports')
  404. class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
  405. """
  406. A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
  407. """
  408. type = models.CharField(
  409. verbose_name=_('type'),
  410. max_length=50,
  411. choices=ConsolePortTypeChoices,
  412. blank=True,
  413. null=True,
  414. help_text=_('Physical port type')
  415. )
  416. speed = models.PositiveIntegerField(
  417. verbose_name=_('speed'),
  418. choices=ConsolePortSpeedChoices,
  419. blank=True,
  420. null=True,
  421. help_text=_('Port speed in bits per second')
  422. )
  423. clone_fields = ('device', 'module', 'type', 'speed')
  424. class Meta(ModularComponentModel.Meta):
  425. verbose_name = _('console server port')
  426. verbose_name_plural = _('console server ports')
  427. #
  428. # Power components
  429. #
  430. class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
  431. """
  432. A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
  433. """
  434. type = models.CharField(
  435. verbose_name=_('type'),
  436. max_length=50,
  437. choices=PowerPortTypeChoices,
  438. blank=True,
  439. null=True,
  440. help_text=_('Physical port type')
  441. )
  442. maximum_draw = models.PositiveIntegerField(
  443. verbose_name=_('maximum draw'),
  444. blank=True,
  445. null=True,
  446. validators=[MinValueValidator(1)],
  447. help_text=_("Maximum power draw (watts)")
  448. )
  449. allocated_draw = models.PositiveIntegerField(
  450. verbose_name=_('allocated draw'),
  451. blank=True,
  452. null=True,
  453. validators=[MinValueValidator(1)],
  454. help_text=_('Allocated power draw (watts)')
  455. )
  456. clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw')
  457. class Meta(ModularComponentModel.Meta):
  458. verbose_name = _('power port')
  459. verbose_name_plural = _('power ports')
  460. def clean(self):
  461. super().clean()
  462. if self.maximum_draw is not None and self.allocated_draw is not None:
  463. if self.allocated_draw > self.maximum_draw:
  464. raise ValidationError({
  465. 'allocated_draw': _(
  466. "Allocated draw cannot exceed the maximum draw ({maximum_draw}W)."
  467. ).format(maximum_draw=self.maximum_draw)
  468. })
  469. def get_downstream_powerports(self, leg=None):
  470. """
  471. Return a queryset of all PowerPorts connected via cable to a child PowerOutlet. For example, in the topology
  472. below, PP1.get_downstream_powerports() would return PP2-4.
  473. ---- PO1 <---> PP2
  474. /
  475. PP1 ------- PO2 <---> PP3
  476. \
  477. ---- PO3 <---> PP4
  478. """
  479. poweroutlets = self.poweroutlets.filter(cable__isnull=False)
  480. if leg:
  481. poweroutlets = poweroutlets.filter(feed_leg=leg)
  482. if not poweroutlets:
  483. return PowerPort.objects.none()
  484. q = Q()
  485. for poweroutlet in poweroutlets:
  486. q |= Q(
  487. cable=poweroutlet.cable,
  488. cable_end=poweroutlet.opposite_cable_end
  489. )
  490. return PowerPort.objects.filter(q)
  491. def get_power_draw(self, _seen=None):
  492. """
  493. Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
  494. """
  495. from dcim.models import PowerFeed
  496. # Calculate aggregate draw of all child power outlets if no numbers have been defined manually
  497. if self.allocated_draw is None and self.maximum_draw is None:
  498. def _aggregate(powerports, seen):
  499. # Recursively resolve the draw for each downstream PowerPort. Using the per-port value
  500. # (rather than a SQL aggregate over allocated_draw/maximum_draw) allows the draw to
  501. # propagate through intermediate auto-mode PowerPorts, e.g. PDU-internal fuse chains.
  502. # `seen` tracks visited PowerPorts to prevent infinite recursion if the topology
  503. # happens to form a cycle.
  504. allocated_total = 0
  505. maximum_total = 0
  506. for powerport in powerports:
  507. if powerport.pk in seen:
  508. continue
  509. seen.add(powerport.pk)
  510. draw = powerport.get_power_draw(_seen=seen)
  511. allocated_total += draw['allocated']
  512. maximum_total += draw['maximum']
  513. return allocated_total, maximum_total
  514. # Seed each _aggregate() call with a fresh copy of the inherited visited set so the full
  515. # and per-leg aggregations are independent. Otherwise, ports visited during the full
  516. # aggregation would be skipped during the per-leg passes.
  517. base_seen = set(_seen) if _seen else set()
  518. base_seen.add(self.pk)
  519. allocated, maximum = _aggregate(self.get_downstream_powerports(), set(base_seen))
  520. ret = {
  521. 'allocated': allocated,
  522. 'maximum': maximum,
  523. 'outlet_count': self.poweroutlets.count(),
  524. 'legs': [],
  525. }
  526. # Calculate per-leg aggregates for three-phase power feeds
  527. if len(self.link_peers) == 1 and isinstance(self.link_peers[0], PowerFeed) and \
  528. self.link_peers[0].phase == PowerFeedPhaseChoices.PHASE_3PHASE:
  529. for leg, leg_name in PowerOutletFeedLegChoices:
  530. leg_allocated, leg_maximum = _aggregate(
  531. self.get_downstream_powerports(leg=leg), set(base_seen)
  532. )
  533. ret['legs'].append({
  534. 'name': leg_name,
  535. 'allocated': leg_allocated,
  536. 'maximum': leg_maximum,
  537. 'outlet_count': self.poweroutlets.filter(feed_leg=leg).count(),
  538. })
  539. return ret
  540. # Default to administratively defined values
  541. return {
  542. 'allocated': self.allocated_draw or 0,
  543. 'maximum': self.maximum_draw or 0,
  544. 'outlet_count': self.poweroutlets.count(),
  545. 'legs': [],
  546. }
  547. class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
  548. """
  549. A physical power outlet (output) within a Device which provides power to a PowerPort.
  550. """
  551. status = models.CharField(
  552. verbose_name=_('status'),
  553. max_length=50,
  554. choices=PowerOutletStatusChoices,
  555. default=PowerOutletStatusChoices.STATUS_ENABLED
  556. )
  557. type = models.CharField(
  558. verbose_name=_('type'),
  559. max_length=50,
  560. choices=PowerOutletTypeChoices,
  561. blank=True,
  562. null=True,
  563. help_text=_('Physical port type')
  564. )
  565. power_port = models.ForeignKey(
  566. to='dcim.PowerPort',
  567. on_delete=models.SET_NULL,
  568. blank=True,
  569. null=True,
  570. related_name='poweroutlets'
  571. )
  572. feed_leg = models.CharField(
  573. verbose_name=_('feed leg'),
  574. max_length=50,
  575. choices=PowerOutletFeedLegChoices,
  576. blank=True,
  577. null=True,
  578. help_text=_('Phase (for three-phase feeds)')
  579. )
  580. color = ColorField(
  581. verbose_name=_('color'),
  582. blank=True
  583. )
  584. clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg')
  585. class Meta(ModularComponentModel.Meta):
  586. verbose_name = _('power outlet')
  587. verbose_name_plural = _('power outlets')
  588. def clean(self):
  589. super().clean()
  590. # Validate power port assignment
  591. if self.power_port and self.power_port.device != self.device:
  592. raise ValidationError(
  593. _("Parent power port ({power_port}) must belong to the same device").format(power_port=self.power_port)
  594. )
  595. def get_status_color(self):
  596. return PowerOutletStatusChoices.colors.get(self.status)
  597. #
  598. # Cooling components
  599. #
  600. class CoolingIntake(
  601. CoolingLoopValidationMixin, DiameterMixin, MaxFlowMixin, ModularComponentModel, TrackingModelMixin
  602. ):
  603. """
  604. A coolant intake port within a Device (e.g. a server cold-plate inlet or CDU intake). A
  605. CoolingIntake is supplied by an upstream CoolingOutflow. The serving CoolingFeed is
  606. derived from the Device's Rack rather than referenced directly.
  607. Unlike CoolingOutflow (whose parent intake is on the same Device and can therefore be
  608. templated), an intake's upstream outflow typically lives on a different Device (e.g. a
  609. CDU), so there is deliberately no upstream-outflow field on CoolingIntakeTemplate.
  610. """
  611. type = models.CharField(
  612. verbose_name=_('type'),
  613. max_length=50,
  614. choices=CoolingConnectorTypeChoices,
  615. blank=True,
  616. null=True,
  617. help_text=_('Physical connector type')
  618. )
  619. # diameter, diameter_unit, _abs_diameter provided by DiameterMixin
  620. # max_flow, max_flow_unit, _abs_max_flow provided by MaxFlowMixin
  621. cooling_outflow = models.ForeignKey(
  622. to='dcim.CoolingOutflow',
  623. on_delete=models.SET_NULL,
  624. blank=True,
  625. null=True,
  626. related_name='coolingintakes',
  627. help_text=_('The upstream cooling outflow supplying this intake')
  628. )
  629. clone_fields = (
  630. 'device', 'module', 'type', 'diameter', 'diameter_unit', 'max_flow',
  631. 'max_flow_unit',
  632. )
  633. upstream_field = 'cooling_outflow'
  634. class Meta(ModularComponentModel.Meta):
  635. verbose_name = _('cooling intake')
  636. verbose_name_plural = _('cooling intakes')
  637. def clean(self):
  638. super().clean()
  639. # Prevent the intake/outflow chain from forming a loop
  640. self.validate_cooling_loop()
  641. class CoolingOutflow(CoolingLoopValidationMixin, DiameterMixin, ModularComponentModel, TrackingModelMixin):
  642. """
  643. A coolant outlet within a Device (e.g. a CDU or manifold outlet) which supplies one or more
  644. CoolingIntakes (referenced via CoolingIntake.cooling_outflow).
  645. """
  646. type = models.CharField(
  647. verbose_name=_('type'),
  648. max_length=50,
  649. choices=CoolingConnectorTypeChoices,
  650. blank=True,
  651. null=True,
  652. help_text=_('Physical connector type')
  653. )
  654. # diameter, diameter_unit, _abs_diameter provided by DiameterMixin
  655. cooling_intake = models.ForeignKey(
  656. to='dcim.CoolingIntake',
  657. on_delete=models.SET_NULL,
  658. blank=True,
  659. null=True,
  660. related_name='coolingoutflows'
  661. )
  662. clone_fields = ('device', 'module', 'type', 'diameter', 'diameter_unit', 'cooling_intake')
  663. upstream_field = 'cooling_intake'
  664. class Meta(ModularComponentModel.Meta):
  665. verbose_name = _('cooling outflow')
  666. verbose_name_plural = _('cooling outflows')
  667. def clean(self):
  668. super().clean()
  669. # Validate cooling intake assignment
  670. if self.cooling_intake and self.cooling_intake.device != self.device:
  671. raise ValidationError(
  672. _("Parent cooling intake ({cooling_intake}) must belong to the same device").format(
  673. cooling_intake=self.cooling_intake)
  674. )
  675. # Prevent the intake/outflow chain from forming a loop
  676. self.validate_cooling_loop()
  677. #
  678. # Interfaces
  679. #
  680. class BaseInterface(models.Model):
  681. """
  682. Abstract base class for fields shared by dcim.Interface and virtualization.VMInterface.
  683. """
  684. enabled = models.BooleanField(
  685. verbose_name=_('enabled'),
  686. default=True
  687. )
  688. mtu = models.PositiveIntegerField(
  689. blank=True,
  690. null=True,
  691. validators=[
  692. MinValueValidator(INTERFACE_MTU_MIN),
  693. MaxValueValidator(INTERFACE_MTU_MAX)
  694. ],
  695. verbose_name=_('MTU')
  696. )
  697. mode = models.CharField(
  698. verbose_name=_('mode'),
  699. max_length=50,
  700. choices=InterfaceModeChoices,
  701. blank=True,
  702. null=True,
  703. help_text=_('IEEE 802.1Q tagging strategy')
  704. )
  705. parent = models.ForeignKey(
  706. to='self',
  707. on_delete=models.RESTRICT,
  708. related_name='child_interfaces',
  709. null=True,
  710. blank=True,
  711. verbose_name=_('parent interface')
  712. )
  713. bridge = models.ForeignKey(
  714. to='self',
  715. on_delete=models.SET_NULL,
  716. related_name='bridge_interfaces',
  717. null=True,
  718. blank=True,
  719. verbose_name=_('bridge interface')
  720. )
  721. untagged_vlan = models.ForeignKey(
  722. to='ipam.VLAN',
  723. on_delete=models.SET_NULL,
  724. related_name='%(class)ss_as_untagged',
  725. null=True,
  726. blank=True,
  727. verbose_name=_('untagged VLAN')
  728. )
  729. tagged_vlans = models.ManyToManyField(
  730. to='ipam.VLAN',
  731. related_name='%(class)ss_as_tagged',
  732. blank=True,
  733. verbose_name=_('tagged VLANs')
  734. )
  735. qinq_svlan = models.ForeignKey(
  736. to='ipam.VLAN',
  737. on_delete=models.SET_NULL,
  738. related_name='%(class)ss_svlan',
  739. null=True,
  740. blank=True,
  741. verbose_name=_('Q-in-Q SVLAN')
  742. )
  743. vlan_translation_policy = models.ForeignKey(
  744. to='ipam.VLANTranslationPolicy',
  745. on_delete=models.PROTECT,
  746. null=True,
  747. blank=True,
  748. verbose_name=_('VLAN Translation Policy')
  749. )
  750. primary_mac_address = models.OneToOneField(
  751. to='dcim.MACAddress',
  752. on_delete=models.SET_NULL,
  753. related_name='+',
  754. blank=True,
  755. null=True,
  756. verbose_name=_('primary MAC address')
  757. )
  758. class Meta:
  759. abstract = True
  760. def clean(self):
  761. super().clean()
  762. # SVLAN can be defined only for Q-in-Q interfaces
  763. if self.qinq_svlan and self.mode != InterfaceModeChoices.MODE_Q_IN_Q:
  764. raise ValidationError({
  765. 'qinq_svlan': _("Only Q-in-Q interfaces may specify a service VLAN.")
  766. })
  767. # A primary MAC address must belong to this interface. On create the MAC is assigned by a
  768. # post_save signal after this runs, so an as-yet-unassigned MAC is only rejected on update
  769. # (self._state.adding is False), where no such signal fires. These are raised as non-field
  770. # errors: primary_mac_address is not an InterfaceForm field (it's edited via the mac_address
  771. # shortcut), so a field-keyed error would raise in the form's add_error() rather than render.
  772. if self.primary_mac_address:
  773. if self.primary_mac_address.assigned_object is None:
  774. if not self._state.adding:
  775. raise ValidationError(
  776. _("Only a MAC address assigned to this interface can be its primary MAC address.")
  777. )
  778. elif self.primary_mac_address.assigned_object != self:
  779. raise ValidationError(
  780. _("MAC address {mac_address} is assigned to a different interface ({interface}).").format(
  781. mac_address=self.primary_mac_address,
  782. interface=self.primary_mac_address.assigned_object,
  783. )
  784. )
  785. def save(self, *args, **kwargs):
  786. # Remove untagged VLAN assignment for non-802.1Q interfaces
  787. if not self.mode:
  788. self.untagged_vlan = None
  789. # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
  790. if not self._state.adding and self.mode != InterfaceModeChoices.MODE_TAGGED:
  791. self.tagged_vlans.clear()
  792. return super().save(*args, **kwargs)
  793. def set_primary_mac_address(self, mac):
  794. """
  795. Set (or clear) this interface's primary MAC address as a single atomic, validated operation.
  796. Pass a MACAddress instance to designate it primary, or None to clear the primary MAC. The
  797. callers own permission checks; this method owns the validated write. To set from a submitted
  798. address string (find-or-create on this interface) use set_primary_mac_address_from_value().
  799. """
  800. self._set_primary_mac_address(mac=mac)
  801. set_primary_mac_address.alters_data = True
  802. def set_primary_mac_address_from_value(self, mac_address):
  803. """
  804. Set this interface's primary MAC address from a submitted address string, finding an existing
  805. MAC on the interface or creating one, all within the operation's locked transaction. An empty
  806. value clears the primary MAC. For the form and API adapters, which receive a string.
  807. """
  808. self._set_primary_mac_address(mac_value=mac_address or None)
  809. set_primary_mac_address_from_value.alters_data = True
  810. def _set_primary_mac_address(self, mac=None, mac_value=None):
  811. """
  812. Shared implementation of the two public setters. Locks this interface's row, resolves a
  813. submitted string to a MACAddress (find-or-create, inside the lock so concurrent requests can't
  814. both create the same one), validates, and saves. Callers pass either a resolved MACAddress
  815. (`mac`) or an address string (`mac_value`), never both.
  816. """
  817. with transaction.atomic(using=router.db_for_write(type(self))):
  818. # Lock and re-fetch this interface so concurrent set-primary/find-or-create requests
  819. # serialize, and mutate the freshly-loaded row rather than the caller's in-memory instance.
  820. # The re-fetch resets change-tracking state (e.g. _original_device) to the persisted values,
  821. # so full_clean() validates the persisted object plus this one change, not unrelated edits the
  822. # adapter already validated and saved.
  823. locked = type(self).objects.select_for_update().get(pk=self.pk)
  824. # Resolve a submitted string to a MAC inside the lock, so two concurrent requests setting the
  825. # same new value can't both miss the lookup and both create a duplicate.
  826. if mac_value is not None:
  827. mac = locked.mac_addresses.filter(mac_address=mac_value).first()
  828. if mac is None:
  829. mac = locked.mac_addresses.model(mac_address=mac_value, assigned_object=locked)
  830. mac.full_clean()
  831. mac.save()
  832. target_id = mac.pk if mac is not None else None
  833. if locked.primary_mac_address_id == target_id:
  834. self.primary_mac_address = mac
  835. self.__dict__.pop('mac_address', None)
  836. return
  837. # Snapshot the locked row (refetched after any adapter save this request) so the changelog
  838. # records the correct pre-change state for this MAC change, not an earlier field edit.
  839. locked.snapshot()
  840. locked.primary_mac_address = mac
  841. locked.full_clean(validate_unique=False)
  842. locked.save()
  843. # Reflect the change on the caller's instance (for success messages and API responses) and
  844. # invalidate the cached read-side mac_address property.
  845. self.primary_mac_address = mac
  846. self.__dict__.pop('mac_address', None)
  847. @property
  848. def tunnel_termination(self):
  849. return self.tunnel_terminations.first()
  850. @property
  851. def count_ipaddresses(self):
  852. return self.ip_addresses.count()
  853. @property
  854. def count_fhrp_groups(self):
  855. return self.fhrp_group_assignments.count()
  856. @cached_property
  857. def mac_address(self):
  858. if self.primary_mac_address:
  859. return self.primary_mac_address.mac_address
  860. return None
  861. class Interface(
  862. InterfaceChannelRenameMixin,
  863. InterfaceValidationMixin,
  864. ModularComponentModel,
  865. BaseInterface,
  866. CabledObjectModel,
  867. PathEndpoint,
  868. TrackingModelMixin,
  869. ):
  870. """
  871. A network interface within a Device. A physical Interface can connect to exactly one other Interface.
  872. """
  873. # Override ComponentModel._name to specify naturalize_interface function
  874. _name = NaturalOrderingField(
  875. target_field='name',
  876. naturalize_function=naturalize_interface,
  877. max_length=100,
  878. blank=True
  879. )
  880. vdcs = models.ManyToManyField(
  881. to='dcim.VirtualDeviceContext',
  882. related_name='interfaces'
  883. )
  884. lag = models.ForeignKey(
  885. to='self',
  886. on_delete=models.SET_NULL,
  887. related_name='member_interfaces',
  888. null=True,
  889. blank=True,
  890. verbose_name=_('parent LAG')
  891. )
  892. type = models.CharField(
  893. verbose_name=_('type'),
  894. max_length=50,
  895. choices=InterfaceTypeChoices
  896. )
  897. channels = models.PositiveSmallIntegerField(
  898. verbose_name=_('channels'),
  899. blank=True,
  900. null=True,
  901. validators=(
  902. MinValueValidator(INTERFACE_CHANNELS_MIN),
  903. MaxValueValidator(INTERFACE_CHANNELS_MAX)
  904. ),
  905. help_text=_('The number of channels into which this interface is channelized')
  906. )
  907. channel_id = models.PositiveSmallIntegerField(
  908. verbose_name=_('channel ID'),
  909. blank=True,
  910. null=True,
  911. validators=(
  912. MinValueValidator(INTERFACE_CHANNELS_MIN),
  913. MaxValueValidator(INTERFACE_CHANNELS_MAX)
  914. ),
  915. help_text=_('The channel on the parent interface to which this subinterface is bound')
  916. )
  917. mgmt_only = models.BooleanField(
  918. default=False,
  919. verbose_name=_('management only'),
  920. help_text=_('This interface is used only for out-of-band management')
  921. )
  922. speed = models.PositiveBigIntegerField(
  923. blank=True,
  924. null=True,
  925. verbose_name=_('speed (Kbps)')
  926. )
  927. duplex = models.CharField(
  928. verbose_name=_('duplex'),
  929. max_length=50,
  930. blank=True,
  931. null=True,
  932. choices=InterfaceDuplexChoices
  933. )
  934. wwn = WWNField(
  935. null=True,
  936. blank=True,
  937. verbose_name=_('WWN'),
  938. help_text=_('64-bit World Wide Name')
  939. )
  940. rf_role = models.CharField(
  941. max_length=30,
  942. choices=WirelessRoleChoices,
  943. blank=True,
  944. null=True,
  945. verbose_name=_('wireless role')
  946. )
  947. rf_channel = models.CharField(
  948. max_length=50,
  949. choices=WirelessChannelChoices,
  950. blank=True,
  951. null=True,
  952. verbose_name=_('wireless channel')
  953. )
  954. rf_channel_frequency = models.DecimalField(
  955. max_digits=8,
  956. decimal_places=3,
  957. blank=True,
  958. null=True,
  959. verbose_name=_('channel frequency (MHz)'),
  960. help_text=_("Populated by selected channel (if set)")
  961. )
  962. rf_channel_width = models.DecimalField(
  963. max_digits=7,
  964. decimal_places=3,
  965. blank=True,
  966. null=True,
  967. verbose_name=('channel width (MHz)'),
  968. help_text=_("Populated by selected channel (if set)")
  969. )
  970. tx_power = models.SmallIntegerField(
  971. blank=True,
  972. null=True,
  973. validators=(
  974. MinValueValidator(-40),
  975. MaxValueValidator(127),
  976. ),
  977. verbose_name=_('transmit power (dBm)')
  978. )
  979. poe_mode = models.CharField(
  980. max_length=50,
  981. choices=InterfacePoEModeChoices,
  982. blank=True,
  983. null=True,
  984. verbose_name=_('PoE mode')
  985. )
  986. poe_type = models.CharField(
  987. max_length=50,
  988. choices=InterfacePoETypeChoices,
  989. blank=True,
  990. null=True,
  991. verbose_name=_('PoE type')
  992. )
  993. wireless_link = models.ForeignKey(
  994. to='wireless.WirelessLink',
  995. on_delete=models.SET_NULL,
  996. related_name='+',
  997. blank=True,
  998. null=True
  999. )
  1000. wireless_lans = models.ManyToManyField(
  1001. to='wireless.WirelessLAN',
  1002. related_name='interfaces',
  1003. blank=True,
  1004. verbose_name=_('wireless LANs')
  1005. )
  1006. vrf = models.ForeignKey(
  1007. to='ipam.VRF',
  1008. on_delete=models.SET_NULL,
  1009. related_name='interfaces',
  1010. null=True,
  1011. blank=True,
  1012. verbose_name=_('VRF')
  1013. )
  1014. ip_addresses = GenericRelation(
  1015. to='ipam.IPAddress',
  1016. content_type_field='assigned_object_type',
  1017. object_id_field='assigned_object_id',
  1018. related_query_name='interface'
  1019. )
  1020. mac_addresses = GenericRelation(
  1021. to='dcim.MACAddress',
  1022. content_type_field='assigned_object_type',
  1023. object_id_field='assigned_object_id',
  1024. related_query_name='interface'
  1025. )
  1026. fhrp_group_assignments = GenericRelation(
  1027. to='ipam.FHRPGroupAssignment',
  1028. content_type_field='interface_type',
  1029. object_id_field='interface_id',
  1030. related_query_name='+'
  1031. )
  1032. tunnel_terminations = GenericRelation(
  1033. to='vpn.TunnelTermination',
  1034. content_type_field='termination_type',
  1035. object_id_field='termination_id',
  1036. related_query_name='interface'
  1037. )
  1038. l2vpn_terminations = GenericRelation(
  1039. to='vpn.L2VPNTermination',
  1040. content_type_field='assigned_object_type',
  1041. object_id_field='assigned_object_id',
  1042. related_query_name='interface',
  1043. )
  1044. clone_fields = (
  1045. 'device', 'module', 'parent', 'bridge', 'lag', 'type', 'channels', 'mgmt_only', 'mtu', 'mode', 'speed',
  1046. 'duplex', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode',
  1047. 'poe_type', 'vrf',
  1048. )
  1049. class Meta(ModularComponentModel.Meta):
  1050. ordering = ('device', CollateAsChar('_name'))
  1051. verbose_name = _('interface')
  1052. verbose_name_plural = _('interfaces')
  1053. constraints = (
  1054. *ModularComponentModel.Meta.constraints,
  1055. models.UniqueConstraint(
  1056. fields=('parent', 'channel_id'),
  1057. name='%(app_label)s_%(class)s_unique_parent_channel_id'
  1058. ),
  1059. )
  1060. def __init__(self, *args, **kwargs):
  1061. # InterfaceChannelRenameMixin.__init__() (reached via super(), first in the MRO) sets _original_channels, used
  1062. # below by InterfaceValidationMixin.clean() and by post_save signal handlers.
  1063. super().__init__(*args, **kwargs)
  1064. # Cache channelization-related fields so post-save signal handlers can detect changes which require rebuilding
  1065. # cable paths (channelization does not involve modifying the Cable itself, so the cable signals do not fire).
  1066. self._original_channel_id = self.__dict__.get('channel_id')
  1067. self._original_parent_id = self.__dict__.get('parent_id')
  1068. def clean(self):
  1069. super().clean()
  1070. # Virtual Interfaces cannot have a Cable attached
  1071. if self.is_virtual and self.cable:
  1072. raise ValidationError({
  1073. 'type': _("{display_type} interfaces cannot have a cable attached.").format(
  1074. display_type=self.get_type_display()
  1075. )
  1076. })
  1077. # Virtual Interfaces cannot be marked as connected
  1078. if self.is_virtual and self.mark_connected:
  1079. raise ValidationError({
  1080. 'mark_connected': _("{display_type} interfaces cannot be marked as connected.".format(
  1081. display_type=self.get_type_display())
  1082. )
  1083. })
  1084. # A channel subinterface's cable state is mirrored from its channelized parent (see
  1085. # update_channelized_cable_paths()), so it cannot also carry its own CableTermination -- checking
  1086. # cable_terminations rather than self.cable, since a valid channel child's self.cable is expected to
  1087. # already reflect the parent's mirrored cable.
  1088. if self.channel_id is not None and self.cable_terminations.exists():
  1089. raise ValidationError({
  1090. 'channel_id': _(
  1091. "A channel ID cannot be assigned to an interface with an existing cable connection. Remove "
  1092. "the cable first."
  1093. )
  1094. })
  1095. # Parent validation (self-reference and interface-type restrictions are enforced by InterfaceValidationMixin)
  1096. # An interface's parent must belong to the same device or virtual chassis
  1097. if self.parent and self.parent.device != self.device:
  1098. if self.device.virtual_chassis is None:
  1099. raise ValidationError({
  1100. 'parent': _(
  1101. "The selected parent interface ({interface}) belongs to a different device ({device})"
  1102. ).format(interface=self.parent, device=self.parent.device)
  1103. })
  1104. if self.parent.device.virtual_chassis != self.device.virtual_chassis:
  1105. raise ValidationError({
  1106. 'parent': _(
  1107. "The selected parent interface ({interface}) belongs to {device}, which is not part of "
  1108. "virtual chassis {virtual_chassis}."
  1109. ).format(
  1110. interface=self.parent,
  1111. device=self.parent.device,
  1112. virtual_chassis=self.device.virtual_chassis
  1113. )
  1114. })
  1115. # Bridge validation
  1116. # A bridged interface belongs to the same device or virtual chassis
  1117. if self.bridge and self.bridge.device != self.device:
  1118. if self.device.virtual_chassis is None:
  1119. raise ValidationError({
  1120. 'bridge': _(
  1121. "The selected bridge interface ({bridge}) belongs to a different device ({device})."
  1122. ).format(bridge=self.bridge, device=self.bridge.device)
  1123. })
  1124. if self.bridge.device.virtual_chassis != self.device.virtual_chassis:
  1125. raise ValidationError({
  1126. 'bridge': _(
  1127. "The selected bridge interface ({interface}) belongs to {device}, which is not part of virtual "
  1128. "chassis {virtual_chassis}."
  1129. ).format(
  1130. interface=self.bridge, device=self.bridge.device, virtual_chassis=self.device.virtual_chassis
  1131. )
  1132. })
  1133. # LAG validation
  1134. # A virtual interface cannot have a parent LAG
  1135. if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
  1136. raise ValidationError({'lag': _("Virtual interfaces cannot have a parent LAG interface.")})
  1137. # A LAG interface cannot be its own parent
  1138. if self.pk and self.lag_id == self.pk:
  1139. raise ValidationError({'lag': _("A LAG interface cannot be its own parent.")})
  1140. # An interface's LAG must belong to the same device or virtual chassis
  1141. if self.lag and self.lag.device != self.device:
  1142. if self.device.virtual_chassis is None:
  1143. raise ValidationError({
  1144. 'lag': _(
  1145. "The selected LAG interface ({lag}) belongs to a different device ({device})."
  1146. ).format(lag=self.lag, device=self.lag.device)
  1147. })
  1148. if self.lag.device.virtual_chassis != self.device.virtual_chassis:
  1149. raise ValidationError({
  1150. 'lag': _(
  1151. "The selected LAG interface ({lag}) belongs to {device}, which is not part of virtual chassis "
  1152. "{virtual_chassis}.".format(
  1153. lag=self.lag, device=self.lag.device, virtual_chassis=self.device.virtual_chassis)
  1154. )
  1155. })
  1156. # Wireless validation
  1157. # RF channel may only be set for wireless interfaces
  1158. if self.rf_channel and not self.is_wireless:
  1159. raise ValidationError({'rf_channel': _("Channel may be set only on wireless interfaces.")})
  1160. # Validate channel frequency against interface type and selected channel (if any)
  1161. if self.rf_channel_frequency:
  1162. if not self.is_wireless:
  1163. raise ValidationError({
  1164. 'rf_channel_frequency': _("Channel frequency may be set only on wireless interfaces."),
  1165. })
  1166. if self.rf_channel and self.rf_channel_frequency != get_channel_attr(self.rf_channel, 'frequency'):
  1167. raise ValidationError({
  1168. 'rf_channel_frequency': _("Cannot specify custom frequency with channel selected."),
  1169. })
  1170. # Validate channel width against interface type and selected channel (if any)
  1171. if self.rf_channel_width:
  1172. if not self.is_wireless:
  1173. raise ValidationError({'rf_channel_width': _("Channel width may be set only on wireless interfaces.")})
  1174. if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
  1175. raise ValidationError({'rf_channel_width': _("Cannot specify custom width with channel selected.")})
  1176. # VLAN validation
  1177. if not self.mode and self.untagged_vlan:
  1178. raise ValidationError({'untagged_vlan': _("Interface mode does not support an untagged vlan.")})
  1179. # Validate untagged VLAN
  1180. if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
  1181. raise ValidationError({
  1182. 'untagged_vlan': _(
  1183. "The untagged VLAN ({untagged_vlan}) must belong to the same site as the interface's parent "
  1184. "device, or it must be global."
  1185. ).format(untagged_vlan=self.untagged_vlan)
  1186. })
  1187. def save(self, *args, **kwargs):
  1188. # Set absolute channel attributes from selected options
  1189. if self.rf_channel and not self.rf_channel_frequency:
  1190. self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
  1191. if self.rf_channel and not self.rf_channel_width:
  1192. self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
  1193. # InterfaceChannelRenameMixin.save() (reached via super(), first in the MRO) detects and cascades a channelized
  1194. # parent rename around this call.
  1195. super().save(*args, **kwargs)
  1196. @property
  1197. def _occupied(self):
  1198. return super()._occupied or bool(self.wireless_link_id)
  1199. @property
  1200. def is_wired(self):
  1201. # Also excludes any channel subinterface, which derives its cable from the channelized parent.
  1202. return self.type not in NONCONNECTABLE_IFACE_TYPES and self.channel_id is None
  1203. @property
  1204. def is_virtual(self):
  1205. return self.type in VIRTUAL_IFACE_TYPES
  1206. @property
  1207. def is_wireless(self):
  1208. return self.type in WIRELESS_IFACE_TYPES
  1209. @property
  1210. def is_lag(self):
  1211. return self.type == InterfaceTypeChoices.TYPE_LAG
  1212. @property
  1213. def is_bridge(self):
  1214. return self.type == InterfaceTypeChoices.TYPE_BRIDGE
  1215. @property
  1216. def is_channel(self):
  1217. # Identified by channel_id, not type — it may keep its own specific physical type instead of "channel".
  1218. return self.channel_id is not None
  1219. @property
  1220. def link(self):
  1221. return self.cable or self.wireless_link
  1222. @cached_property
  1223. def link_peers(self):
  1224. if self.cable:
  1225. return super().link_peers
  1226. if self.wireless_link:
  1227. # Return the opposite side of the attached wireless link
  1228. if self.wireless_link.interface_a == self:
  1229. return [self.wireless_link.interface_b]
  1230. return [self.wireless_link.interface_a]
  1231. return []
  1232. @property
  1233. def l2vpn_termination(self):
  1234. return self.l2vpn_terminations.first()
  1235. @cached_property
  1236. def connected_endpoints(self):
  1237. # If this is a virtual interface, return the remote endpoint of the connected
  1238. # virtual circuit, if any.
  1239. if self.is_virtual and hasattr(self, 'virtual_circuit_termination'):
  1240. return self.virtual_circuit_termination.peer_terminations
  1241. return super().connected_endpoints
  1242. def set_cable_termination(self, termination):
  1243. super().set_cable_termination(termination)
  1244. # A channelized interface carries no path of its own; instead, its cable is mirrored onto each channel
  1245. # subinterface (occupying a single position of the shared connector) so that each channel traces independently.
  1246. if self.channels:
  1247. self.propagate_channel_cables()
  1248. def clear_cable_termination(self, termination):
  1249. super().clear_cable_termination(termination)
  1250. if self.channels:
  1251. self.clear_channel_cables()
  1252. def propagate_channel_cables(self):
  1253. """
  1254. Mirror this channelized interface's cable attributes onto each of its channel subinterfaces, restricting each
  1255. child to the single connector position identified by its channel_id. Only profiled cables map connector
  1256. positions to channels; a positionless (unprofiled) cable carries no per-channel path, so nothing is mirrored.
  1257. """
  1258. # Only a profiled cable defines the connector positions that channels map onto; without one, clear any
  1259. # previously-mirrored attributes rather than propagate an unusable cable reference.
  1260. if not (self.cable and self.cable.profile):
  1261. self.clear_channel_cables()
  1262. return
  1263. # Mirror via bulk_update() to issue a single UPDATE and, crucially, to bypass the post_save signal — a
  1264. # per-child save() would re-trigger update_channelized_cable_paths() and recurse indefinitely.
  1265. children = list(self.child_interfaces.filter(channel_id__isnull=False))
  1266. for child in children:
  1267. child.cable = self.cable
  1268. child.cable_end = self.cable_end
  1269. child.cable_connector = self.cable_connector
  1270. child.cable_positions = [child.channel_id]
  1271. type(self).objects.bulk_update(
  1272. children, ['cable', 'cable_end', 'cable_connector', 'cable_positions']
  1273. )
  1274. def clear_channel_cables(self):
  1275. """
  1276. Clear the mirrored cable attributes from this channelized interface's channel subinterfaces.
  1277. """
  1278. # A queryset update() clears every child in a single query and bypasses the post_save signal (see above).
  1279. # cable_end is cleared to '' to match the convention used elsewhere when nullifying a termination (see
  1280. # nullify_connected_endpoints() and update_channelized_cable_paths() in dcim.signals).
  1281. self.child_interfaces.filter(channel_id__isnull=False).update(
  1282. cable=None,
  1283. cable_end='',
  1284. cable_connector=None,
  1285. cable_positions=None,
  1286. )
  1287. #
  1288. # Pass-through ports
  1289. #
  1290. class PortMapping(ChangeLoggingMixin, PortMappingBase):
  1291. """
  1292. Maps a FrontPort & position to a RearPort & position.
  1293. """
  1294. device = models.ForeignKey(
  1295. to='dcim.Device',
  1296. on_delete=models.CASCADE,
  1297. related_name='port_mappings',
  1298. )
  1299. front_port = models.ForeignKey(
  1300. to='dcim.FrontPort',
  1301. on_delete=models.CASCADE,
  1302. related_name='mappings',
  1303. )
  1304. rear_port = models.ForeignKey(
  1305. to='dcim.RearPort',
  1306. on_delete=models.CASCADE,
  1307. related_name='mappings',
  1308. )
  1309. class Meta(PortMappingBase.Meta):
  1310. # Inherit the unique constraints from PortMappingBase.Meta.
  1311. pass
  1312. def clean(self):
  1313. super().clean()
  1314. # Both ports must belong to the same device
  1315. if self.front_port.device_id != self.rear_port.device_id:
  1316. raise ValidationError({
  1317. "rear_port": _("Rear port ({rear_port}) must belong to the same device").format(
  1318. rear_port=self.rear_port
  1319. )
  1320. })
  1321. def save(self, *args, **kwargs):
  1322. # Associate the mapping with the parent Device
  1323. self.device = self.front_port.device
  1324. super().save(*args, **kwargs)
  1325. class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
  1326. """
  1327. A pass-through port on the front of a Device.
  1328. """
  1329. type = models.CharField(
  1330. verbose_name=_('type'),
  1331. max_length=50,
  1332. choices=PortTypeChoices
  1333. )
  1334. color = ColorField(
  1335. verbose_name=_('color'),
  1336. blank=True
  1337. )
  1338. positions = models.PositiveSmallIntegerField(
  1339. verbose_name=_('positions'),
  1340. default=1,
  1341. validators=[
  1342. MinValueValidator(PORT_POSITION_MIN),
  1343. MaxValueValidator(PORT_POSITION_MAX)
  1344. ],
  1345. )
  1346. clone_fields = ('device', 'type', 'color', 'positions')
  1347. class Meta(ModularComponentModel.Meta):
  1348. constraints = (
  1349. models.UniqueConstraint(
  1350. fields=('device', 'name'),
  1351. name='%(app_label)s_%(class)s_unique_device_name'
  1352. ),
  1353. )
  1354. verbose_name = _('front port')
  1355. verbose_name_plural = _('front ports')
  1356. def clean(self):
  1357. super().clean()
  1358. # Check that positions is greater than or equal to the number of associated RearPorts
  1359. if not self._state.adding:
  1360. mapping_count = self.mappings.count()
  1361. if self.positions < mapping_count:
  1362. raise ValidationError({
  1363. "positions": _(
  1364. "The number of positions cannot be less than the number of mapped rear ports ({count})"
  1365. ).format(count=mapping_count)
  1366. })
  1367. class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
  1368. """
  1369. A pass-through port on the rear of a Device.
  1370. """
  1371. type = models.CharField(
  1372. verbose_name=_('type'),
  1373. max_length=50,
  1374. choices=PortTypeChoices
  1375. )
  1376. color = ColorField(
  1377. verbose_name=_('color'),
  1378. blank=True
  1379. )
  1380. positions = models.PositiveSmallIntegerField(
  1381. verbose_name=_('positions'),
  1382. default=1,
  1383. validators=[
  1384. MinValueValidator(PORT_POSITION_MIN),
  1385. MaxValueValidator(PORT_POSITION_MAX)
  1386. ],
  1387. )
  1388. clone_fields = ('device', 'type', 'color', 'positions')
  1389. class Meta(ModularComponentModel.Meta):
  1390. verbose_name = _('rear port')
  1391. verbose_name_plural = _('rear ports')
  1392. def clean(self):
  1393. super().clean()
  1394. # Check that positions count is greater than or equal to the number of associated FrontPorts
  1395. if not self._state.adding:
  1396. mapping_count = self.mappings.count()
  1397. if self.positions < mapping_count:
  1398. raise ValidationError({
  1399. "positions": _(
  1400. "The number of positions cannot be less than the number of mapped front ports "
  1401. "({count})"
  1402. ).format(count=mapping_count)
  1403. })
  1404. #
  1405. # Bays
  1406. #
  1407. class ModuleBay(ModularComponentModel, TrackingModelMixin, LtreeModel):
  1408. """
  1409. An empty space within a Device which can house a child device
  1410. """
  1411. parent = models.ForeignKey(
  1412. to='self',
  1413. on_delete=models.CASCADE,
  1414. related_name='children',
  1415. blank=True,
  1416. null=True,
  1417. editable=False,
  1418. db_index=True
  1419. )
  1420. position = models.CharField(
  1421. verbose_name=_('position'),
  1422. max_length=30,
  1423. blank=True,
  1424. help_text=_('Identifier to reference when renaming installed components')
  1425. )
  1426. enabled = models.BooleanField(
  1427. verbose_name=_('enabled'),
  1428. default=True,
  1429. )
  1430. module_bay_types = models.ManyToManyField(
  1431. to='dcim.ModuleBayType',
  1432. related_name='module_bays',
  1433. blank=True,
  1434. verbose_name=_('module bay types'),
  1435. help_text=_('Types of modules that can be installed in this bay (empty = unconstrained)'),
  1436. )
  1437. # sort_path inherits `name`'s natural_sort collation automatically (LtreeModelBase),
  1438. # so ORDER BY sort_path sorts siblings naturally (Slot 0..Slot 13) — as MPTT's
  1439. # order_insertion_by=('name',) did — rather than lexicographically.
  1440. sort_path = SortPathField(
  1441. editable=False,
  1442. blank=True,
  1443. default='',
  1444. )
  1445. clone_fields = ('device', 'enabled')
  1446. objects = LtreeManager()
  1447. class Meta(ModularComponentModel.Meta):
  1448. # Order by sort_path alone (not device-first), reproducing the MPTT
  1449. # ModuleBayManager's ('_root_name', 'lft'): sort_path begins with the tree's
  1450. # root-bay name (natural_sort collation), so the global list groups by
  1451. # root-bay name across devices, descendants following their root. `pk`
  1452. # gives a deterministic tie-break among same-named roots on different devices
  1453. # (MPTT's lft=1 left this order arbitrary).
  1454. ordering = ('sort_path', 'pk')
  1455. indexes = (
  1456. GistIndex(fields=['path'], name='dcim_modulebay_path_gist'),
  1457. models.Index(fields=['sort_path'], name='dcim_modulebay_sort_path_idx'),
  1458. )
  1459. constraints = (
  1460. models.UniqueConstraint(
  1461. fields=('device', 'module', 'name'),
  1462. name='%(app_label)s_%(class)s_unique_device_module_name'
  1463. ),
  1464. )
  1465. verbose_name = _('module bay')
  1466. verbose_name_plural = _('module bays')
  1467. def clean(self):
  1468. super().clean()
  1469. # Check for recursion
  1470. if module := self.module:
  1471. module_bays = [self.pk]
  1472. modules = []
  1473. while module:
  1474. if module.pk in modules or module.module_bay.pk in module_bays:
  1475. raise ValidationError(_("A module bay cannot belong to a module installed within it."))
  1476. modules.append(module.pk)
  1477. module_bays.append(module.module_bay.pk)
  1478. module = module.module_bay.module if module.module_bay else None
  1479. def save(self, *args, **kwargs):
  1480. if self.module:
  1481. self.parent = self.module.module_bay
  1482. else:
  1483. self.parent = None
  1484. super().save(*args, **kwargs)
  1485. def _parent_creates_cycle(self):
  1486. # A ModuleBay's parent is system-derived from its module (see save()), not
  1487. # user-assigned, and module/bay recursion is validated in clean(); skip the
  1488. # generic ltree cycle guard.
  1489. return False
  1490. @property
  1491. def _occupied(self):
  1492. """
  1493. Indicates whether the module bay is occupied by a module.
  1494. """
  1495. return bool(not self.enabled or hasattr(self, 'installed_module'))
  1496. @property
  1497. def is_module_compatible(self):
  1498. """
  1499. Return True if the installed module (if any) is compatible with this bay's type constraints,
  1500. or if this bay has no type constraints, or if no module is installed.
  1501. Returns False when this bay and the installed module's type have non-empty, disjoint bay type sets.
  1502. """
  1503. module = getattr(self, 'installed_module', None)
  1504. if module is None:
  1505. return True
  1506. # Use .all() so a prefetch cache is honoured; see Module.is_bay_compatible for details.
  1507. bay_types = {t.pk for t in self.module_bay_types.all()}
  1508. if not bay_types:
  1509. return True
  1510. type_types = {t.pk for t in module.module_type.module_bay_types.all()}
  1511. if type_types and not (bay_types & type_types):
  1512. return False
  1513. return True
  1514. def get_incompatible_module(self):
  1515. """
  1516. Return the installed Module if it is incompatible with this bay's type constraints, else None.
  1517. """
  1518. module = getattr(self, 'installed_module', None)
  1519. return module if module and not self.is_module_compatible else None
  1520. class DeviceBay(ComponentModel, TrackingModelMixin):
  1521. """
  1522. An empty space within a Device which can house a child device
  1523. """
  1524. installed_device = models.OneToOneField(
  1525. to='dcim.Device',
  1526. on_delete=models.SET_NULL,
  1527. related_name='parent_bay',
  1528. blank=True,
  1529. null=True
  1530. )
  1531. enabled = models.BooleanField(
  1532. verbose_name=_('enabled'),
  1533. default=True,
  1534. )
  1535. clone_fields = ('device', 'enabled')
  1536. class Meta(ComponentModel.Meta):
  1537. verbose_name = _('device bay')
  1538. verbose_name_plural = _('device bays')
  1539. def clean(self):
  1540. super().clean()
  1541. # Validate that the parent Device can have DeviceBays
  1542. if hasattr(self, 'device') and not self.device.device_type.is_parent_device:
  1543. raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format(
  1544. device_type=self.device.device_type
  1545. ))
  1546. # Prevent installing a device into a disabled bay
  1547. if self.installed_device and not self.enabled:
  1548. current_installed_device_id = (
  1549. DeviceBay.objects.filter(pk=self.pk).values_list('installed_device_id', flat=True).first()
  1550. )
  1551. if self.pk is None or current_installed_device_id != self.installed_device_id:
  1552. raise ValidationError({
  1553. 'installed_device': _("Cannot install a device in a disabled device bay.")
  1554. })
  1555. # Cannot install a device into itself, obviously
  1556. if self.installed_device and getattr(self, 'device', None) == self.installed_device:
  1557. raise ValidationError(_("Cannot install a device into itself."))
  1558. # Check that the installed device is not already installed elsewhere
  1559. if self.installed_device:
  1560. current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
  1561. if current_bay and current_bay != self:
  1562. raise ValidationError({
  1563. 'installed_device': _(
  1564. "Cannot install the specified device; device is already installed in {bay}."
  1565. ).format(bay=current_bay)
  1566. })
  1567. @property
  1568. def _occupied(self):
  1569. """
  1570. Indicates whether the device bay is occupied by a child device.
  1571. """
  1572. return bool(not self.enabled or self.installed_device_id)
  1573. #
  1574. # Inventory items
  1575. #
  1576. class InventoryItemRole(OrganizationalModel):
  1577. """
  1578. Inventory items may optionally be assigned a functional role.
  1579. """
  1580. color = ColorField(
  1581. verbose_name=_('color'),
  1582. default=ColorChoices.COLOR_GREY
  1583. )
  1584. class Meta:
  1585. ordering = ('name',)
  1586. verbose_name = _('inventory item role')
  1587. verbose_name_plural = _('inventory item roles')
  1588. class InventoryItem(LtreeModel, ComponentModel, TrackingModelMixin):
  1589. """
  1590. An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
  1591. InventoryItems are used only for inventory purposes.
  1592. """
  1593. parent = models.ForeignKey(
  1594. to='self',
  1595. on_delete=models.CASCADE,
  1596. related_name='child_items',
  1597. blank=True,
  1598. null=True,
  1599. db_index=True
  1600. )
  1601. component_type = models.ForeignKey(
  1602. to='contenttypes.ContentType',
  1603. on_delete=models.PROTECT,
  1604. related_name='+',
  1605. blank=True,
  1606. null=True
  1607. )
  1608. component_id = models.PositiveBigIntegerField(
  1609. blank=True,
  1610. null=True
  1611. )
  1612. component = GenericForeignKey(
  1613. ct_field='component_type',
  1614. fk_field='component_id'
  1615. )
  1616. status = models.CharField(
  1617. verbose_name=_('status'),
  1618. max_length=50,
  1619. choices=InventoryItemStatusChoices,
  1620. default=InventoryItemStatusChoices.STATUS_ACTIVE
  1621. )
  1622. role = models.ForeignKey(
  1623. to='dcim.InventoryItemRole',
  1624. on_delete=models.PROTECT,
  1625. related_name='inventory_items',
  1626. blank=True,
  1627. null=True
  1628. )
  1629. manufacturer = models.ForeignKey(
  1630. to='dcim.Manufacturer',
  1631. on_delete=models.PROTECT,
  1632. related_name='inventory_items',
  1633. blank=True,
  1634. null=True
  1635. )
  1636. part_id = models.CharField(
  1637. max_length=50,
  1638. verbose_name=_('part ID'),
  1639. blank=True,
  1640. help_text=_('Manufacturer-assigned part identifier')
  1641. )
  1642. serial = models.CharField(
  1643. max_length=50,
  1644. verbose_name=_('serial number'),
  1645. blank=True
  1646. )
  1647. asset_tag = models.CharField(
  1648. max_length=50,
  1649. unique=True,
  1650. blank=True,
  1651. null=True,
  1652. verbose_name=_('asset tag'),
  1653. help_text=_('A unique tag used to identify this item')
  1654. )
  1655. discovered = models.BooleanField(
  1656. verbose_name=_('discovered'),
  1657. default=False,
  1658. help_text=_('This item was automatically discovered')
  1659. )
  1660. clone_fields = ('device', 'parent', 'role', 'manufacturer', 'status', 'part_id')
  1661. objects = LtreeManager()
  1662. class Meta:
  1663. # Global list is flat + alphabetical by name (natural_sort collation). The
  1664. # per-device Inventory tab renders the hierarchy instead — DeviceInventoryView
  1665. # .get_children() orders that by `path`. `pk` is a deterministic tie-break for
  1666. # same-named items on different devices.
  1667. ordering = ('name', 'pk')
  1668. indexes = (
  1669. models.Index(fields=('component_type', 'component_id')),
  1670. GistIndex(fields=['path'], name='dcim_inventoryitem_path_gist'),
  1671. )
  1672. constraints = (
  1673. models.UniqueConstraint(
  1674. fields=('device', 'parent', 'name'),
  1675. name='%(app_label)s_%(class)s_unique_device_parent_name'
  1676. ),
  1677. )
  1678. verbose_name = _('inventory item')
  1679. verbose_name_plural = _('inventory items')
  1680. def clean(self):
  1681. super().clean()
  1682. # Validation for moving InventoryItems
  1683. if not self._state.adding:
  1684. # Cannot move an InventoryItem to another device if it has a parent
  1685. if self.parent and self.parent.device != self.device:
  1686. raise ValidationError({
  1687. "parent": _("Parent inventory item does not belong to the same device.")
  1688. })
  1689. # Prevent moving InventoryItems with children
  1690. first_child = self.get_children().first()
  1691. if first_child and first_child.device != self.device:
  1692. raise ValidationError(_("Cannot move an inventory item with dependent children"))
  1693. # When moving an InventoryItem to another device, remove any associated component
  1694. if self.component and self.component.device != self.device:
  1695. self.component = None
  1696. else:
  1697. if self.component and self.component.device != self.device:
  1698. raise ValidationError({
  1699. "device": _("Cannot assign inventory item to component on another device")
  1700. })
  1701. def get_status_color(self):
  1702. return InventoryItemStatusChoices.colors.get(self.status)