device_component_templates.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682
  1. from django.contrib.contenttypes.fields import GenericForeignKey
  2. from django.contrib.contenttypes.models import ContentType
  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 as _
  7. from mptt.models import MPTTModel, TreeForeignKey
  8. from dcim.choices import *
  9. from dcim.constants import *
  10. from netbox.models import ChangeLoggedModel
  11. from utilities.fields import ColorField, NaturalOrderingField
  12. from utilities.mptt import TreeManager
  13. from utilities.ordering import naturalize_interface
  14. from .device_components import (
  15. ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
  16. RearPort,
  17. )
  18. __all__ = (
  19. 'ConsolePortTemplate',
  20. 'ConsoleServerPortTemplate',
  21. 'DeviceBayTemplate',
  22. 'FrontPortTemplate',
  23. 'InterfaceTemplate',
  24. 'InventoryItemTemplate',
  25. 'ModuleBayTemplate',
  26. 'PowerOutletTemplate',
  27. 'PowerPortTemplate',
  28. 'RearPortTemplate',
  29. )
  30. class ComponentTemplateModel(ChangeLoggedModel):
  31. device_type = models.ForeignKey(
  32. to='dcim.DeviceType',
  33. on_delete=models.CASCADE,
  34. related_name='%(class)ss'
  35. )
  36. name = models.CharField(
  37. max_length=64,
  38. help_text="""
  39. {module} is accepted as a substitution for the module bay position when attached to a module type.
  40. """
  41. )
  42. _name = NaturalOrderingField(
  43. target_field='name',
  44. max_length=100,
  45. blank=True
  46. )
  47. label = models.CharField(
  48. max_length=64,
  49. blank=True,
  50. help_text=_("Physical label")
  51. )
  52. description = models.CharField(
  53. max_length=200,
  54. blank=True
  55. )
  56. class Meta:
  57. abstract = True
  58. ordering = ('device_type', '_name')
  59. constraints = (
  60. models.UniqueConstraint(
  61. fields=('device_type', 'name'),
  62. name='%(app_label)s_%(class)s_unique_device_type_name'
  63. ),
  64. )
  65. def __str__(self):
  66. if self.label:
  67. return f"{self.name} ({self.label})"
  68. return self.name
  69. def instantiate(self, device):
  70. """
  71. Instantiate a new component on the specified Device.
  72. """
  73. raise NotImplementedError()
  74. def to_objectchange(self, action):
  75. objectchange = super().to_objectchange(action)
  76. objectchange.related_object = self.device_type
  77. return objectchange
  78. class ModularComponentTemplateModel(ComponentTemplateModel):
  79. """
  80. A ComponentTemplateModel which supports optional assignment to a ModuleType.
  81. """
  82. device_type = models.ForeignKey(
  83. to='dcim.DeviceType',
  84. on_delete=models.CASCADE,
  85. related_name='%(class)ss',
  86. blank=True,
  87. null=True
  88. )
  89. module_type = models.ForeignKey(
  90. to='dcim.ModuleType',
  91. on_delete=models.CASCADE,
  92. related_name='%(class)ss',
  93. blank=True,
  94. null=True
  95. )
  96. class Meta:
  97. abstract = True
  98. ordering = ('device_type', 'module_type', '_name')
  99. constraints = (
  100. models.UniqueConstraint(
  101. fields=('device_type', 'name'),
  102. name='%(app_label)s_%(class)s_unique_device_type_name'
  103. ),
  104. models.UniqueConstraint(
  105. fields=('module_type', 'name'),
  106. name='%(app_label)s_%(class)s_unique_module_type_name'
  107. ),
  108. )
  109. def to_objectchange(self, action):
  110. objectchange = super().to_objectchange(action)
  111. if self.device_type is not None:
  112. objectchange.related_object = self.device_type
  113. elif self.module_type is not None:
  114. objectchange.related_object = self.module_type
  115. return objectchange
  116. def clean(self):
  117. super().clean()
  118. # A component template must belong to a DeviceType *or* to a ModuleType
  119. if self.device_type and self.module_type:
  120. raise ValidationError(
  121. "A component template cannot be associated with both a device type and a module type."
  122. )
  123. if not self.device_type and not self.module_type:
  124. raise ValidationError(
  125. "A component template must be associated with either a device type or a module type."
  126. )
  127. def resolve_name(self, module):
  128. if module:
  129. return self.name.replace(MODULE_TOKEN, module.module_bay.position)
  130. return self.name
  131. def resolve_label(self, module):
  132. if module:
  133. return self.label.replace(MODULE_TOKEN, module.module_bay.position)
  134. return self.label
  135. class ConsolePortTemplate(ModularComponentTemplateModel):
  136. """
  137. A template for a ConsolePort to be created for a new Device.
  138. """
  139. type = models.CharField(
  140. max_length=50,
  141. choices=ConsolePortTypeChoices,
  142. blank=True
  143. )
  144. component_model = ConsolePort
  145. def instantiate(self, **kwargs):
  146. return self.component_model(
  147. name=self.resolve_name(kwargs.get('module')),
  148. label=self.resolve_label(kwargs.get('module')),
  149. type=self.type,
  150. **kwargs
  151. )
  152. def to_yaml(self):
  153. return {
  154. 'name': self.name,
  155. 'type': self.type,
  156. 'label': self.label,
  157. 'description': self.description,
  158. }
  159. class ConsoleServerPortTemplate(ModularComponentTemplateModel):
  160. """
  161. A template for a ConsoleServerPort to be created for a new Device.
  162. """
  163. type = models.CharField(
  164. max_length=50,
  165. choices=ConsolePortTypeChoices,
  166. blank=True
  167. )
  168. component_model = ConsoleServerPort
  169. def instantiate(self, **kwargs):
  170. return self.component_model(
  171. name=self.resolve_name(kwargs.get('module')),
  172. label=self.resolve_label(kwargs.get('module')),
  173. type=self.type,
  174. **kwargs
  175. )
  176. def to_yaml(self):
  177. return {
  178. 'name': self.name,
  179. 'type': self.type,
  180. 'label': self.label,
  181. 'description': self.description,
  182. }
  183. class PowerPortTemplate(ModularComponentTemplateModel):
  184. """
  185. A template for a PowerPort to be created for a new Device.
  186. """
  187. type = models.CharField(
  188. max_length=50,
  189. choices=PowerPortTypeChoices,
  190. blank=True
  191. )
  192. maximum_draw = models.PositiveSmallIntegerField(
  193. blank=True,
  194. null=True,
  195. validators=[MinValueValidator(1)],
  196. help_text=_("Maximum power draw (watts)")
  197. )
  198. allocated_draw = models.PositiveSmallIntegerField(
  199. blank=True,
  200. null=True,
  201. validators=[MinValueValidator(1)],
  202. help_text=_("Allocated power draw (watts)")
  203. )
  204. component_model = PowerPort
  205. def instantiate(self, **kwargs):
  206. return self.component_model(
  207. name=self.resolve_name(kwargs.get('module')),
  208. label=self.resolve_label(kwargs.get('module')),
  209. type=self.type,
  210. maximum_draw=self.maximum_draw,
  211. allocated_draw=self.allocated_draw,
  212. **kwargs
  213. )
  214. def clean(self):
  215. super().clean()
  216. if self.maximum_draw is not None and self.allocated_draw is not None:
  217. if self.allocated_draw > self.maximum_draw:
  218. raise ValidationError({
  219. 'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
  220. })
  221. def to_yaml(self):
  222. return {
  223. 'name': self.name,
  224. 'type': self.type,
  225. 'maximum_draw': self.maximum_draw,
  226. 'allocated_draw': self.allocated_draw,
  227. 'label': self.label,
  228. 'description': self.description,
  229. }
  230. class PowerOutletTemplate(ModularComponentTemplateModel):
  231. """
  232. A template for a PowerOutlet to be created for a new Device.
  233. """
  234. type = models.CharField(
  235. max_length=50,
  236. choices=PowerOutletTypeChoices,
  237. blank=True
  238. )
  239. power_port = models.ForeignKey(
  240. to='dcim.PowerPortTemplate',
  241. on_delete=models.SET_NULL,
  242. blank=True,
  243. null=True,
  244. related_name='poweroutlet_templates'
  245. )
  246. feed_leg = models.CharField(
  247. max_length=50,
  248. choices=PowerOutletFeedLegChoices,
  249. blank=True,
  250. help_text=_("Phase (for three-phase feeds)")
  251. )
  252. component_model = PowerOutlet
  253. def clean(self):
  254. super().clean()
  255. # Validate power port assignment
  256. if self.power_port:
  257. if self.device_type and self.power_port.device_type != self.device_type:
  258. raise ValidationError(
  259. f"Parent power port ({self.power_port}) must belong to the same device type"
  260. )
  261. if self.module_type and self.power_port.module_type != self.module_type:
  262. raise ValidationError(
  263. f"Parent power port ({self.power_port}) must belong to the same module type"
  264. )
  265. def instantiate(self, **kwargs):
  266. if self.power_port:
  267. power_port_name = self.power_port.resolve_name(kwargs.get('module'))
  268. power_port = PowerPort.objects.get(name=power_port_name, **kwargs)
  269. else:
  270. power_port = None
  271. return self.component_model(
  272. name=self.resolve_name(kwargs.get('module')),
  273. label=self.resolve_label(kwargs.get('module')),
  274. type=self.type,
  275. power_port=power_port,
  276. feed_leg=self.feed_leg,
  277. **kwargs
  278. )
  279. def to_yaml(self):
  280. return {
  281. 'name': self.name,
  282. 'type': self.type,
  283. 'power_port': self.power_port.name if self.power_port else None,
  284. 'feed_leg': self.feed_leg,
  285. 'label': self.label,
  286. 'description': self.description,
  287. }
  288. class InterfaceTemplate(ModularComponentTemplateModel):
  289. """
  290. A template for a physical data interface on a new Device.
  291. """
  292. # Override ComponentTemplateModel._name to specify naturalize_interface function
  293. _name = NaturalOrderingField(
  294. target_field='name',
  295. naturalize_function=naturalize_interface,
  296. max_length=100,
  297. blank=True
  298. )
  299. type = models.CharField(
  300. max_length=50,
  301. choices=InterfaceTypeChoices
  302. )
  303. enabled = models.BooleanField(
  304. default=True
  305. )
  306. mgmt_only = models.BooleanField(
  307. default=False,
  308. verbose_name='Management only'
  309. )
  310. bridge = models.ForeignKey(
  311. to='self',
  312. on_delete=models.SET_NULL,
  313. related_name='bridge_interfaces',
  314. null=True,
  315. blank=True,
  316. verbose_name='Bridge interface'
  317. )
  318. poe_mode = models.CharField(
  319. max_length=50,
  320. choices=InterfacePoEModeChoices,
  321. blank=True,
  322. verbose_name='PoE mode'
  323. )
  324. poe_type = models.CharField(
  325. max_length=50,
  326. choices=InterfacePoETypeChoices,
  327. blank=True,
  328. verbose_name='PoE type'
  329. )
  330. component_model = Interface
  331. def clean(self):
  332. super().clean()
  333. if self.bridge:
  334. if self.device_type and self.device_type != self.bridge.device_type:
  335. raise ValidationError({
  336. 'bridge': f"Bridge interface ({self.bridge}) must belong to the same device type"
  337. })
  338. if self.module_type and self.module_type != self.bridge.module_type:
  339. raise ValidationError({
  340. 'bridge': f"Bridge interface ({self.bridge}) must belong to the same module type"
  341. })
  342. def instantiate(self, **kwargs):
  343. return self.component_model(
  344. name=self.resolve_name(kwargs.get('module')),
  345. label=self.resolve_label(kwargs.get('module')),
  346. type=self.type,
  347. enabled=self.enabled,
  348. mgmt_only=self.mgmt_only,
  349. poe_mode=self.poe_mode,
  350. poe_type=self.poe_type,
  351. **kwargs
  352. )
  353. def to_yaml(self):
  354. return {
  355. 'name': self.name,
  356. 'type': self.type,
  357. 'enabled': self.enabled,
  358. 'mgmt_only': self.mgmt_only,
  359. 'label': self.label,
  360. 'description': self.description,
  361. 'bridge': self.bridge.name if self.bridge else None,
  362. 'poe_mode': self.poe_mode,
  363. 'poe_type': self.poe_type,
  364. }
  365. class FrontPortTemplate(ModularComponentTemplateModel):
  366. """
  367. Template for a pass-through port on the front of a new Device.
  368. """
  369. type = models.CharField(
  370. max_length=50,
  371. choices=PortTypeChoices
  372. )
  373. color = ColorField(
  374. blank=True
  375. )
  376. rear_port = models.ForeignKey(
  377. to='dcim.RearPortTemplate',
  378. on_delete=models.CASCADE,
  379. related_name='frontport_templates'
  380. )
  381. rear_port_position = models.PositiveSmallIntegerField(
  382. default=1,
  383. validators=[
  384. MinValueValidator(REARPORT_POSITIONS_MIN),
  385. MaxValueValidator(REARPORT_POSITIONS_MAX)
  386. ]
  387. )
  388. component_model = FrontPort
  389. class Meta(ModularComponentTemplateModel.Meta):
  390. constraints = (
  391. models.UniqueConstraint(
  392. fields=('device_type', 'name'),
  393. name='%(app_label)s_%(class)s_unique_device_type_name'
  394. ),
  395. models.UniqueConstraint(
  396. fields=('module_type', 'name'),
  397. name='%(app_label)s_%(class)s_unique_module_type_name'
  398. ),
  399. models.UniqueConstraint(
  400. fields=('rear_port', 'rear_port_position'),
  401. name='%(app_label)s_%(class)s_unique_rear_port_position'
  402. ),
  403. )
  404. def clean(self):
  405. super().clean()
  406. try:
  407. # Validate rear port assignment
  408. if self.rear_port.device_type != self.device_type:
  409. raise ValidationError(
  410. "Rear port ({}) must belong to the same device type".format(self.rear_port)
  411. )
  412. # Validate rear port position assignment
  413. if self.rear_port_position > self.rear_port.positions:
  414. raise ValidationError(
  415. "Invalid rear port position ({}); rear port {} has only {} positions".format(
  416. self.rear_port_position, self.rear_port.name, self.rear_port.positions
  417. )
  418. )
  419. except RearPortTemplate.DoesNotExist:
  420. pass
  421. def instantiate(self, **kwargs):
  422. if self.rear_port:
  423. rear_port_name = self.rear_port.resolve_name(kwargs.get('module'))
  424. rear_port = RearPort.objects.get(name=rear_port_name, **kwargs)
  425. else:
  426. rear_port = None
  427. return self.component_model(
  428. name=self.resolve_name(kwargs.get('module')),
  429. label=self.resolve_label(kwargs.get('module')),
  430. type=self.type,
  431. color=self.color,
  432. rear_port=rear_port,
  433. rear_port_position=self.rear_port_position,
  434. **kwargs
  435. )
  436. def to_yaml(self):
  437. return {
  438. 'name': self.name,
  439. 'type': self.type,
  440. 'color': self.color,
  441. 'rear_port': self.rear_port.name,
  442. 'rear_port_position': self.rear_port_position,
  443. 'label': self.label,
  444. 'description': self.description,
  445. }
  446. class RearPortTemplate(ModularComponentTemplateModel):
  447. """
  448. Template for a pass-through port on the rear of a new Device.
  449. """
  450. type = models.CharField(
  451. max_length=50,
  452. choices=PortTypeChoices
  453. )
  454. color = ColorField(
  455. blank=True
  456. )
  457. positions = models.PositiveSmallIntegerField(
  458. default=1,
  459. validators=[
  460. MinValueValidator(REARPORT_POSITIONS_MIN),
  461. MaxValueValidator(REARPORT_POSITIONS_MAX)
  462. ]
  463. )
  464. component_model = RearPort
  465. def instantiate(self, **kwargs):
  466. return self.component_model(
  467. name=self.resolve_name(kwargs.get('module')),
  468. label=self.resolve_label(kwargs.get('module')),
  469. type=self.type,
  470. color=self.color,
  471. positions=self.positions,
  472. **kwargs
  473. )
  474. def to_yaml(self):
  475. return {
  476. 'name': self.name,
  477. 'type': self.type,
  478. 'color': self.color,
  479. 'positions': self.positions,
  480. 'label': self.label,
  481. 'description': self.description,
  482. }
  483. class ModuleBayTemplate(ComponentTemplateModel):
  484. """
  485. A template for a ModuleBay to be created for a new parent Device.
  486. """
  487. position = models.CharField(
  488. max_length=30,
  489. blank=True,
  490. help_text=_('Identifier to reference when renaming installed components')
  491. )
  492. component_model = ModuleBay
  493. def instantiate(self, device):
  494. return self.component_model(
  495. device=device,
  496. name=self.name,
  497. label=self.label,
  498. position=self.position
  499. )
  500. def to_yaml(self):
  501. return {
  502. 'name': self.name,
  503. 'label': self.label,
  504. 'position': self.position,
  505. 'description': self.description,
  506. }
  507. class DeviceBayTemplate(ComponentTemplateModel):
  508. """
  509. A template for a DeviceBay to be created for a new parent Device.
  510. """
  511. component_model = DeviceBay
  512. def instantiate(self, device):
  513. return self.component_model(
  514. device=device,
  515. name=self.name,
  516. label=self.label
  517. )
  518. def clean(self):
  519. if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
  520. raise ValidationError(
  521. f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
  522. )
  523. def to_yaml(self):
  524. return {
  525. 'name': self.name,
  526. 'label': self.label,
  527. 'description': self.description,
  528. }
  529. class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
  530. """
  531. A template for an InventoryItem to be created for a new parent Device.
  532. """
  533. parent = TreeForeignKey(
  534. to='self',
  535. on_delete=models.CASCADE,
  536. related_name='child_items',
  537. blank=True,
  538. null=True,
  539. db_index=True
  540. )
  541. component_type = models.ForeignKey(
  542. to=ContentType,
  543. limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS,
  544. on_delete=models.PROTECT,
  545. related_name='+',
  546. blank=True,
  547. null=True
  548. )
  549. component_id = models.PositiveBigIntegerField(
  550. blank=True,
  551. null=True
  552. )
  553. component = GenericForeignKey(
  554. ct_field='component_type',
  555. fk_field='component_id'
  556. )
  557. role = models.ForeignKey(
  558. to='dcim.InventoryItemRole',
  559. on_delete=models.PROTECT,
  560. related_name='inventory_item_templates',
  561. blank=True,
  562. null=True
  563. )
  564. manufacturer = models.ForeignKey(
  565. to='dcim.Manufacturer',
  566. on_delete=models.PROTECT,
  567. related_name='inventory_item_templates',
  568. blank=True,
  569. null=True
  570. )
  571. part_id = models.CharField(
  572. max_length=50,
  573. verbose_name='Part ID',
  574. blank=True,
  575. help_text=_('Manufacturer-assigned part identifier')
  576. )
  577. objects = TreeManager()
  578. component_model = InventoryItem
  579. class Meta:
  580. ordering = ('device_type__id', 'parent__id', '_name')
  581. constraints = (
  582. models.UniqueConstraint(
  583. fields=('device_type', 'parent', 'name'),
  584. name='%(app_label)s_%(class)s_unique_device_type_parent_name'
  585. ),
  586. )
  587. def instantiate(self, **kwargs):
  588. parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None
  589. if self.component:
  590. model = self.component.component_model
  591. component = model.objects.get(name=self.component.name, **kwargs)
  592. else:
  593. component = None
  594. return self.component_model(
  595. parent=parent,
  596. name=self.name,
  597. label=self.label,
  598. component=component,
  599. role=self.role,
  600. manufacturer=self.manufacturer,
  601. part_id=self.part_id,
  602. **kwargs
  603. )