device_component_templates.py 17 KB

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