device_component_templates.py 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112
  1. from django.contrib.contenttypes.fields import GenericForeignKey
  2. from django.contrib.postgres.indexes import GistIndex
  3. from django.core.exceptions import ValidationError
  4. from django.core.validators import MaxValueValidator, MinValueValidator
  5. from django.db import models
  6. from django.utils.translation import gettext_lazy as _
  7. from dcim.choices import *
  8. from dcim.constants import *
  9. from dcim.models.base import PortMappingBase
  10. from dcim.models.mixins import DiameterMixin, InterfaceChannelRenameMixin, InterfaceValidationMixin, MaxFlowMixin
  11. from dcim.utils import get_module_bay_positions, resolve_module_placeholder
  12. from netbox.models import ChangeLoggedModel
  13. from netbox.models.features import ChangeLoggingMixin
  14. from netbox.models.ltree import LtreeManager, LtreeModel
  15. from utilities.exceptions import AbortRequest
  16. from utilities.fields import ColorField, NaturalOrderingField
  17. from utilities.ordering import naturalize_interface
  18. from utilities.tracking import TrackingModelMixin
  19. from wireless.choices import WirelessRoleChoices
  20. from .device_components import (
  21. ConsolePort,
  22. ConsoleServerPort,
  23. CoolingIntake,
  24. CoolingOutflow,
  25. DeviceBay,
  26. FrontPort,
  27. Interface,
  28. InventoryItem,
  29. ModuleBay,
  30. PowerOutlet,
  31. PowerPort,
  32. RearPort,
  33. )
  34. __all__ = (
  35. 'ConsolePortTemplate',
  36. 'ConsoleServerPortTemplate',
  37. 'CoolingIntakeTemplate',
  38. 'CoolingOutflowTemplate',
  39. 'DeviceBayTemplate',
  40. 'FrontPortTemplate',
  41. 'InterfaceTemplate',
  42. 'InventoryItemTemplate',
  43. 'ModuleBayTemplate',
  44. 'PortTemplateMapping',
  45. 'PowerOutletTemplate',
  46. 'PowerPortTemplate',
  47. 'RearPortTemplate',
  48. )
  49. class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
  50. device_type = models.ForeignKey(
  51. to='dcim.DeviceType',
  52. on_delete=models.CASCADE,
  53. related_name='%(class)ss'
  54. )
  55. name = models.CharField(
  56. verbose_name=_('name'),
  57. max_length=64,
  58. help_text=_(
  59. "{module} is accepted as a substitution for the module bay position when attached to a module type."
  60. ),
  61. db_collation="natural_sort"
  62. )
  63. label = models.CharField(
  64. verbose_name=_('label'),
  65. max_length=64,
  66. blank=True,
  67. help_text=_('Physical label')
  68. )
  69. description = models.CharField(
  70. verbose_name=_('description'),
  71. max_length=200,
  72. blank=True
  73. )
  74. class Meta:
  75. abstract = True
  76. ordering = ('device_type', 'name')
  77. constraints = (
  78. models.UniqueConstraint(
  79. fields=('device_type', 'name'),
  80. name='%(app_label)s_%(class)s_unique_device_type_name'
  81. ),
  82. )
  83. def __str__(self):
  84. if self.label:
  85. return f"{self.name} ({self.label})"
  86. return self.name
  87. def instantiate(self, device):
  88. """
  89. Instantiate a new component on the specified Device.
  90. """
  91. raise NotImplementedError()
  92. def __init__(self, *args, **kwargs):
  93. super().__init__(*args, **kwargs)
  94. # Cache the original DeviceType ID for reference under clean()
  95. self._original_device_type = self.__dict__.get('device_type_id')
  96. def to_objectchange(self, action):
  97. objectchange = super().to_objectchange(action)
  98. objectchange.related_object = self.device_type
  99. return objectchange
  100. def clean(self):
  101. super().clean()
  102. if not self._state.adding and self._original_device_type != self.device_type_id:
  103. raise ValidationError({
  104. "device_type": _("Component templates cannot be moved to a different device type.")
  105. })
  106. class ModularComponentTemplateModel(ComponentTemplateModel):
  107. """
  108. A ComponentTemplateModel which supports optional assignment to a ModuleType.
  109. """
  110. device_type = models.ForeignKey(
  111. to='dcim.DeviceType',
  112. on_delete=models.CASCADE,
  113. related_name='%(class)ss',
  114. blank=True,
  115. null=True
  116. )
  117. module_type = models.ForeignKey(
  118. to='dcim.ModuleType',
  119. on_delete=models.CASCADE,
  120. related_name='%(class)ss',
  121. blank=True,
  122. null=True
  123. )
  124. class Meta:
  125. abstract = True
  126. ordering = ('device_type', 'module_type', 'name')
  127. constraints = (
  128. models.UniqueConstraint(
  129. fields=('device_type', 'name'),
  130. name='%(app_label)s_%(class)s_unique_device_type_name'
  131. ),
  132. models.UniqueConstraint(
  133. fields=('module_type', 'name'),
  134. name='%(app_label)s_%(class)s_unique_module_type_name'
  135. ),
  136. )
  137. indexes = (
  138. models.Index(fields=('device_type', 'module_type', 'name')), # Default ordering
  139. )
  140. def to_objectchange(self, action):
  141. objectchange = super().to_objectchange(action)
  142. if self.device_type is not None:
  143. objectchange.related_object = self.device_type
  144. elif self.module_type is not None:
  145. objectchange.related_object = self.module_type
  146. return objectchange
  147. def clean(self):
  148. super().clean()
  149. # A component template must belong to a DeviceType *or* to a ModuleType
  150. if self.device_type and self.module_type:
  151. raise ValidationError(
  152. _("A component template cannot be associated with both a device type and a module type.")
  153. )
  154. if not self.device_type and not self.module_type:
  155. raise ValidationError(
  156. _("A component template must be associated with either a device type or a module type.")
  157. )
  158. @staticmethod
  159. def _resolve_vc_position(value: str, device) -> str:
  160. """
  161. Resolves {vc_position} and {vc_position:X} tokens.
  162. If the device has a vc_position, replaces the token with that value.
  163. Otherwise uses the explicit fallback X if given, else '0'.
  164. """
  165. def replacer(match):
  166. explicit_fallback = match.group(1)
  167. if (
  168. device is not None
  169. and device.virtual_chassis is not None
  170. and device.vc_position is not None
  171. ):
  172. return str(device.vc_position)
  173. return explicit_fallback if explicit_fallback is not None else '0'
  174. return VC_POSITION_RE.sub(replacer, value)
  175. def _resolve_all_placeholders(self, value, module=None, device=None):
  176. has_module = MODULE_TOKEN in value
  177. has_vc = VC_POSITION_RE.search(value) is not None
  178. if not has_module and not has_vc:
  179. return value
  180. if has_module and module:
  181. # Reached only from Module._save_new(); AbortRequest is what the view/viewset catches.
  182. try:
  183. positions = get_module_bay_positions(module.module_bay)
  184. value = resolve_module_placeholder(value, positions)
  185. except ValueError as e:
  186. raise AbortRequest(str(e)) from e
  187. if has_vc:
  188. resolved_device = (module.device if module else None) or device
  189. value = self._resolve_vc_position(value, resolved_device)
  190. return value
  191. def resolve_name(self, module=None, device=None):
  192. return self._resolve_all_placeholders(self.name, module, device)
  193. def resolve_label(self, module=None, device=None):
  194. return self._resolve_all_placeholders(self.label, module, device)
  195. def resolve_position(self, module=None, device=None):
  196. return self._resolve_all_placeholders(self.position, module, device)
  197. class ConsolePortTemplate(ModularComponentTemplateModel):
  198. """
  199. A template for a ConsolePort to be created for a new Device.
  200. """
  201. type = models.CharField(
  202. verbose_name=_('type'),
  203. max_length=50,
  204. choices=ConsolePortTypeChoices,
  205. blank=True,
  206. null=True
  207. )
  208. component_model = ConsolePort
  209. class Meta(ModularComponentTemplateModel.Meta):
  210. verbose_name = _('console port template')
  211. verbose_name_plural = _('console port templates')
  212. def instantiate(self, **kwargs):
  213. return self.component_model(
  214. name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
  215. label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
  216. type=self.type,
  217. **kwargs
  218. )
  219. def to_yaml(self):
  220. return {
  221. 'name': self.name,
  222. 'type': self.type,
  223. 'label': self.label,
  224. 'description': self.description,
  225. }
  226. class ConsoleServerPortTemplate(ModularComponentTemplateModel):
  227. """
  228. A template for a ConsoleServerPort to be created for a new Device.
  229. """
  230. type = models.CharField(
  231. verbose_name=_('type'),
  232. max_length=50,
  233. choices=ConsolePortTypeChoices,
  234. blank=True,
  235. null=True
  236. )
  237. component_model = ConsoleServerPort
  238. class Meta(ModularComponentTemplateModel.Meta):
  239. verbose_name = _('console server port template')
  240. verbose_name_plural = _('console server port templates')
  241. def instantiate(self, **kwargs):
  242. return self.component_model(
  243. name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
  244. label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
  245. type=self.type,
  246. **kwargs
  247. )
  248. instantiate.do_not_call_in_templates = True
  249. def to_yaml(self):
  250. return {
  251. 'name': self.name,
  252. 'type': self.type,
  253. 'label': self.label,
  254. 'description': self.description,
  255. }
  256. class PowerPortTemplate(ModularComponentTemplateModel):
  257. """
  258. A template for a PowerPort to be created for a new Device.
  259. """
  260. type = models.CharField(
  261. verbose_name=_('type'),
  262. max_length=50,
  263. choices=PowerPortTypeChoices,
  264. blank=True,
  265. null=True
  266. )
  267. maximum_draw = models.PositiveIntegerField(
  268. verbose_name=_('maximum draw'),
  269. blank=True,
  270. null=True,
  271. validators=[MinValueValidator(1)],
  272. help_text=_('Maximum power draw (watts)')
  273. )
  274. allocated_draw = models.PositiveIntegerField(
  275. verbose_name=_('allocated draw'),
  276. blank=True,
  277. null=True,
  278. validators=[MinValueValidator(1)],
  279. help_text=_('Allocated power draw (watts)')
  280. )
  281. component_model = PowerPort
  282. class Meta(ModularComponentTemplateModel.Meta):
  283. verbose_name = _('power port template')
  284. verbose_name_plural = _('power port templates')
  285. def instantiate(self, **kwargs):
  286. return self.component_model(
  287. name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
  288. label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
  289. type=self.type,
  290. maximum_draw=self.maximum_draw,
  291. allocated_draw=self.allocated_draw,
  292. **kwargs
  293. )
  294. instantiate.do_not_call_in_templates = True
  295. def clean(self):
  296. super().clean()
  297. if self.maximum_draw is not None and self.allocated_draw is not None:
  298. if self.allocated_draw > self.maximum_draw:
  299. raise ValidationError({
  300. 'allocated_draw': _(
  301. "Allocated draw cannot exceed the maximum draw ({maximum_draw}W)."
  302. ).format(maximum_draw=self.maximum_draw)
  303. })
  304. def to_yaml(self):
  305. return {
  306. 'name': self.name,
  307. 'type': self.type,
  308. 'maximum_draw': self.maximum_draw,
  309. 'allocated_draw': self.allocated_draw,
  310. 'label': self.label,
  311. 'description': self.description,
  312. }
  313. class PowerOutletTemplate(ModularComponentTemplateModel):
  314. """
  315. A template for a PowerOutlet to be created for a new Device.
  316. """
  317. type = models.CharField(
  318. verbose_name=_('type'),
  319. max_length=50,
  320. choices=PowerOutletTypeChoices,
  321. blank=True,
  322. null=True
  323. )
  324. color = ColorField(
  325. verbose_name=_('color'),
  326. blank=True
  327. )
  328. power_port = models.ForeignKey(
  329. to='dcim.PowerPortTemplate',
  330. on_delete=models.SET_NULL,
  331. blank=True,
  332. null=True,
  333. related_name='poweroutlet_templates'
  334. )
  335. feed_leg = models.CharField(
  336. verbose_name=_('feed leg'),
  337. max_length=50,
  338. choices=PowerOutletFeedLegChoices,
  339. blank=True,
  340. null=True,
  341. help_text=_('Phase (for three-phase feeds)')
  342. )
  343. component_model = PowerOutlet
  344. class Meta(ModularComponentTemplateModel.Meta):
  345. verbose_name = _('power outlet template')
  346. verbose_name_plural = _('power outlet templates')
  347. def clean(self):
  348. super().clean()
  349. # Validate power port assignment
  350. if self.power_port:
  351. if self.device_type and self.power_port.device_type != self.device_type:
  352. raise ValidationError(
  353. _("Parent power port ({power_port}) must belong to the same device type").format(
  354. power_port=self.power_port
  355. )
  356. )
  357. if self.module_type and self.power_port.module_type != self.module_type:
  358. raise ValidationError(
  359. _("Parent power port ({power_port}) must belong to the same module type").format(
  360. power_port=self.power_port
  361. )
  362. )
  363. def instantiate(self, **kwargs):
  364. if self.power_port:
  365. power_port_name = self.power_port.resolve_name(kwargs.get('module'), kwargs.get('device'))
  366. power_port = PowerPort.objects.get(name=power_port_name, **kwargs)
  367. else:
  368. power_port = None
  369. return self.component_model(
  370. name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
  371. label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
  372. type=self.type,
  373. color=self.color,
  374. power_port=power_port,
  375. feed_leg=self.feed_leg,
  376. **kwargs
  377. )
  378. instantiate.do_not_call_in_templates = True
  379. def to_yaml(self):
  380. return {
  381. 'name': self.name,
  382. 'type': self.type,
  383. 'color': self.color,
  384. 'power_port': self.power_port.name if self.power_port else None,
  385. 'feed_leg': self.feed_leg,
  386. 'label': self.label,
  387. 'description': self.description,
  388. }
  389. class CoolingIntakeTemplate(DiameterMixin, MaxFlowMixin, ModularComponentTemplateModel):
  390. """
  391. A template for a CoolingIntake to be created for a new Device.
  392. """
  393. type = models.CharField(
  394. verbose_name=_('type'),
  395. max_length=50,
  396. choices=CoolingConnectorTypeChoices,
  397. blank=True,
  398. null=True
  399. )
  400. # diameter, diameter_unit, _abs_diameter provided by DiameterMixin
  401. # max_flow, max_flow_unit, _abs_max_flow provided by MaxFlowMixin
  402. component_model = CoolingIntake
  403. class Meta(ModularComponentTemplateModel.Meta):
  404. verbose_name = _('cooling intake template')
  405. verbose_name_plural = _('cooling intake templates')
  406. def instantiate(self, **kwargs):
  407. component = self.component_model(
  408. name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
  409. label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
  410. type=self.type,
  411. diameter=self.diameter,
  412. diameter_unit=self.diameter_unit,
  413. max_flow=self.max_flow,
  414. max_flow_unit=self.max_flow_unit,
  415. **kwargs
  416. )
  417. # bulk_create bypasses save(), so populate the normalized _abs_* fields here
  418. component.normalize_diameter()
  419. component.normalize_max_flow()
  420. return component
  421. instantiate.do_not_call_in_templates = True
  422. def to_yaml(self):
  423. return {
  424. 'name': self.name,
  425. 'type': self.type,
  426. 'diameter': float(self.diameter) if self.diameter is not None else None,
  427. 'diameter_unit': self.diameter_unit,
  428. 'max_flow': float(self.max_flow) if self.max_flow is not None else None,
  429. 'max_flow_unit': self.max_flow_unit,
  430. 'label': self.label,
  431. 'description': self.description,
  432. }
  433. class CoolingOutflowTemplate(DiameterMixin, ModularComponentTemplateModel):
  434. """
  435. A template for a CoolingOutflow to be created for a new Device.
  436. """
  437. type = models.CharField(
  438. verbose_name=_('type'),
  439. max_length=50,
  440. choices=CoolingConnectorTypeChoices,
  441. blank=True,
  442. null=True
  443. )
  444. # diameter, diameter_unit, _abs_diameter provided by DiameterMixin
  445. cooling_intake = models.ForeignKey(
  446. to='dcim.CoolingIntakeTemplate',
  447. on_delete=models.SET_NULL,
  448. blank=True,
  449. null=True,
  450. related_name='coolingoutflow_templates'
  451. )
  452. component_model = CoolingOutflow
  453. class Meta(ModularComponentTemplateModel.Meta):
  454. verbose_name = _('cooling outflow template')
  455. verbose_name_plural = _('cooling outflow templates')
  456. def clean(self):
  457. super().clean()
  458. # Validate cooling intake assignment
  459. if self.cooling_intake:
  460. if self.device_type and self.cooling_intake.device_type != self.device_type:
  461. raise ValidationError(
  462. _("Parent cooling intake ({cooling_intake}) must belong to the same device type").format(
  463. cooling_intake=self.cooling_intake
  464. )
  465. )
  466. if self.module_type and self.cooling_intake.module_type != self.module_type:
  467. raise ValidationError(
  468. _("Parent cooling intake ({cooling_intake}) must belong to the same module type").format(
  469. cooling_intake=self.cooling_intake
  470. )
  471. )
  472. def instantiate(self, **kwargs):
  473. if self.cooling_intake:
  474. cooling_intake_name = self.cooling_intake.resolve_name(kwargs.get('module'), kwargs.get('device'))
  475. cooling_intake = CoolingIntake.objects.get(name=cooling_intake_name, **kwargs)
  476. else:
  477. cooling_intake = None
  478. component = self.component_model(
  479. name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
  480. label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
  481. type=self.type,
  482. diameter=self.diameter,
  483. diameter_unit=self.diameter_unit,
  484. cooling_intake=cooling_intake,
  485. **kwargs
  486. )
  487. # bulk_create bypasses save(), so populate the normalized _abs_diameter here
  488. component.normalize_diameter()
  489. return component
  490. instantiate.do_not_call_in_templates = True
  491. def to_yaml(self):
  492. return {
  493. 'name': self.name,
  494. 'type': self.type,
  495. 'diameter': float(self.diameter) if self.diameter is not None else None,
  496. 'diameter_unit': self.diameter_unit,
  497. 'cooling_intake': self.cooling_intake.name if self.cooling_intake else None,
  498. 'label': self.label,
  499. 'description': self.description,
  500. }
  501. class InterfaceTemplate(InterfaceChannelRenameMixin, InterfaceValidationMixin, ModularComponentTemplateModel):
  502. """
  503. A template for a physical data interface on a new Device.
  504. """
  505. # Override ComponentTemplateModel._name to specify naturalize_interface function
  506. _name = NaturalOrderingField(
  507. target_field='name',
  508. naturalize_function=naturalize_interface,
  509. max_length=100,
  510. blank=True
  511. )
  512. type = models.CharField(
  513. verbose_name=_('type'),
  514. max_length=50,
  515. choices=InterfaceTypeChoices
  516. )
  517. channels = models.PositiveSmallIntegerField(
  518. verbose_name=_('channels'),
  519. blank=True,
  520. null=True,
  521. validators=(
  522. MinValueValidator(INTERFACE_CHANNELS_MIN),
  523. MaxValueValidator(INTERFACE_CHANNELS_MAX)
  524. ),
  525. help_text=_('The number of channels into which this interface is channelized')
  526. )
  527. channel_id = models.PositiveSmallIntegerField(
  528. verbose_name=_('channel ID'),
  529. blank=True,
  530. null=True,
  531. validators=(
  532. MinValueValidator(INTERFACE_CHANNELS_MIN),
  533. MaxValueValidator(INTERFACE_CHANNELS_MAX)
  534. ),
  535. help_text=_('The channel on the parent interface to which this subinterface is bound')
  536. )
  537. enabled = models.BooleanField(
  538. verbose_name=_('enabled'),
  539. default=True
  540. )
  541. mgmt_only = models.BooleanField(
  542. default=False,
  543. verbose_name=_('management only')
  544. )
  545. parent = models.ForeignKey(
  546. to='self',
  547. on_delete=models.RESTRICT,
  548. related_name='child_interfaces',
  549. null=True,
  550. blank=True,
  551. verbose_name=_('parent interface')
  552. )
  553. bridge = models.ForeignKey(
  554. to='self',
  555. on_delete=models.SET_NULL,
  556. related_name='bridge_interfaces',
  557. null=True,
  558. blank=True,
  559. verbose_name=_('bridge interface')
  560. )
  561. poe_mode = models.CharField(
  562. max_length=50,
  563. choices=InterfacePoEModeChoices,
  564. blank=True,
  565. null=True,
  566. verbose_name=_('PoE mode')
  567. )
  568. poe_type = models.CharField(
  569. max_length=50,
  570. choices=InterfacePoETypeChoices,
  571. blank=True,
  572. null=True,
  573. verbose_name=_('PoE type')
  574. )
  575. rf_role = models.CharField(
  576. max_length=30,
  577. choices=WirelessRoleChoices,
  578. blank=True,
  579. null=True,
  580. verbose_name=_('wireless role')
  581. )
  582. component_model = Interface
  583. class Meta(ModularComponentTemplateModel.Meta):
  584. constraints = (
  585. *ModularComponentTemplateModel.Meta.constraints,
  586. models.UniqueConstraint(
  587. fields=('parent', 'channel_id'),
  588. name='%(app_label)s_%(class)s_unique_parent_channel_id'
  589. ),
  590. )
  591. verbose_name = _('interface template')
  592. verbose_name_plural = _('interface templates')
  593. def clean(self):
  594. super().clean()
  595. # Self-reference and interface-type restrictions are enforced by InterfaceValidationMixin
  596. if self.parent:
  597. if self.device_type and self.device_type != self.parent.device_type:
  598. raise ValidationError({
  599. 'parent': _(
  600. "Parent interface ({parent}) must belong to the same device type"
  601. ).format(parent=self.parent)
  602. })
  603. if self.module_type and self.module_type != self.parent.module_type:
  604. raise ValidationError({
  605. 'parent': _(
  606. "Parent interface ({parent}) must belong to the same module type"
  607. ).format(parent=self.parent)
  608. })
  609. if self.bridge:
  610. if self.device_type and self.device_type != self.bridge.device_type:
  611. raise ValidationError({
  612. 'bridge': _(
  613. "Bridge interface ({bridge}) must belong to the same device type"
  614. ).format(bridge=self.bridge)
  615. })
  616. if self.module_type and self.module_type != self.bridge.module_type:
  617. raise ValidationError({
  618. 'bridge': _(
  619. "Bridge interface ({bridge}) must belong to the same module type"
  620. ).format(bridge=self.bridge)
  621. })
  622. def instantiate(self, **kwargs):
  623. return self.component_model(
  624. name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
  625. label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
  626. type=self.type,
  627. channels=self.channels,
  628. channel_id=self.channel_id,
  629. enabled=self.enabled,
  630. mgmt_only=self.mgmt_only,
  631. poe_mode=self.poe_mode,
  632. poe_type=self.poe_type,
  633. rf_role=self.rf_role,
  634. **kwargs
  635. )
  636. instantiate.do_not_call_in_templates = True
  637. def to_yaml(self):
  638. return {
  639. 'name': self.name,
  640. 'type': self.type,
  641. 'channels': self.channels,
  642. 'channel_id': self.channel_id,
  643. 'enabled': self.enabled,
  644. 'mgmt_only': self.mgmt_only,
  645. 'label': self.label,
  646. 'description': self.description,
  647. 'parent': self.parent.name if self.parent else None,
  648. 'bridge': self.bridge.name if self.bridge else None,
  649. 'poe_mode': self.poe_mode,
  650. 'poe_type': self.poe_type,
  651. 'rf_role': self.rf_role,
  652. }
  653. class PortTemplateMapping(ChangeLoggingMixin, PortMappingBase):
  654. """
  655. Maps a FrontPortTemplate & position to a RearPortTemplate & position.
  656. """
  657. device_type = models.ForeignKey(
  658. to='dcim.DeviceType',
  659. on_delete=models.CASCADE,
  660. related_name='port_mappings',
  661. blank=True,
  662. null=True,
  663. )
  664. module_type = models.ForeignKey(
  665. to='dcim.ModuleType',
  666. on_delete=models.CASCADE,
  667. related_name='port_mappings',
  668. blank=True,
  669. null=True,
  670. )
  671. front_port = models.ForeignKey(
  672. to='dcim.FrontPortTemplate',
  673. on_delete=models.CASCADE,
  674. related_name='mappings',
  675. )
  676. rear_port = models.ForeignKey(
  677. to='dcim.RearPortTemplate',
  678. on_delete=models.CASCADE,
  679. related_name='mappings',
  680. )
  681. class Meta(PortMappingBase.Meta):
  682. # Inherit the unique constraints from PortMappingBase.Meta.
  683. pass
  684. def clean(self):
  685. super().clean()
  686. # Validate rear port assignment
  687. if self.front_port.device_type_id != self.rear_port.device_type_id:
  688. raise ValidationError({
  689. "rear_port": _("Rear port ({rear_port}) must belong to the same device type").format(
  690. rear_port=self.rear_port
  691. )
  692. })
  693. def save(self, *args, **kwargs):
  694. # Associate the mapping with the parent DeviceType/ModuleType
  695. self.device_type = self.front_port.device_type
  696. self.module_type = self.front_port.module_type
  697. super().save(*args, **kwargs)
  698. def to_yaml(self):
  699. return {
  700. 'front_port': self.front_port.name,
  701. 'front_port_position': self.front_port_position,
  702. 'rear_port': self.rear_port.name,
  703. 'rear_port_position': self.rear_port_position,
  704. }
  705. class FrontPortTemplate(ModularComponentTemplateModel):
  706. """
  707. Template for a pass-through port on the front of a new Device.
  708. """
  709. type = models.CharField(
  710. verbose_name=_('type'),
  711. max_length=50,
  712. choices=PortTypeChoices
  713. )
  714. color = ColorField(
  715. verbose_name=_('color'),
  716. blank=True
  717. )
  718. positions = models.PositiveSmallIntegerField(
  719. verbose_name=_('positions'),
  720. default=1,
  721. validators=[
  722. MinValueValidator(PORT_POSITION_MIN),
  723. MaxValueValidator(PORT_POSITION_MAX)
  724. ],
  725. )
  726. component_model = FrontPort
  727. class Meta(ModularComponentTemplateModel.Meta):
  728. constraints = (
  729. models.UniqueConstraint(
  730. fields=('device_type', 'name'),
  731. name='%(app_label)s_%(class)s_unique_device_type_name'
  732. ),
  733. models.UniqueConstraint(
  734. fields=('module_type', 'name'),
  735. name='%(app_label)s_%(class)s_unique_module_type_name'
  736. ),
  737. )
  738. verbose_name = _('front port template')
  739. verbose_name_plural = _('front port templates')
  740. def clean(self):
  741. super().clean()
  742. # Check that positions is greater than or equal to the number of associated RearPortTemplates
  743. if not self._state.adding:
  744. mapping_count = self.mappings.count()
  745. if self.positions < mapping_count:
  746. raise ValidationError({
  747. "positions": _(
  748. "The number of positions cannot be less than the number of mapped rear port templates ({count})"
  749. ).format(count=mapping_count)
  750. })
  751. def instantiate(self, **kwargs):
  752. return self.component_model(
  753. name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
  754. label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
  755. type=self.type,
  756. color=self.color,
  757. positions=self.positions,
  758. **kwargs
  759. )
  760. instantiate.do_not_call_in_templates = True
  761. def to_yaml(self):
  762. return {
  763. 'name': self.name,
  764. 'type': self.type,
  765. 'color': self.color,
  766. 'positions': self.positions,
  767. 'label': self.label,
  768. 'description': self.description,
  769. }
  770. class RearPortTemplate(ModularComponentTemplateModel):
  771. """
  772. Template for a pass-through port on the rear of a new Device.
  773. """
  774. type = models.CharField(
  775. verbose_name=_('type'),
  776. max_length=50,
  777. choices=PortTypeChoices
  778. )
  779. color = ColorField(
  780. verbose_name=_('color'),
  781. blank=True
  782. )
  783. positions = models.PositiveSmallIntegerField(
  784. verbose_name=_('positions'),
  785. default=1,
  786. validators=[
  787. MinValueValidator(PORT_POSITION_MIN),
  788. MaxValueValidator(PORT_POSITION_MAX)
  789. ],
  790. )
  791. component_model = RearPort
  792. class Meta(ModularComponentTemplateModel.Meta):
  793. verbose_name = _('rear port template')
  794. verbose_name_plural = _('rear port templates')
  795. def clean(self):
  796. super().clean()
  797. # Check that positions is greater than or equal to the number of associated FrontPortTemplates
  798. if not self._state.adding:
  799. mapping_count = self.mappings.count()
  800. if self.positions < mapping_count:
  801. raise ValidationError({
  802. "positions": _(
  803. "The number of positions cannot be less than the number of mapped front port templates "
  804. "({count})"
  805. ).format(count=mapping_count)
  806. })
  807. def instantiate(self, **kwargs):
  808. return self.component_model(
  809. name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
  810. label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
  811. type=self.type,
  812. color=self.color,
  813. positions=self.positions,
  814. **kwargs
  815. )
  816. instantiate.do_not_call_in_templates = True
  817. def to_yaml(self):
  818. return {
  819. 'name': self.name,
  820. 'type': self.type,
  821. 'color': self.color,
  822. 'positions': self.positions,
  823. 'label': self.label,
  824. 'description': self.description,
  825. }
  826. class ModuleBayTemplate(ModularComponentTemplateModel):
  827. """
  828. A template for a ModuleBay to be created for a new parent Device.
  829. """
  830. position = models.CharField(
  831. verbose_name=_('position'),
  832. max_length=30,
  833. blank=True,
  834. help_text=_('Identifier to reference when renaming installed components')
  835. )
  836. enabled = models.BooleanField(
  837. verbose_name=_('enabled'),
  838. default=True,
  839. )
  840. module_bay_types = models.ManyToManyField(
  841. to='dcim.ModuleBayType',
  842. related_name='module_bay_templates',
  843. blank=True,
  844. verbose_name=_('module bay types'),
  845. help_text=_('Types of modules that can be installed in this bay (empty = unconstrained)'),
  846. )
  847. component_model = ModuleBay
  848. class Meta(ModularComponentTemplateModel.Meta):
  849. verbose_name = _('module bay template')
  850. verbose_name_plural = _('module bay templates')
  851. def instantiate(self, **kwargs):
  852. module = kwargs.get('module')
  853. instance = self.component_model(
  854. name=self.resolve_name(module, kwargs.get('device')),
  855. label=self.resolve_label(module, kwargs.get('device')),
  856. position=self.resolve_position(module, kwargs.get('device')),
  857. enabled=self.enabled,
  858. # A module bay created for an installed module nests under that module's
  859. # bay. bulk_create() bypasses ModuleBay.save() (which would otherwise set
  860. # this), so the parent must be assigned here for the path trigger to nest
  861. # it correctly. Device-level bays are instantiated without a module and
  862. # remain roots (parent=None).
  863. parent=module.module_bay if module else None,
  864. **kwargs
  865. )
  866. # Stash reference so callers (Module.save, Device._instantiate_components) can
  867. # copy M2M fields (e.g. module_bay_types) that bulk_create cannot handle.
  868. instance._source_template = self
  869. return instance
  870. instantiate.do_not_call_in_templates = True
  871. def to_yaml(self):
  872. return {
  873. 'name': self.name,
  874. 'label': self.label,
  875. 'position': self.position,
  876. 'enabled': self.enabled,
  877. 'description': self.description,
  878. 'module_bay_types': [t.name for t in self.module_bay_types.all()],
  879. }
  880. class DeviceBayTemplate(ComponentTemplateModel):
  881. """
  882. A template for a DeviceBay to be created for a new parent Device.
  883. """
  884. enabled = models.BooleanField(
  885. verbose_name=_('enabled'),
  886. default=True,
  887. )
  888. component_model = DeviceBay
  889. class Meta(ComponentTemplateModel.Meta):
  890. verbose_name = _('device bay template')
  891. verbose_name_plural = _('device bay templates')
  892. def instantiate(self, device):
  893. return self.component_model(
  894. device=device,
  895. name=self.name,
  896. label=self.label,
  897. enabled=self.enabled,
  898. )
  899. instantiate.do_not_call_in_templates = True
  900. def clean(self):
  901. if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
  902. raise ValidationError(
  903. _(
  904. 'Subdevice role of device type ({device_type}) must be set to "parent" to allow device bays.'
  905. ).format(device_type=self.device_type)
  906. )
  907. def to_yaml(self):
  908. return {
  909. 'name': self.name,
  910. 'label': self.label,
  911. 'enabled': self.enabled,
  912. 'description': self.description,
  913. }
  914. class InventoryItemTemplate(LtreeModel, ComponentTemplateModel):
  915. """
  916. A template for an InventoryItem to be created for a new parent Device.
  917. """
  918. parent = models.ForeignKey(
  919. to='self',
  920. on_delete=models.CASCADE,
  921. related_name='child_items',
  922. blank=True,
  923. null=True,
  924. db_index=True
  925. )
  926. component_type = models.ForeignKey(
  927. to='contenttypes.ContentType',
  928. on_delete=models.PROTECT,
  929. related_name='+',
  930. blank=True,
  931. null=True
  932. )
  933. component_id = models.PositiveBigIntegerField(
  934. blank=True,
  935. null=True
  936. )
  937. component = GenericForeignKey(
  938. ct_field='component_type',
  939. fk_field='component_id'
  940. )
  941. role = models.ForeignKey(
  942. to='dcim.InventoryItemRole',
  943. on_delete=models.PROTECT,
  944. related_name='inventory_item_templates',
  945. blank=True,
  946. null=True
  947. )
  948. manufacturer = models.ForeignKey(
  949. to='dcim.Manufacturer',
  950. on_delete=models.PROTECT,
  951. related_name='inventory_item_templates',
  952. blank=True,
  953. null=True
  954. )
  955. part_id = models.CharField(
  956. max_length=50,
  957. verbose_name=_('part ID'),
  958. blank=True,
  959. help_text=_('Manufacturer-assigned part identifier')
  960. )
  961. objects = LtreeManager()
  962. component_model = InventoryItem
  963. class Meta:
  964. ordering = ('device_type__id', 'parent__id', 'name')
  965. indexes = (
  966. models.Index(fields=('component_type', 'component_id')),
  967. GistIndex(fields=['path'], name='dcim_inv_item_tmpl_path_gist'),
  968. )
  969. constraints = (
  970. models.UniqueConstraint(
  971. fields=('device_type', 'parent', 'name'),
  972. name='%(app_label)s_%(class)s_unique_device_type_parent_name'
  973. ),
  974. )
  975. verbose_name = _('inventory item template')
  976. verbose_name_plural = _('inventory item templates')
  977. def instantiate(self, **kwargs):
  978. parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None
  979. if self.component:
  980. model = self.component.component_model
  981. component = model.objects.get(name=self.component.name, **kwargs)
  982. else:
  983. component = None
  984. return self.component_model(
  985. parent=parent,
  986. name=self.name,
  987. label=self.label,
  988. component=component,
  989. role=self.role,
  990. manufacturer=self.manufacturer,
  991. part_id=self.part_id,
  992. **kwargs
  993. )
  994. instantiate.do_not_call_in_templates = True