device_components.py 51 KB

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