device_components.py 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674
  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.utils.translation import gettext_lazy as _
  8. from mptt.models import MPTTModel, TreeForeignKey
  9. from dcim.choices import *
  10. from dcim.constants import *
  11. from dcim.fields import WWNField
  12. from dcim.models.base import PortMappingBase
  13. from dcim.models.mixins import InterfaceValidationMixin
  14. from netbox.choices import ColorChoices
  15. from netbox.models import NetBoxModel, OrganizationalModel
  16. from netbox.models.features import ChangeLoggingMixin
  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 not self.cable:
  232. return []
  233. if self.cable.profile:
  234. return self._get_profile_link_peers()
  235. return [peer.termination for peer in self.cable.terminations.all() if peer.cable_end != self.cable_end]
  236. def _get_profile_link_peers(self):
  237. if self.cable_end is None or self.cable_connector is None or not self.cable_positions:
  238. return []
  239. profile = self.cable.profile_class()
  240. peer_terminations = {
  241. (peer.connector, position): peer.termination
  242. for peer in self.cable.terminations.all()
  243. if peer.cable_end == self.opposite_cable_end and peer.connector is not None
  244. for position in peer.positions or []
  245. }
  246. link_peers = []
  247. for position in self.cable_positions:
  248. mapped_position = profile.get_mapped_position(self.cable_end, self.cable_connector, position)
  249. if mapped_position is None:
  250. continue
  251. peer = peer_terminations.get(mapped_position)
  252. if peer is not None and peer not in link_peers:
  253. link_peers.append(peer)
  254. return link_peers
  255. @property
  256. def _occupied(self):
  257. return bool(self.mark_connected or self.cable_id)
  258. @property
  259. def parent_object(self):
  260. raise NotImplementedError(
  261. _("{class_name} models must declare a parent_object property").format(class_name=self.__class__.__name__)
  262. )
  263. @property
  264. def opposite_cable_end(self):
  265. if not self.cable_end:
  266. return None
  267. return CableEndChoices.SIDE_A if self.cable_end == CableEndChoices.SIDE_B else CableEndChoices.SIDE_B
  268. def set_cable_termination(self, termination):
  269. """Save attributes from the given CableTermination on the terminating object."""
  270. self.cable = termination.cable
  271. self.cable_end = termination.cable_end
  272. self.cable_connector = termination.connector
  273. self.cable_positions = termination.positions
  274. set_cable_termination.alters_data = True
  275. def clear_cable_termination(self, termination):
  276. """Clear all cable termination attributes from the terminating object."""
  277. self.cable = None
  278. self.cable_end = None
  279. self.cable_connector = None
  280. self.cable_positions = None
  281. clear_cable_termination.alters_data = True
  282. class PathEndpoint(models.Model):
  283. """
  284. An abstract model inherited by any CabledObjectModel subclass which represents the end of a CablePath; specifically,
  285. these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed.
  286. `_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in
  287. dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
  288. CablePath model. `_path` should not be accessed directly; rather, use the `path` property.
  289. `connected_endpoints()` is a convenience method for returning the destination of the associated CablePath, if any.
  290. """
  291. _path = models.ForeignKey(
  292. to='dcim.CablePath',
  293. on_delete=models.SET_NULL,
  294. null=True,
  295. blank=True,
  296. )
  297. class Meta:
  298. abstract = True
  299. def trace(self):
  300. origin = self
  301. path = []
  302. # Construct the complete path (including e.g. bridged interfaces)
  303. while origin is not None:
  304. # Go through the public accessor rather than dereferencing `_path`
  305. # directly. During cable edits, CablePath rows can be deleted and
  306. # recreated while this endpoint instance is still in memory.
  307. cable_path = origin.path
  308. if cable_path is None:
  309. break
  310. path.extend(cable_path.path_objects)
  311. # If the path ends at a non-connected pass-through port, pad out the link and far-end terminations
  312. if len(path) % 3 == 1:
  313. path.extend(([], []))
  314. # If the path ends at a site or provider network, inject a null "link" to render an attachment
  315. elif len(path) % 3 == 2:
  316. path.insert(-1, [])
  317. # Check for a bridged relationship to continue the trace.
  318. destinations = cable_path.destinations
  319. if len(destinations) == 1:
  320. origin = getattr(destinations[0], 'bridge', None)
  321. else:
  322. origin = None
  323. # Return the path as a list of three-tuples (A termination(s), cable(s), B termination(s))
  324. return list(zip(*[iter(path)] * 3))
  325. @property
  326. def path(self):
  327. """
  328. Return this endpoint's current CablePath, if any.
  329. `_path` is a denormalized reference that is updated from CablePath
  330. save/delete handlers, including queryset.update() calls on origin
  331. endpoints. That means an already-instantiated endpoint can briefly hold
  332. a stale in-memory `_path` relation while the database already points to
  333. a different CablePath (or to no path at all).
  334. Two stale cases are repaired by refreshing only the `_path` field
  335. from the database:
  336. 1. The endpoint is linked (by cable or wireless link) but `_path` is
  337. unset, because the instance was loaded before its path was traced
  338. (e.g. while queued for event serialization during link creation).
  339. 2. The cached relation points to a CablePath row that has just been
  340. deleted.
  341. Repairing case 1 costs one query per access for a linked endpoint
  342. whose path is genuinely absent in the database. That state is
  343. transient outside of tracing failures, so no result caching is
  344. attempted here.
  345. """
  346. if self._path_id is None:
  347. has_link = self.cable_id is not None or getattr(self, 'wireless_link_id', None) is not None
  348. if self.pk and has_link:
  349. self.refresh_from_db(fields=['_path'])
  350. if self._path_id is None:
  351. return None
  352. try:
  353. return self._path
  354. except ObjectDoesNotExist:
  355. # Refresh only the denormalized FK instead of the whole model.
  356. # The expected problem here is in-memory staleness during path
  357. # rebuilds, not persistent database corruption.
  358. self.refresh_from_db(fields=['_path'])
  359. return self._path if self._path_id else None
  360. @cached_property
  361. def connected_endpoints(self):
  362. """
  363. Caching accessor for the attached CablePath's destinations (if any).
  364. Always route through `path` so stale in-memory `_path` references are
  365. repaired before we cache the result for the lifetime of this instance.
  366. """
  367. if cable_path := self.path:
  368. return cable_path.destinations
  369. return []
  370. #
  371. # Console components
  372. #
  373. class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
  374. """
  375. A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
  376. """
  377. type = models.CharField(
  378. verbose_name=_('type'),
  379. max_length=50,
  380. choices=ConsolePortTypeChoices,
  381. blank=True,
  382. null=True,
  383. help_text=_('Physical port type')
  384. )
  385. speed = models.PositiveIntegerField(
  386. verbose_name=_('speed'),
  387. choices=ConsolePortSpeedChoices,
  388. blank=True,
  389. null=True,
  390. help_text=_('Port speed in bits per second')
  391. )
  392. clone_fields = ('device', 'module', 'type', 'speed')
  393. class Meta(ModularComponentModel.Meta):
  394. verbose_name = _('console port')
  395. verbose_name_plural = _('console ports')
  396. class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
  397. """
  398. A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
  399. """
  400. type = models.CharField(
  401. verbose_name=_('type'),
  402. max_length=50,
  403. choices=ConsolePortTypeChoices,
  404. blank=True,
  405. null=True,
  406. help_text=_('Physical port type')
  407. )
  408. speed = models.PositiveIntegerField(
  409. verbose_name=_('speed'),
  410. choices=ConsolePortSpeedChoices,
  411. blank=True,
  412. null=True,
  413. help_text=_('Port speed in bits per second')
  414. )
  415. clone_fields = ('device', 'module', 'type', 'speed')
  416. class Meta(ModularComponentModel.Meta):
  417. verbose_name = _('console server port')
  418. verbose_name_plural = _('console server ports')
  419. #
  420. # Power components
  421. #
  422. class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
  423. """
  424. A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
  425. """
  426. type = models.CharField(
  427. verbose_name=_('type'),
  428. max_length=50,
  429. choices=PowerPortTypeChoices,
  430. blank=True,
  431. null=True,
  432. help_text=_('Physical port type')
  433. )
  434. maximum_draw = models.PositiveIntegerField(
  435. verbose_name=_('maximum draw'),
  436. blank=True,
  437. null=True,
  438. validators=[MinValueValidator(1)],
  439. help_text=_("Maximum power draw (watts)")
  440. )
  441. allocated_draw = models.PositiveIntegerField(
  442. verbose_name=_('allocated draw'),
  443. blank=True,
  444. null=True,
  445. validators=[MinValueValidator(1)],
  446. help_text=_('Allocated power draw (watts)')
  447. )
  448. clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw')
  449. class Meta(ModularComponentModel.Meta):
  450. verbose_name = _('power port')
  451. verbose_name_plural = _('power ports')
  452. def clean(self):
  453. super().clean()
  454. if self.maximum_draw is not None and self.allocated_draw is not None:
  455. if self.allocated_draw > self.maximum_draw:
  456. raise ValidationError({
  457. 'allocated_draw': _(
  458. "Allocated draw cannot exceed the maximum draw ({maximum_draw}W)."
  459. ).format(maximum_draw=self.maximum_draw)
  460. })
  461. def get_downstream_powerports(self, leg=None):
  462. """
  463. Return a queryset of all PowerPorts connected via cable to a child PowerOutlet. For example, in the topology
  464. below, PP1.get_downstream_powerports() would return PP2-4.
  465. ---- PO1 <---> PP2
  466. /
  467. PP1 ------- PO2 <---> PP3
  468. \
  469. ---- PO3 <---> PP4
  470. """
  471. poweroutlets = self.poweroutlets.filter(cable__isnull=False)
  472. if leg:
  473. poweroutlets = poweroutlets.filter(feed_leg=leg)
  474. if not poweroutlets:
  475. return PowerPort.objects.none()
  476. q = Q()
  477. for poweroutlet in poweroutlets:
  478. q |= Q(
  479. cable=poweroutlet.cable,
  480. cable_end=poweroutlet.opposite_cable_end
  481. )
  482. return PowerPort.objects.filter(q)
  483. def get_power_draw(self, _seen=None):
  484. """
  485. Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
  486. """
  487. from dcim.models import PowerFeed
  488. # Calculate aggregate draw of all child power outlets if no numbers have been defined manually
  489. if self.allocated_draw is None and self.maximum_draw is None:
  490. def _aggregate(powerports, seen):
  491. # Recursively resolve the draw for each downstream PowerPort. Using the per-port value
  492. # (rather than a SQL aggregate over allocated_draw/maximum_draw) allows the draw to
  493. # propagate through intermediate auto-mode PowerPorts, e.g. PDU-internal fuse chains.
  494. # `seen` tracks visited PowerPorts to prevent infinite recursion if the topology
  495. # happens to form a cycle.
  496. allocated_total = 0
  497. maximum_total = 0
  498. for powerport in powerports:
  499. if powerport.pk in seen:
  500. continue
  501. seen.add(powerport.pk)
  502. draw = powerport.get_power_draw(_seen=seen)
  503. allocated_total += draw['allocated']
  504. maximum_total += draw['maximum']
  505. return allocated_total, maximum_total
  506. # Seed each _aggregate() call with a fresh copy of the inherited visited set so the full
  507. # and per-leg aggregations are independent. Otherwise, ports visited during the full
  508. # aggregation would be skipped during the per-leg passes.
  509. base_seen = set(_seen) if _seen else set()
  510. base_seen.add(self.pk)
  511. allocated, maximum = _aggregate(self.get_downstream_powerports(), set(base_seen))
  512. ret = {
  513. 'allocated': allocated,
  514. 'maximum': maximum,
  515. 'outlet_count': self.poweroutlets.count(),
  516. 'legs': [],
  517. }
  518. # Calculate per-leg aggregates for three-phase power feeds
  519. if len(self.link_peers) == 1 and isinstance(self.link_peers[0], PowerFeed) and \
  520. self.link_peers[0].phase == PowerFeedPhaseChoices.PHASE_3PHASE:
  521. for leg, leg_name in PowerOutletFeedLegChoices:
  522. leg_allocated, leg_maximum = _aggregate(
  523. self.get_downstream_powerports(leg=leg), set(base_seen)
  524. )
  525. ret['legs'].append({
  526. 'name': leg_name,
  527. 'allocated': leg_allocated,
  528. 'maximum': leg_maximum,
  529. 'outlet_count': self.poweroutlets.filter(feed_leg=leg).count(),
  530. })
  531. return ret
  532. # Default to administratively defined values
  533. return {
  534. 'allocated': self.allocated_draw or 0,
  535. 'maximum': self.maximum_draw or 0,
  536. 'outlet_count': self.poweroutlets.count(),
  537. 'legs': [],
  538. }
  539. class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
  540. """
  541. A physical power outlet (output) within a Device which provides power to a PowerPort.
  542. """
  543. status = models.CharField(
  544. verbose_name=_('status'),
  545. max_length=50,
  546. choices=PowerOutletStatusChoices,
  547. default=PowerOutletStatusChoices.STATUS_ENABLED
  548. )
  549. type = models.CharField(
  550. verbose_name=_('type'),
  551. max_length=50,
  552. choices=PowerOutletTypeChoices,
  553. blank=True,
  554. null=True,
  555. help_text=_('Physical port type')
  556. )
  557. power_port = models.ForeignKey(
  558. to='dcim.PowerPort',
  559. on_delete=models.SET_NULL,
  560. blank=True,
  561. null=True,
  562. related_name='poweroutlets'
  563. )
  564. feed_leg = models.CharField(
  565. verbose_name=_('feed leg'),
  566. max_length=50,
  567. choices=PowerOutletFeedLegChoices,
  568. blank=True,
  569. null=True,
  570. help_text=_('Phase (for three-phase feeds)')
  571. )
  572. color = ColorField(
  573. verbose_name=_('color'),
  574. blank=True
  575. )
  576. clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg')
  577. class Meta(ModularComponentModel.Meta):
  578. verbose_name = _('power outlet')
  579. verbose_name_plural = _('power outlets')
  580. def clean(self):
  581. super().clean()
  582. # Validate power port assignment
  583. if self.power_port and self.power_port.device != self.device:
  584. raise ValidationError(
  585. _("Parent power port ({power_port}) must belong to the same device").format(power_port=self.power_port)
  586. )
  587. def get_status_color(self):
  588. return PowerOutletStatusChoices.colors.get(self.status)
  589. #
  590. # Interfaces
  591. #
  592. class BaseInterface(models.Model):
  593. """
  594. Abstract base class for fields shared by dcim.Interface and virtualization.VMInterface.
  595. """
  596. enabled = models.BooleanField(
  597. verbose_name=_('enabled'),
  598. default=True
  599. )
  600. mtu = models.PositiveIntegerField(
  601. blank=True,
  602. null=True,
  603. validators=[
  604. MinValueValidator(INTERFACE_MTU_MIN),
  605. MaxValueValidator(INTERFACE_MTU_MAX)
  606. ],
  607. verbose_name=_('MTU')
  608. )
  609. mode = models.CharField(
  610. verbose_name=_('mode'),
  611. max_length=50,
  612. choices=InterfaceModeChoices,
  613. blank=True,
  614. null=True,
  615. help_text=_('IEEE 802.1Q tagging strategy')
  616. )
  617. parent = models.ForeignKey(
  618. to='self',
  619. on_delete=models.RESTRICT,
  620. related_name='child_interfaces',
  621. null=True,
  622. blank=True,
  623. verbose_name=_('parent interface')
  624. )
  625. bridge = models.ForeignKey(
  626. to='self',
  627. on_delete=models.SET_NULL,
  628. related_name='bridge_interfaces',
  629. null=True,
  630. blank=True,
  631. verbose_name=_('bridge interface')
  632. )
  633. untagged_vlan = models.ForeignKey(
  634. to='ipam.VLAN',
  635. on_delete=models.SET_NULL,
  636. related_name='%(class)ss_as_untagged',
  637. null=True,
  638. blank=True,
  639. verbose_name=_('untagged VLAN')
  640. )
  641. tagged_vlans = models.ManyToManyField(
  642. to='ipam.VLAN',
  643. related_name='%(class)ss_as_tagged',
  644. blank=True,
  645. verbose_name=_('tagged VLANs')
  646. )
  647. qinq_svlan = models.ForeignKey(
  648. to='ipam.VLAN',
  649. on_delete=models.SET_NULL,
  650. related_name='%(class)ss_svlan',
  651. null=True,
  652. blank=True,
  653. verbose_name=_('Q-in-Q SVLAN')
  654. )
  655. vlan_translation_policy = models.ForeignKey(
  656. to='ipam.VLANTranslationPolicy',
  657. on_delete=models.PROTECT,
  658. null=True,
  659. blank=True,
  660. verbose_name=_('VLAN Translation Policy')
  661. )
  662. primary_mac_address = models.OneToOneField(
  663. to='dcim.MACAddress',
  664. on_delete=models.SET_NULL,
  665. related_name='+',
  666. blank=True,
  667. null=True,
  668. verbose_name=_('primary MAC address')
  669. )
  670. class Meta:
  671. abstract = True
  672. def clean(self):
  673. super().clean()
  674. # SVLAN can be defined only for Q-in-Q interfaces
  675. if self.qinq_svlan and self.mode != InterfaceModeChoices.MODE_Q_IN_Q:
  676. raise ValidationError({
  677. 'qinq_svlan': _("Only Q-in-Q interfaces may specify a service VLAN.")
  678. })
  679. # Check that the primary MAC address (if any) is assigned to this interface
  680. if (
  681. self.primary_mac_address and
  682. self.primary_mac_address.assigned_object is not None and
  683. self.primary_mac_address.assigned_object != self
  684. ):
  685. raise ValidationError({
  686. 'primary_mac_address': _(
  687. "MAC address {mac_address} is assigned to a different interface ({interface})."
  688. ).format(
  689. mac_address=self.primary_mac_address,
  690. interface=self.primary_mac_address.assigned_object,
  691. )
  692. })
  693. def save(self, *args, **kwargs):
  694. # Remove untagged VLAN assignment for non-802.1Q interfaces
  695. if not self.mode:
  696. self.untagged_vlan = None
  697. # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
  698. if not self._state.adding and self.mode != InterfaceModeChoices.MODE_TAGGED:
  699. self.tagged_vlans.clear()
  700. return super().save(*args, **kwargs)
  701. @property
  702. def tunnel_termination(self):
  703. return self.tunnel_terminations.first()
  704. @property
  705. def count_ipaddresses(self):
  706. return self.ip_addresses.count()
  707. @property
  708. def count_fhrp_groups(self):
  709. return self.fhrp_group_assignments.count()
  710. @cached_property
  711. def mac_address(self):
  712. if self.primary_mac_address:
  713. return self.primary_mac_address.mac_address
  714. return None
  715. class Interface(
  716. InterfaceValidationMixin,
  717. ModularComponentModel,
  718. BaseInterface,
  719. CabledObjectModel,
  720. PathEndpoint,
  721. TrackingModelMixin,
  722. ):
  723. """
  724. A network interface within a Device. A physical Interface can connect to exactly one other Interface.
  725. """
  726. # Override ComponentModel._name to specify naturalize_interface function
  727. _name = NaturalOrderingField(
  728. target_field='name',
  729. naturalize_function=naturalize_interface,
  730. max_length=100,
  731. blank=True
  732. )
  733. vdcs = models.ManyToManyField(
  734. to='dcim.VirtualDeviceContext',
  735. related_name='interfaces'
  736. )
  737. lag = models.ForeignKey(
  738. to='self',
  739. on_delete=models.SET_NULL,
  740. related_name='member_interfaces',
  741. null=True,
  742. blank=True,
  743. verbose_name=_('parent LAG')
  744. )
  745. type = models.CharField(
  746. verbose_name=_('type'),
  747. max_length=50,
  748. choices=InterfaceTypeChoices
  749. )
  750. mgmt_only = models.BooleanField(
  751. default=False,
  752. verbose_name=_('management only'),
  753. help_text=_('This interface is used only for out-of-band management')
  754. )
  755. speed = models.PositiveBigIntegerField(
  756. blank=True,
  757. null=True,
  758. verbose_name=_('speed (Kbps)')
  759. )
  760. duplex = models.CharField(
  761. verbose_name=_('duplex'),
  762. max_length=50,
  763. blank=True,
  764. null=True,
  765. choices=InterfaceDuplexChoices
  766. )
  767. wwn = WWNField(
  768. null=True,
  769. blank=True,
  770. verbose_name=_('WWN'),
  771. help_text=_('64-bit World Wide Name')
  772. )
  773. rf_role = models.CharField(
  774. max_length=30,
  775. choices=WirelessRoleChoices,
  776. blank=True,
  777. null=True,
  778. verbose_name=_('wireless role')
  779. )
  780. rf_channel = models.CharField(
  781. max_length=50,
  782. choices=WirelessChannelChoices,
  783. blank=True,
  784. null=True,
  785. verbose_name=_('wireless channel')
  786. )
  787. rf_channel_frequency = models.DecimalField(
  788. max_digits=8,
  789. decimal_places=3,
  790. blank=True,
  791. null=True,
  792. verbose_name=_('channel frequency (MHz)'),
  793. help_text=_("Populated by selected channel (if set)")
  794. )
  795. rf_channel_width = models.DecimalField(
  796. max_digits=7,
  797. decimal_places=3,
  798. blank=True,
  799. null=True,
  800. verbose_name=('channel width (MHz)'),
  801. help_text=_("Populated by selected channel (if set)")
  802. )
  803. tx_power = models.SmallIntegerField(
  804. blank=True,
  805. null=True,
  806. validators=(
  807. MinValueValidator(-40),
  808. MaxValueValidator(127),
  809. ),
  810. verbose_name=_('transmit power (dBm)')
  811. )
  812. poe_mode = models.CharField(
  813. max_length=50,
  814. choices=InterfacePoEModeChoices,
  815. blank=True,
  816. null=True,
  817. verbose_name=_('PoE mode')
  818. )
  819. poe_type = models.CharField(
  820. max_length=50,
  821. choices=InterfacePoETypeChoices,
  822. blank=True,
  823. null=True,
  824. verbose_name=_('PoE type')
  825. )
  826. wireless_link = models.ForeignKey(
  827. to='wireless.WirelessLink',
  828. on_delete=models.SET_NULL,
  829. related_name='+',
  830. blank=True,
  831. null=True
  832. )
  833. wireless_lans = models.ManyToManyField(
  834. to='wireless.WirelessLAN',
  835. related_name='interfaces',
  836. blank=True,
  837. verbose_name=_('wireless LANs')
  838. )
  839. vrf = models.ForeignKey(
  840. to='ipam.VRF',
  841. on_delete=models.SET_NULL,
  842. related_name='interfaces',
  843. null=True,
  844. blank=True,
  845. verbose_name=_('VRF')
  846. )
  847. ip_addresses = GenericRelation(
  848. to='ipam.IPAddress',
  849. content_type_field='assigned_object_type',
  850. object_id_field='assigned_object_id',
  851. related_query_name='interface'
  852. )
  853. mac_addresses = GenericRelation(
  854. to='dcim.MACAddress',
  855. content_type_field='assigned_object_type',
  856. object_id_field='assigned_object_id',
  857. related_query_name='interface'
  858. )
  859. fhrp_group_assignments = GenericRelation(
  860. to='ipam.FHRPGroupAssignment',
  861. content_type_field='interface_type',
  862. object_id_field='interface_id',
  863. related_query_name='+'
  864. )
  865. tunnel_terminations = GenericRelation(
  866. to='vpn.TunnelTermination',
  867. content_type_field='termination_type',
  868. object_id_field='termination_id',
  869. related_query_name='interface'
  870. )
  871. l2vpn_terminations = GenericRelation(
  872. to='vpn.L2VPNTermination',
  873. content_type_field='assigned_object_type',
  874. object_id_field='assigned_object_id',
  875. related_query_name='interface',
  876. )
  877. clone_fields = (
  878. 'device', 'module', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'mtu', 'mode', 'speed', 'duplex', 'rf_role',
  879. 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'vrf',
  880. )
  881. class Meta(ModularComponentModel.Meta):
  882. ordering = ('device', CollateAsChar('_name'))
  883. verbose_name = _('interface')
  884. verbose_name_plural = _('interfaces')
  885. def clean(self):
  886. super().clean()
  887. # Virtual Interfaces cannot have a Cable attached
  888. if self.is_virtual and self.cable:
  889. raise ValidationError({
  890. 'type': _("{display_type} interfaces cannot have a cable attached.").format(
  891. display_type=self.get_type_display()
  892. )
  893. })
  894. # Virtual Interfaces cannot be marked as connected
  895. if self.is_virtual and self.mark_connected:
  896. raise ValidationError({
  897. 'mark_connected': _("{display_type} interfaces cannot be marked as connected.".format(
  898. display_type=self.get_type_display())
  899. )
  900. })
  901. # Parent validation
  902. # An interface cannot be its own parent
  903. if self.pk and self.parent_id == self.pk:
  904. raise ValidationError({'parent': _("An interface cannot be its own parent.")})
  905. # A physical interface cannot have a parent interface
  906. if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
  907. raise ValidationError({'parent': _("Only virtual interfaces may be assigned to a parent interface.")})
  908. # An interface's parent must belong to the same device or virtual chassis
  909. if self.parent and self.parent.device != self.device:
  910. if self.device.virtual_chassis is None:
  911. raise ValidationError({
  912. 'parent': _(
  913. "The selected parent interface ({interface}) belongs to a different device ({device})"
  914. ).format(interface=self.parent, device=self.parent.device)
  915. })
  916. if self.parent.device.virtual_chassis != self.device.virtual_chassis:
  917. raise ValidationError({
  918. 'parent': _(
  919. "The selected parent interface ({interface}) belongs to {device}, which is not part of "
  920. "virtual chassis {virtual_chassis}."
  921. ).format(
  922. interface=self.parent,
  923. device=self.parent.device,
  924. virtual_chassis=self.device.virtual_chassis
  925. )
  926. })
  927. # Bridge validation
  928. # A bridged interface belongs to the same device or virtual chassis
  929. if self.bridge and self.bridge.device != self.device:
  930. if self.device.virtual_chassis is None:
  931. raise ValidationError({
  932. 'bridge': _(
  933. "The selected bridge interface ({bridge}) belongs to a different device ({device})."
  934. ).format(bridge=self.bridge, device=self.bridge.device)
  935. })
  936. if self.bridge.device.virtual_chassis != self.device.virtual_chassis:
  937. raise ValidationError({
  938. 'bridge': _(
  939. "The selected bridge interface ({interface}) belongs to {device}, which is not part of virtual "
  940. "chassis {virtual_chassis}."
  941. ).format(
  942. interface=self.bridge, device=self.bridge.device, virtual_chassis=self.device.virtual_chassis
  943. )
  944. })
  945. # LAG validation
  946. # A virtual interface cannot have a parent LAG
  947. if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
  948. raise ValidationError({'lag': _("Virtual interfaces cannot have a parent LAG interface.")})
  949. # A LAG interface cannot be its own parent
  950. if self.pk and self.lag_id == self.pk:
  951. raise ValidationError({'lag': _("A LAG interface cannot be its own parent.")})
  952. # An interface's LAG must belong to the same device or virtual chassis
  953. if self.lag and self.lag.device != self.device:
  954. if self.device.virtual_chassis is None:
  955. raise ValidationError({
  956. 'lag': _(
  957. "The selected LAG interface ({lag}) belongs to a different device ({device})."
  958. ).format(lag=self.lag, device=self.lag.device)
  959. })
  960. if self.lag.device.virtual_chassis != self.device.virtual_chassis:
  961. raise ValidationError({
  962. 'lag': _(
  963. "The selected LAG interface ({lag}) belongs to {device}, which is not part of virtual chassis "
  964. "{virtual_chassis}.".format(
  965. lag=self.lag, device=self.lag.device, virtual_chassis=self.device.virtual_chassis)
  966. )
  967. })
  968. # Wireless validation
  969. # RF channel may only be set for wireless interfaces
  970. if self.rf_channel and not self.is_wireless:
  971. raise ValidationError({'rf_channel': _("Channel may be set only on wireless interfaces.")})
  972. # Validate channel frequency against interface type and selected channel (if any)
  973. if self.rf_channel_frequency:
  974. if not self.is_wireless:
  975. raise ValidationError({
  976. 'rf_channel_frequency': _("Channel frequency may be set only on wireless interfaces."),
  977. })
  978. if self.rf_channel and self.rf_channel_frequency != get_channel_attr(self.rf_channel, 'frequency'):
  979. raise ValidationError({
  980. 'rf_channel_frequency': _("Cannot specify custom frequency with channel selected."),
  981. })
  982. # Validate channel width against interface type and selected channel (if any)
  983. if self.rf_channel_width:
  984. if not self.is_wireless:
  985. raise ValidationError({'rf_channel_width': _("Channel width may be set only on wireless interfaces.")})
  986. if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
  987. raise ValidationError({'rf_channel_width': _("Cannot specify custom width with channel selected.")})
  988. # VLAN validation
  989. if not self.mode and self.untagged_vlan:
  990. raise ValidationError({'untagged_vlan': _("Interface mode does not support an untagged vlan.")})
  991. # Validate untagged VLAN
  992. if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
  993. raise ValidationError({
  994. 'untagged_vlan': _(
  995. "The untagged VLAN ({untagged_vlan}) must belong to the same site as the interface's parent "
  996. "device, or it must be global."
  997. ).format(untagged_vlan=self.untagged_vlan)
  998. })
  999. def save(self, *args, **kwargs):
  1000. # Set absolute channel attributes from selected options
  1001. if self.rf_channel and not self.rf_channel_frequency:
  1002. self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
  1003. if self.rf_channel and not self.rf_channel_width:
  1004. self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
  1005. super().save(*args, **kwargs)
  1006. @property
  1007. def _occupied(self):
  1008. return super()._occupied or bool(self.wireless_link_id)
  1009. @property
  1010. def is_wired(self):
  1011. return not self.is_virtual and not self.is_wireless
  1012. @property
  1013. def is_virtual(self):
  1014. return self.type in VIRTUAL_IFACE_TYPES
  1015. @property
  1016. def is_wireless(self):
  1017. return self.type in WIRELESS_IFACE_TYPES
  1018. @property
  1019. def is_lag(self):
  1020. return self.type == InterfaceTypeChoices.TYPE_LAG
  1021. @property
  1022. def is_bridge(self):
  1023. return self.type == InterfaceTypeChoices.TYPE_BRIDGE
  1024. @property
  1025. def link(self):
  1026. return self.cable or self.wireless_link
  1027. @cached_property
  1028. def link_peers(self):
  1029. if self.cable:
  1030. return super().link_peers
  1031. if self.wireless_link:
  1032. # Return the opposite side of the attached wireless link
  1033. if self.wireless_link.interface_a == self:
  1034. return [self.wireless_link.interface_b]
  1035. return [self.wireless_link.interface_a]
  1036. return []
  1037. @property
  1038. def l2vpn_termination(self):
  1039. return self.l2vpn_terminations.first()
  1040. @cached_property
  1041. def connected_endpoints(self):
  1042. # If this is a virtual interface, return the remote endpoint of the connected
  1043. # virtual circuit, if any.
  1044. if self.is_virtual and hasattr(self, 'virtual_circuit_termination'):
  1045. return self.virtual_circuit_termination.peer_terminations
  1046. return super().connected_endpoints
  1047. #
  1048. # Pass-through ports
  1049. #
  1050. class PortMapping(ChangeLoggingMixin, PortMappingBase):
  1051. """
  1052. Maps a FrontPort & position to a RearPort & position.
  1053. """
  1054. device = models.ForeignKey(
  1055. to='dcim.Device',
  1056. on_delete=models.CASCADE,
  1057. related_name='port_mappings',
  1058. )
  1059. front_port = models.ForeignKey(
  1060. to='dcim.FrontPort',
  1061. on_delete=models.CASCADE,
  1062. related_name='mappings',
  1063. )
  1064. rear_port = models.ForeignKey(
  1065. to='dcim.RearPort',
  1066. on_delete=models.CASCADE,
  1067. related_name='mappings',
  1068. )
  1069. class Meta(PortMappingBase.Meta):
  1070. # Inherit the unique constraints from PortMappingBase.Meta.
  1071. pass
  1072. def clean(self):
  1073. super().clean()
  1074. # Both ports must belong to the same device
  1075. if self.front_port.device_id != self.rear_port.device_id:
  1076. raise ValidationError({
  1077. "rear_port": _("Rear port ({rear_port}) must belong to the same device").format(
  1078. rear_port=self.rear_port
  1079. )
  1080. })
  1081. def save(self, *args, **kwargs):
  1082. # Associate the mapping with the parent Device
  1083. self.device = self.front_port.device
  1084. super().save(*args, **kwargs)
  1085. class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
  1086. """
  1087. A pass-through port on the front of a Device.
  1088. """
  1089. type = models.CharField(
  1090. verbose_name=_('type'),
  1091. max_length=50,
  1092. choices=PortTypeChoices
  1093. )
  1094. color = ColorField(
  1095. verbose_name=_('color'),
  1096. blank=True
  1097. )
  1098. positions = models.PositiveSmallIntegerField(
  1099. verbose_name=_('positions'),
  1100. default=1,
  1101. validators=[
  1102. MinValueValidator(PORT_POSITION_MIN),
  1103. MaxValueValidator(PORT_POSITION_MAX)
  1104. ],
  1105. )
  1106. clone_fields = ('device', 'type', 'color', 'positions')
  1107. class Meta(ModularComponentModel.Meta):
  1108. constraints = (
  1109. models.UniqueConstraint(
  1110. fields=('device', 'name'),
  1111. name='%(app_label)s_%(class)s_unique_device_name'
  1112. ),
  1113. )
  1114. verbose_name = _('front port')
  1115. verbose_name_plural = _('front ports')
  1116. def clean(self):
  1117. super().clean()
  1118. # Check that positions is greater than or equal to the number of associated RearPorts
  1119. if not self._state.adding:
  1120. mapping_count = self.mappings.count()
  1121. if self.positions < mapping_count:
  1122. raise ValidationError({
  1123. "positions": _(
  1124. "The number of positions cannot be less than the number of mapped rear ports ({count})"
  1125. ).format(count=mapping_count)
  1126. })
  1127. class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
  1128. """
  1129. A pass-through port on the rear of a Device.
  1130. """
  1131. type = models.CharField(
  1132. verbose_name=_('type'),
  1133. max_length=50,
  1134. choices=PortTypeChoices
  1135. )
  1136. color = ColorField(
  1137. verbose_name=_('color'),
  1138. blank=True
  1139. )
  1140. positions = models.PositiveSmallIntegerField(
  1141. verbose_name=_('positions'),
  1142. default=1,
  1143. validators=[
  1144. MinValueValidator(PORT_POSITION_MIN),
  1145. MaxValueValidator(PORT_POSITION_MAX)
  1146. ],
  1147. )
  1148. clone_fields = ('device', 'type', 'color', 'positions')
  1149. class Meta(ModularComponentModel.Meta):
  1150. verbose_name = _('rear port')
  1151. verbose_name_plural = _('rear ports')
  1152. def clean(self):
  1153. super().clean()
  1154. # Check that positions count is greater than or equal to the number of associated FrontPorts
  1155. if not self._state.adding:
  1156. mapping_count = self.mappings.count()
  1157. if self.positions < mapping_count:
  1158. raise ValidationError({
  1159. "positions": _(
  1160. "The number of positions cannot be less than the number of mapped front ports "
  1161. "({count})"
  1162. ).format(count=mapping_count)
  1163. })
  1164. #
  1165. # Bays
  1166. #
  1167. class ModuleBayManager(TreeManager):
  1168. """
  1169. Order ModuleBays by the natural-sort name of each tree's root, then by MPTT
  1170. left value within the tree. Combined with the root-insert bypass in
  1171. ModuleBay.save(), this lets us keep MPTTMeta.order_insertion_by for cheap
  1172. intra-tree sibling placement while skipping the global tree_id renumbering
  1173. it would otherwise perform on every root insert.
  1174. """
  1175. def get_queryset(self, *args, **kwargs):
  1176. # Use the raw manager to avoid recursing through this get_queryset() when
  1177. # building the annotation subquery.
  1178. root_name = self.model._objects_raw.filter(
  1179. tree_id=models.OuterRef('tree_id'),
  1180. level=0,
  1181. ).values('name')[:1]
  1182. return super().get_queryset(*args, **kwargs).annotate(
  1183. _root_name=models.Subquery(
  1184. root_name,
  1185. output_field=models.CharField(db_collation='natural_sort'),
  1186. )
  1187. ).order_by('_root_name', 'lft')
  1188. class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
  1189. """
  1190. An empty space within a Device which can house a child device
  1191. """
  1192. parent = TreeForeignKey(
  1193. to='self',
  1194. on_delete=models.CASCADE,
  1195. related_name='children',
  1196. blank=True,
  1197. null=True,
  1198. editable=False,
  1199. db_index=True
  1200. )
  1201. position = models.CharField(
  1202. verbose_name=_('position'),
  1203. max_length=30,
  1204. blank=True,
  1205. help_text=_('Identifier to reference when renaming installed components')
  1206. )
  1207. enabled = models.BooleanField(
  1208. verbose_name=_('enabled'),
  1209. default=True,
  1210. )
  1211. objects = ModuleBayManager()
  1212. # Plain TreeManager used by ModuleBayManager to build the _root_name subquery
  1213. # without recursing through our annotated get_queryset().
  1214. _objects_raw = TreeManager()
  1215. clone_fields = ('device', 'enabled')
  1216. class Meta(ModularComponentModel.Meta):
  1217. # Empty tuple triggers Django migration detection for MPTT indexes
  1218. # (see #21016, django-mptt/django-mptt#682)
  1219. indexes = ()
  1220. constraints = (
  1221. models.UniqueConstraint(
  1222. fields=('device', 'module', 'name'),
  1223. name='%(app_label)s_%(class)s_unique_device_module_name'
  1224. ),
  1225. )
  1226. verbose_name = _('module bay')
  1227. verbose_name_plural = _('module bays')
  1228. class MPTTMeta:
  1229. # Used for placing siblings within a single tree at insert time. Costs
  1230. # are bounded to that tree's rows. Cross-tree (root) insertion is
  1231. # handled in save() to avoid the O(N) tree_id shift this would trigger.
  1232. order_insertion_by = ('name',)
  1233. def clean(self):
  1234. super().clean()
  1235. # Check for recursion
  1236. if module := self.module:
  1237. module_bays = [self.pk]
  1238. modules = []
  1239. while module:
  1240. if module.pk in modules or module.module_bay.pk in module_bays:
  1241. raise ValidationError(_("A module bay cannot belong to a module installed within it."))
  1242. modules.append(module.pk)
  1243. module_bays.append(module.module_bay.pk)
  1244. module = module.module_bay.module if module.module_bay else None
  1245. def save(self, *args, **kwargs):
  1246. if self.module:
  1247. self.parent = self.module.module_bay
  1248. else:
  1249. self.parent = None
  1250. opts = self._mptt_meta
  1251. # For new root nodes, allocate the next tree_id and pre-set MPTT fields
  1252. # so super().save() skips MPTT's order_insertion_by-driven insertion
  1253. # path. That path would otherwise UPDATE every later tree_id on each
  1254. # root insert (NB-2800). Children still go through MPTT, which keeps
  1255. # siblings in name order via the same order_insertion_by setting.
  1256. if self._state.adding and self.parent_id is None and not self.lft and not self.rght:
  1257. max_tree_id = ModuleBay._objects_raw.aggregate(
  1258. models.Max('tree_id')
  1259. )['tree_id__max'] or 0
  1260. self.tree_id = max_tree_id + 1
  1261. self.lft = 1
  1262. self.rght = 2
  1263. self.level = 0
  1264. elif (
  1265. not self._state.adding
  1266. and self.parent_id is None
  1267. and self._mptt_cached_fields.get(opts.parent_attr) is None
  1268. ):
  1269. # Existing root being updated. Spoof the cached order_insertion_by
  1270. # values so MPTT's same_order check passes and it skips its reorder
  1271. # path, which would UPDATE every later tree_id on each root rename.
  1272. # ModuleBayManager._root_name recovers display order at query time,
  1273. # so the tree_id reshuffling would be wasted work. Child renames
  1274. # and root<->child transitions intentionally fall through to MPTT.
  1275. for field_name in opts.order_insertion_by:
  1276. field_name = field_name.lstrip('-')
  1277. self._mptt_cached_fields[field_name] = opts.get_raw_field_value(
  1278. self, field_name
  1279. )
  1280. super().save(*args, **kwargs)
  1281. @property
  1282. def _occupied(self):
  1283. """
  1284. Indicates whether the module bay is occupied by a module.
  1285. """
  1286. return bool(not self.enabled or hasattr(self, 'installed_module'))
  1287. class DeviceBay(ComponentModel, TrackingModelMixin):
  1288. """
  1289. An empty space within a Device which can house a child device
  1290. """
  1291. installed_device = models.OneToOneField(
  1292. to='dcim.Device',
  1293. on_delete=models.SET_NULL,
  1294. related_name='parent_bay',
  1295. blank=True,
  1296. null=True
  1297. )
  1298. enabled = models.BooleanField(
  1299. verbose_name=_('enabled'),
  1300. default=True,
  1301. )
  1302. clone_fields = ('device', 'enabled')
  1303. class Meta(ComponentModel.Meta):
  1304. verbose_name = _('device bay')
  1305. verbose_name_plural = _('device bays')
  1306. def clean(self):
  1307. super().clean()
  1308. # Validate that the parent Device can have DeviceBays
  1309. if hasattr(self, 'device') and not self.device.device_type.is_parent_device:
  1310. raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format(
  1311. device_type=self.device.device_type
  1312. ))
  1313. # Prevent installing a device into a disabled bay
  1314. if self.installed_device and not self.enabled:
  1315. current_installed_device_id = (
  1316. DeviceBay.objects.filter(pk=self.pk).values_list('installed_device_id', flat=True).first()
  1317. )
  1318. if self.pk is None or current_installed_device_id != self.installed_device_id:
  1319. raise ValidationError({
  1320. 'installed_device': _("Cannot install a device in a disabled device bay.")
  1321. })
  1322. # Cannot install a device into itself, obviously
  1323. if self.installed_device and getattr(self, 'device', None) == self.installed_device:
  1324. raise ValidationError(_("Cannot install a device into itself."))
  1325. # Check that the installed device is not already installed elsewhere
  1326. if self.installed_device:
  1327. current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
  1328. if current_bay and current_bay != self:
  1329. raise ValidationError({
  1330. 'installed_device': _(
  1331. "Cannot install the specified device; device is already installed in {bay}."
  1332. ).format(bay=current_bay)
  1333. })
  1334. @property
  1335. def _occupied(self):
  1336. """
  1337. Indicates whether the device bay is occupied by a child device.
  1338. """
  1339. return bool(not self.enabled or self.installed_device_id)
  1340. #
  1341. # Inventory items
  1342. #
  1343. class InventoryItemRole(OrganizationalModel):
  1344. """
  1345. Inventory items may optionally be assigned a functional role.
  1346. """
  1347. color = ColorField(
  1348. verbose_name=_('color'),
  1349. default=ColorChoices.COLOR_GREY
  1350. )
  1351. class Meta:
  1352. ordering = ('name',)
  1353. verbose_name = _('inventory item role')
  1354. verbose_name_plural = _('inventory item roles')
  1355. class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
  1356. """
  1357. An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
  1358. InventoryItems are used only for inventory purposes.
  1359. """
  1360. parent = TreeForeignKey(
  1361. to='self',
  1362. on_delete=models.CASCADE,
  1363. related_name='child_items',
  1364. blank=True,
  1365. null=True,
  1366. db_index=True
  1367. )
  1368. component_type = models.ForeignKey(
  1369. to='contenttypes.ContentType',
  1370. on_delete=models.PROTECT,
  1371. related_name='+',
  1372. blank=True,
  1373. null=True
  1374. )
  1375. component_id = models.PositiveBigIntegerField(
  1376. blank=True,
  1377. null=True
  1378. )
  1379. component = GenericForeignKey(
  1380. ct_field='component_type',
  1381. fk_field='component_id'
  1382. )
  1383. status = models.CharField(
  1384. verbose_name=_('status'),
  1385. max_length=50,
  1386. choices=InventoryItemStatusChoices,
  1387. default=InventoryItemStatusChoices.STATUS_ACTIVE
  1388. )
  1389. role = models.ForeignKey(
  1390. to='dcim.InventoryItemRole',
  1391. on_delete=models.PROTECT,
  1392. related_name='inventory_items',
  1393. blank=True,
  1394. null=True
  1395. )
  1396. manufacturer = models.ForeignKey(
  1397. to='dcim.Manufacturer',
  1398. on_delete=models.PROTECT,
  1399. related_name='inventory_items',
  1400. blank=True,
  1401. null=True
  1402. )
  1403. part_id = models.CharField(
  1404. max_length=50,
  1405. verbose_name=_('part ID'),
  1406. blank=True,
  1407. help_text=_('Manufacturer-assigned part identifier')
  1408. )
  1409. serial = models.CharField(
  1410. max_length=50,
  1411. verbose_name=_('serial number'),
  1412. blank=True
  1413. )
  1414. asset_tag = models.CharField(
  1415. max_length=50,
  1416. unique=True,
  1417. blank=True,
  1418. null=True,
  1419. verbose_name=_('asset tag'),
  1420. help_text=_('A unique tag used to identify this item')
  1421. )
  1422. discovered = models.BooleanField(
  1423. verbose_name=_('discovered'),
  1424. default=False,
  1425. help_text=_('This item was automatically discovered')
  1426. )
  1427. objects = TreeManager()
  1428. clone_fields = ('device', 'parent', 'role', 'manufacturer', 'status', 'part_id')
  1429. class Meta:
  1430. ordering = ('device__id', 'parent__id', 'name')
  1431. indexes = (
  1432. models.Index(fields=('component_type', 'component_id')),
  1433. )
  1434. constraints = (
  1435. models.UniqueConstraint(
  1436. fields=('device', 'parent', 'name'),
  1437. name='%(app_label)s_%(class)s_unique_device_parent_name'
  1438. ),
  1439. )
  1440. verbose_name = _('inventory item')
  1441. verbose_name_plural = _('inventory items')
  1442. def clean(self):
  1443. super().clean()
  1444. # An InventoryItem cannot be its own parent
  1445. if self.pk and self.parent_id == self.pk:
  1446. raise ValidationError({
  1447. "parent": _("Cannot assign self as parent.")
  1448. })
  1449. # Validation for moving InventoryItems
  1450. if not self._state.adding:
  1451. # Cannot move an InventoryItem to another device if it has a parent
  1452. if self.parent and self.parent.device != self.device:
  1453. raise ValidationError({
  1454. "parent": _("Parent inventory item does not belong to the same device.")
  1455. })
  1456. # Prevent moving InventoryItems with children
  1457. first_child = self.get_children().first()
  1458. if first_child and first_child.device != self.device:
  1459. raise ValidationError(_("Cannot move an inventory item with dependent children"))
  1460. # When moving an InventoryItem to another device, remove any associated component
  1461. if self.component and self.component.device != self.device:
  1462. self.component = None
  1463. else:
  1464. if self.component and self.component.device != self.device:
  1465. raise ValidationError({
  1466. "device": _("Cannot assign inventory item to component on another device")
  1467. })
  1468. def get_status_color(self):
  1469. return InventoryItemStatusChoices.colors.get(self.status)