device_components.py 71 KB

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