Coverage for dcim/models/device_component_templates.py: 58%

344 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1from django.contrib.contenttypes.fields import GenericForeignKey 

2from django.contrib.postgres.indexes import GistIndex 

3from django.core.exceptions import ValidationError 

4from django.core.validators import MaxValueValidator, MinValueValidator 

5from django.db import models 

6from django.utils.translation import gettext_lazy as _ 

7 

8from dcim.choices import * 

9from dcim.constants import * 

10from dcim.models.base import PortMappingBase 

11from dcim.models.mixins import DiameterMixin, InterfaceChannelRenameMixin, InterfaceValidationMixin, MaxFlowMixin 

12from dcim.utils import get_module_bay_positions, resolve_module_placeholder 

13from netbox.models import ChangeLoggedModel 

14from netbox.models.features import ChangeLoggingMixin 

15from netbox.models.ltree import LtreeManager, LtreeModel 

16from utilities.exceptions import AbortRequest 

17from utilities.fields import ColorField, NaturalOrderingField 

18from utilities.ordering import naturalize_interface 

19from utilities.tracking import TrackingModelMixin 

20from wireless.choices import WirelessRoleChoices 

21 

22from .device_components import ( 

23 ConsolePort, 

24 ConsoleServerPort, 

25 CoolingIntake, 

26 CoolingOutflow, 

27 DeviceBay, 

28 FrontPort, 

29 Interface, 

30 InventoryItem, 

31 ModuleBay, 

32 PowerOutlet, 

33 PowerPort, 

34 RearPort, 

35) 

36 

37__all__ = ( 

38 'ConsolePortTemplate', 

39 'ConsoleServerPortTemplate', 

40 'CoolingIntakeTemplate', 

41 'CoolingOutflowTemplate', 

42 'DeviceBayTemplate', 

43 'FrontPortTemplate', 

44 'InterfaceTemplate', 

45 'InventoryItemTemplate', 

46 'ModuleBayTemplate', 

47 'PortTemplateMapping', 

48 'PowerOutletTemplate', 

49 'PowerPortTemplate', 

50 'RearPortTemplate', 

51) 

52 

53 

54class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin): 

55 device_type = models.ForeignKey( 

56 to='dcim.DeviceType', 

57 on_delete=models.CASCADE, 

58 related_name='%(class)ss' 

59 ) 

60 name = models.CharField( 

61 verbose_name=_('name'), 

62 max_length=64, 

63 help_text=_( 

64 "{module} is accepted as a substitution for the module bay position when attached to a module type." 

65 ), 

66 db_collation="natural_sort" 

67 ) 

68 label = models.CharField( 

69 verbose_name=_('label'), 

70 max_length=64, 

71 blank=True, 

72 help_text=_('Physical label') 

73 ) 

74 description = models.CharField( 

75 verbose_name=_('description'), 

76 max_length=200, 

77 blank=True 

78 ) 

79 

80 class Meta: 

81 abstract = True 

82 ordering = ('device_type', 'name') 

83 constraints = ( 

84 models.UniqueConstraint( 

85 fields=('device_type', 'name'), 

86 name='%(app_label)s_%(class)s_unique_device_type_name' 

87 ), 

88 ) 

89 

90 def __str__(self): 

91 if self.label: 

92 return f"{self.name} ({self.label})" 

93 return self.name 

94 

95 def instantiate(self, device): 

96 """ 

97 Instantiate a new component on the specified Device. 

98 """ 

99 raise NotImplementedError() 

100 

101 def __init__(self, *args, **kwargs): 

102 super().__init__(*args, **kwargs) 

103 

104 # Cache the original DeviceType ID for reference under clean() 

105 self._original_device_type = self.__dict__.get('device_type_id') 

106 

107 def to_objectchange(self, action): 

108 objectchange = super().to_objectchange(action) 

109 objectchange.related_object = self.device_type 

110 return objectchange 

111 

112 def clean(self): 

113 super().clean() 

114 

115 if not self._state.adding and self._original_device_type != self.device_type_id: 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true

116 raise ValidationError({ 

117 "device_type": _("Component templates cannot be moved to a different device type.") 

118 }) 

119 

120 

121class ModularComponentTemplateModel(ComponentTemplateModel): 

122 """ 

123 A ComponentTemplateModel which supports optional assignment to a ModuleType. 

124 """ 

125 device_type = models.ForeignKey( 

126 to='dcim.DeviceType', 

127 on_delete=models.CASCADE, 

128 related_name='%(class)ss', 

129 blank=True, 

130 null=True 

131 ) 

132 module_type = models.ForeignKey( 

133 to='dcim.ModuleType', 

134 on_delete=models.CASCADE, 

135 related_name='%(class)ss', 

136 blank=True, 

137 null=True 

138 ) 

139 

140 class Meta: 

141 abstract = True 

142 ordering = ('device_type', 'module_type', 'name') 

143 constraints = ( 

144 models.UniqueConstraint( 

145 fields=('device_type', 'name'), 

146 name='%(app_label)s_%(class)s_unique_device_type_name' 

147 ), 

148 models.UniqueConstraint( 

149 fields=('module_type', 'name'), 

150 name='%(app_label)s_%(class)s_unique_module_type_name' 

151 ), 

152 ) 

153 indexes = ( 

154 models.Index(fields=('device_type', 'module_type', 'name')), # Default ordering 

155 ) 

156 

157 def to_objectchange(self, action): 

158 objectchange = super().to_objectchange(action) 

159 if self.device_type is not None: 159 ↛ 161line 159 didn't jump to line 161 because the condition on line 159 was always true

160 objectchange.related_object = self.device_type 

161 elif self.module_type is not None: 

162 objectchange.related_object = self.module_type 

163 return objectchange 

164 

165 def clean(self): 

166 super().clean() 

167 

168 # A component template must belong to a DeviceType *or* to a ModuleType 

169 if self.device_type and self.module_type: 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true

170 raise ValidationError( 

171 _("A component template cannot be associated with both a device type and a module type.") 

172 ) 

173 if not self.device_type and not self.module_type: 

174 raise ValidationError( 

175 _("A component template must be associated with either a device type or a module type.") 

176 ) 

177 

178 @staticmethod 

179 def _resolve_vc_position(value: str, device) -> str: 

180 """ 

181 Resolves {vc_position} and {vc_position:X} tokens. 

182 

183 If the device has a vc_position, replaces the token with that value. 

184 Otherwise uses the explicit fallback X if given, else '0'. 

185 """ 

186 def replacer(match): 

187 explicit_fallback = match.group(1) 

188 if ( 

189 device is not None 

190 and device.virtual_chassis is not None 

191 and device.vc_position is not None 

192 ): 

193 return str(device.vc_position) 

194 return explicit_fallback if explicit_fallback is not None else '0' 

195 

196 return VC_POSITION_RE.sub(replacer, value) 

197 

198 def _resolve_all_placeholders(self, value, module=None, device=None): 

199 has_module = MODULE_TOKEN in value 

200 has_vc = VC_POSITION_RE.search(value) is not None 

201 if not has_module and not has_vc: 

202 return value 

203 if has_module and module: 

204 # Reached only from Module._save_new(); AbortRequest is what the view/viewset catches. 

205 try: 

206 positions = get_module_bay_positions(module.module_bay) 

207 except ValueError as e: 

208 raise AbortRequest(str(e)) from e 

209 value = resolve_module_placeholder(value, positions) 

210 if has_vc: 

211 resolved_device = (module.device if module else None) or device 

212 value = self._resolve_vc_position(value, resolved_device) 

213 return value 

214 

215 def resolve_name(self, module=None, device=None): 

216 return self._resolve_all_placeholders(self.name, module, device) 

217 

218 def resolve_label(self, module=None, device=None): 

219 return self._resolve_all_placeholders(self.label, module, device) 

220 

221 def resolve_position(self, module=None, device=None): 

222 return self._resolve_all_placeholders(self.position, module, device) 

223 

224 

225class ConsolePortTemplate(ModularComponentTemplateModel): 

226 """ 

227 A template for a ConsolePort to be created for a new Device. 

228 """ 

229 type = models.CharField( 

230 verbose_name=_('type'), 

231 max_length=50, 

232 choices=ConsolePortTypeChoices, 

233 blank=True, 

234 null=True 

235 ) 

236 

237 component_model = ConsolePort 

238 

239 class Meta(ModularComponentTemplateModel.Meta): 

240 verbose_name = _('console port template') 

241 verbose_name_plural = _('console port templates') 

242 

243 def instantiate(self, **kwargs): 

244 return self.component_model( 

245 name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), 

246 label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), 

247 type=self.type, 

248 **kwargs 

249 ) 

250 

251 def to_yaml(self): 

252 return { 

253 'name': self.name, 

254 'type': self.type, 

255 'label': self.label, 

256 'description': self.description, 

257 } 

258 

259 

260class ConsoleServerPortTemplate(ModularComponentTemplateModel): 

261 """ 

262 A template for a ConsoleServerPort to be created for a new Device. 

263 """ 

264 type = models.CharField( 

265 verbose_name=_('type'), 

266 max_length=50, 

267 choices=ConsolePortTypeChoices, 

268 blank=True, 

269 null=True 

270 ) 

271 

272 component_model = ConsoleServerPort 

273 

274 class Meta(ModularComponentTemplateModel.Meta): 

275 verbose_name = _('console server port template') 

276 verbose_name_plural = _('console server port templates') 

277 

278 def instantiate(self, **kwargs): 

279 return self.component_model( 

280 name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), 

281 label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), 

282 type=self.type, 

283 **kwargs 

284 ) 

285 instantiate.do_not_call_in_templates = True 

286 

287 def to_yaml(self): 

288 return { 

289 'name': self.name, 

290 'type': self.type, 

291 'label': self.label, 

292 'description': self.description, 

293 } 

294 

295 

296class PowerPortTemplate(ModularComponentTemplateModel): 

297 """ 

298 A template for a PowerPort to be created for a new Device. 

299 """ 

300 type = models.CharField( 

301 verbose_name=_('type'), 

302 max_length=50, 

303 choices=PowerPortTypeChoices, 

304 blank=True, 

305 null=True 

306 ) 

307 maximum_draw = models.PositiveIntegerField( 

308 verbose_name=_('maximum draw'), 

309 blank=True, 

310 null=True, 

311 validators=[MinValueValidator(1)], 

312 help_text=_('Maximum power draw (watts)') 

313 ) 

314 allocated_draw = models.PositiveIntegerField( 

315 verbose_name=_('allocated draw'), 

316 blank=True, 

317 null=True, 

318 validators=[MinValueValidator(1)], 

319 help_text=_('Allocated power draw (watts)') 

320 ) 

321 

322 component_model = PowerPort 

323 

324 class Meta(ModularComponentTemplateModel.Meta): 

325 verbose_name = _('power port template') 

326 verbose_name_plural = _('power port templates') 

327 

328 def instantiate(self, **kwargs): 

329 return self.component_model( 

330 name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), 

331 label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), 

332 type=self.type, 

333 maximum_draw=self.maximum_draw, 

334 allocated_draw=self.allocated_draw, 

335 **kwargs 

336 ) 

337 instantiate.do_not_call_in_templates = True 

338 

339 def clean(self): 

340 super().clean() 

341 

342 if self.maximum_draw is not None and self.allocated_draw is not None: 

343 if self.allocated_draw > self.maximum_draw: 

344 raise ValidationError({ 

345 'allocated_draw': _( 

346 "Allocated draw cannot exceed the maximum draw ({maximum_draw}W)." 

347 ).format(maximum_draw=self.maximum_draw) 

348 }) 

349 

350 def to_yaml(self): 

351 return { 

352 'name': self.name, 

353 'type': self.type, 

354 'maximum_draw': self.maximum_draw, 

355 'allocated_draw': self.allocated_draw, 

356 'label': self.label, 

357 'description': self.description, 

358 } 

359 

360 

361class PowerOutletTemplate(ModularComponentTemplateModel): 

362 """ 

363 A template for a PowerOutlet to be created for a new Device. 

364 """ 

365 type = models.CharField( 

366 verbose_name=_('type'), 

367 max_length=50, 

368 choices=PowerOutletTypeChoices, 

369 blank=True, 

370 null=True 

371 ) 

372 color = ColorField( 

373 verbose_name=_('color'), 

374 blank=True 

375 ) 

376 power_port = models.ForeignKey( 

377 to='dcim.PowerPortTemplate', 

378 on_delete=models.SET_NULL, 

379 blank=True, 

380 null=True, 

381 related_name='poweroutlet_templates' 

382 ) 

383 feed_leg = models.CharField( 

384 verbose_name=_('feed leg'), 

385 max_length=50, 

386 choices=PowerOutletFeedLegChoices, 

387 blank=True, 

388 null=True, 

389 help_text=_('Phase (for three-phase feeds)') 

390 ) 

391 

392 component_model = PowerOutlet 

393 

394 class Meta(ModularComponentTemplateModel.Meta): 

395 verbose_name = _('power outlet template') 

396 verbose_name_plural = _('power outlet templates') 

397 

398 def clean(self): 

399 super().clean() 

400 

401 # Validate power port assignment 

402 if self.power_port: 402 ↛ 403line 402 didn't jump to line 403 because the condition on line 402 was never true

403 if self.device_type and self.power_port.device_type != self.device_type: 

404 raise ValidationError( 

405 _("Parent power port ({power_port}) must belong to the same device type").format( 

406 power_port=self.power_port 

407 ) 

408 ) 

409 if self.module_type and self.power_port.module_type != self.module_type: 

410 raise ValidationError( 

411 _("Parent power port ({power_port}) must belong to the same module type").format( 

412 power_port=self.power_port 

413 ) 

414 ) 

415 

416 def instantiate(self, **kwargs): 

417 if self.power_port: 

418 power_port_name = self.power_port.resolve_name(kwargs.get('module'), kwargs.get('device')) 

419 power_port = PowerPort.objects.get(name=power_port_name, **kwargs) 

420 else: 

421 power_port = None 

422 return self.component_model( 

423 name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), 

424 label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), 

425 type=self.type, 

426 color=self.color, 

427 power_port=power_port, 

428 feed_leg=self.feed_leg, 

429 **kwargs 

430 ) 

431 instantiate.do_not_call_in_templates = True 

432 

433 def to_yaml(self): 

434 return { 

435 'name': self.name, 

436 'type': self.type, 

437 'color': self.color, 

438 'power_port': self.power_port.name if self.power_port else None, 

439 'feed_leg': self.feed_leg, 

440 'label': self.label, 

441 'description': self.description, 

442 } 

443 

444 

445class CoolingIntakeTemplate(DiameterMixin, MaxFlowMixin, ModularComponentTemplateModel): 

446 """ 

447 A template for a CoolingIntake to be created for a new Device. 

448 """ 

449 type = models.CharField( 

450 verbose_name=_('type'), 

451 max_length=50, 

452 choices=CoolingConnectorTypeChoices, 

453 blank=True, 

454 null=True 

455 ) 

456 # diameter, diameter_unit, _abs_diameter provided by DiameterMixin 

457 # max_flow, max_flow_unit, _abs_max_flow provided by MaxFlowMixin 

458 

459 component_model = CoolingIntake 

460 

461 class Meta(ModularComponentTemplateModel.Meta): 

462 verbose_name = _('cooling intake template') 

463 verbose_name_plural = _('cooling intake templates') 

464 

465 def instantiate(self, **kwargs): 

466 component = self.component_model( 

467 name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), 

468 label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), 

469 type=self.type, 

470 diameter=self.diameter, 

471 diameter_unit=self.diameter_unit, 

472 max_flow=self.max_flow, 

473 max_flow_unit=self.max_flow_unit, 

474 **kwargs 

475 ) 

476 # bulk_create bypasses save(), so populate the normalized _abs_* fields here 

477 component.normalize_diameter() 

478 component.normalize_max_flow() 

479 return component 

480 instantiate.do_not_call_in_templates = True 

481 

482 def to_yaml(self): 

483 return { 

484 'name': self.name, 

485 'type': self.type, 

486 'diameter': float(self.diameter) if self.diameter is not None else None, 

487 'diameter_unit': self.diameter_unit, 

488 'max_flow': float(self.max_flow) if self.max_flow is not None else None, 

489 'max_flow_unit': self.max_flow_unit, 

490 'label': self.label, 

491 'description': self.description, 

492 } 

493 

494 

495class CoolingOutflowTemplate(DiameterMixin, ModularComponentTemplateModel): 

496 """ 

497 A template for a CoolingOutflow to be created for a new Device. 

498 """ 

499 type = models.CharField( 

500 verbose_name=_('type'), 

501 max_length=50, 

502 choices=CoolingConnectorTypeChoices, 

503 blank=True, 

504 null=True 

505 ) 

506 # diameter, diameter_unit, _abs_diameter provided by DiameterMixin 

507 cooling_intake = models.ForeignKey( 

508 to='dcim.CoolingIntakeTemplate', 

509 on_delete=models.SET_NULL, 

510 blank=True, 

511 null=True, 

512 related_name='coolingoutflow_templates' 

513 ) 

514 

515 component_model = CoolingOutflow 

516 

517 class Meta(ModularComponentTemplateModel.Meta): 

518 verbose_name = _('cooling outflow template') 

519 verbose_name_plural = _('cooling outflow templates') 

520 

521 def clean(self): 

522 super().clean() 

523 

524 # Validate cooling intake assignment 

525 if self.cooling_intake: 

526 if self.device_type and self.cooling_intake.device_type != self.device_type: 

527 raise ValidationError( 

528 _("Parent cooling intake ({cooling_intake}) must belong to the same device type").format( 

529 cooling_intake=self.cooling_intake 

530 ) 

531 ) 

532 if self.module_type and self.cooling_intake.module_type != self.module_type: 

533 raise ValidationError( 

534 _("Parent cooling intake ({cooling_intake}) must belong to the same module type").format( 

535 cooling_intake=self.cooling_intake 

536 ) 

537 ) 

538 

539 def instantiate(self, **kwargs): 

540 if self.cooling_intake: 

541 cooling_intake_name = self.cooling_intake.resolve_name(kwargs.get('module'), kwargs.get('device')) 

542 cooling_intake = CoolingIntake.objects.get(name=cooling_intake_name, **kwargs) 

543 else: 

544 cooling_intake = None 

545 component = self.component_model( 

546 name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), 

547 label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), 

548 type=self.type, 

549 diameter=self.diameter, 

550 diameter_unit=self.diameter_unit, 

551 cooling_intake=cooling_intake, 

552 **kwargs 

553 ) 

554 # bulk_create bypasses save(), so populate the normalized _abs_diameter here 

555 component.normalize_diameter() 

556 return component 

557 instantiate.do_not_call_in_templates = True 

558 

559 def to_yaml(self): 

560 return { 

561 'name': self.name, 

562 'type': self.type, 

563 'diameter': float(self.diameter) if self.diameter is not None else None, 

564 'diameter_unit': self.diameter_unit, 

565 'cooling_intake': self.cooling_intake.name if self.cooling_intake else None, 

566 'label': self.label, 

567 'description': self.description, 

568 } 

569 

570 

571class InterfaceTemplate(InterfaceChannelRenameMixin, InterfaceValidationMixin, ModularComponentTemplateModel): 

572 """ 

573 A template for a physical data interface on a new Device. 

574 """ 

575 # Override ComponentTemplateModel._name to specify naturalize_interface function 

576 _name = NaturalOrderingField( 

577 target_field='name', 

578 naturalize_function=naturalize_interface, 

579 max_length=100, 

580 blank=True 

581 ) 

582 type = models.CharField( 

583 verbose_name=_('type'), 

584 max_length=50, 

585 choices=InterfaceTypeChoices 

586 ) 

587 channels = models.PositiveSmallIntegerField( 

588 verbose_name=_('channels'), 

589 blank=True, 

590 null=True, 

591 validators=( 

592 MinValueValidator(INTERFACE_CHANNELS_MIN), 

593 MaxValueValidator(INTERFACE_CHANNELS_MAX) 

594 ), 

595 help_text=_('The number of channels into which this interface is channelized') 

596 ) 

597 channel_id = models.PositiveSmallIntegerField( 

598 verbose_name=_('channel ID'), 

599 blank=True, 

600 null=True, 

601 validators=( 

602 MinValueValidator(INTERFACE_CHANNELS_MIN), 

603 MaxValueValidator(INTERFACE_CHANNELS_MAX) 

604 ), 

605 help_text=_('The channel on the parent interface to which this subinterface is bound') 

606 ) 

607 enabled = models.BooleanField( 

608 verbose_name=_('enabled'), 

609 default=True 

610 ) 

611 mgmt_only = models.BooleanField( 

612 default=False, 

613 verbose_name=_('management only') 

614 ) 

615 parent = models.ForeignKey( 

616 to='self', 

617 on_delete=models.RESTRICT, 

618 related_name='child_interfaces', 

619 null=True, 

620 blank=True, 

621 verbose_name=_('parent interface') 

622 ) 

623 bridge = models.ForeignKey( 

624 to='self', 

625 on_delete=models.SET_NULL, 

626 related_name='bridge_interfaces', 

627 null=True, 

628 blank=True, 

629 verbose_name=_('bridge interface') 

630 ) 

631 poe_mode = models.CharField( 

632 max_length=50, 

633 choices=InterfacePoEModeChoices, 

634 blank=True, 

635 null=True, 

636 verbose_name=_('PoE mode') 

637 ) 

638 poe_type = models.CharField( 

639 max_length=50, 

640 choices=InterfacePoETypeChoices, 

641 blank=True, 

642 null=True, 

643 verbose_name=_('PoE type') 

644 ) 

645 rf_role = models.CharField( 

646 max_length=30, 

647 choices=WirelessRoleChoices, 

648 blank=True, 

649 null=True, 

650 verbose_name=_('wireless role') 

651 ) 

652 

653 component_model = Interface 

654 

655 class Meta(ModularComponentTemplateModel.Meta): 

656 constraints = ( 

657 *ModularComponentTemplateModel.Meta.constraints, 

658 models.UniqueConstraint( 

659 fields=('parent', 'channel_id'), 

660 name='%(app_label)s_%(class)s_unique_parent_channel_id' 

661 ), 

662 ) 

663 verbose_name = _('interface template') 

664 verbose_name_plural = _('interface templates') 

665 

666 def clean(self): 

667 super().clean() 

668 

669 # Self-reference and interface-type restrictions are enforced by InterfaceValidationMixin 

670 if self.parent: 

671 if self.device_type and self.device_type != self.parent.device_type: 

672 raise ValidationError({ 

673 'parent': _( 

674 "Parent interface ({parent}) must belong to the same device type" 

675 ).format(parent=self.parent) 

676 }) 

677 if self.module_type and self.module_type != self.parent.module_type: 

678 raise ValidationError({ 

679 'parent': _( 

680 "Parent interface ({parent}) must belong to the same module type" 

681 ).format(parent=self.parent) 

682 }) 

683 

684 if self.bridge: 

685 if self.device_type and self.device_type != self.bridge.device_type: 

686 raise ValidationError({ 

687 'bridge': _( 

688 "Bridge interface ({bridge}) must belong to the same device type" 

689 ).format(bridge=self.bridge) 

690 }) 

691 if self.module_type and self.module_type != self.bridge.module_type: 

692 raise ValidationError({ 

693 'bridge': _( 

694 "Bridge interface ({bridge}) must belong to the same module type" 

695 ).format(bridge=self.bridge) 

696 }) 

697 

698 def instantiate(self, **kwargs): 

699 return self.component_model( 

700 name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), 

701 label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), 

702 type=self.type, 

703 channels=self.channels, 

704 channel_id=self.channel_id, 

705 enabled=self.enabled, 

706 mgmt_only=self.mgmt_only, 

707 poe_mode=self.poe_mode, 

708 poe_type=self.poe_type, 

709 rf_role=self.rf_role, 

710 **kwargs 

711 ) 

712 instantiate.do_not_call_in_templates = True 

713 

714 def to_yaml(self): 

715 return { 

716 'name': self.name, 

717 'type': self.type, 

718 'channels': self.channels, 

719 'channel_id': self.channel_id, 

720 'enabled': self.enabled, 

721 'mgmt_only': self.mgmt_only, 

722 'label': self.label, 

723 'description': self.description, 

724 'parent': self.parent.name if self.parent else None, 

725 'bridge': self.bridge.name if self.bridge else None, 

726 'poe_mode': self.poe_mode, 

727 'poe_type': self.poe_type, 

728 'rf_role': self.rf_role, 

729 } 

730 

731 

732class PortTemplateMapping(ChangeLoggingMixin, PortMappingBase): 

733 """ 

734 Maps a FrontPortTemplate & position to a RearPortTemplate & position. 

735 """ 

736 device_type = models.ForeignKey( 

737 to='dcim.DeviceType', 

738 on_delete=models.CASCADE, 

739 related_name='port_mappings', 

740 blank=True, 

741 null=True, 

742 ) 

743 module_type = models.ForeignKey( 

744 to='dcim.ModuleType', 

745 on_delete=models.CASCADE, 

746 related_name='port_mappings', 

747 blank=True, 

748 null=True, 

749 ) 

750 front_port = models.ForeignKey( 

751 to='dcim.FrontPortTemplate', 

752 on_delete=models.CASCADE, 

753 related_name='mappings', 

754 ) 

755 rear_port = models.ForeignKey( 

756 to='dcim.RearPortTemplate', 

757 on_delete=models.CASCADE, 

758 related_name='mappings', 

759 ) 

760 

761 class Meta(PortMappingBase.Meta): 

762 # Inherit the unique constraints from PortMappingBase.Meta. 

763 pass 

764 

765 def clean(self): 

766 super().clean() 

767 

768 # Validate rear port assignment 

769 if self.front_port.device_type_id != self.rear_port.device_type_id: 

770 raise ValidationError({ 

771 "rear_port": _("Rear port ({rear_port}) must belong to the same device type").format( 

772 rear_port=self.rear_port 

773 ) 

774 }) 

775 

776 def save(self, *args, **kwargs): 

777 # Associate the mapping with the parent DeviceType/ModuleType 

778 self.device_type = self.front_port.device_type 

779 self.module_type = self.front_port.module_type 

780 super().save(*args, **kwargs) 

781 

782 def to_yaml(self): 

783 return { 

784 'front_port': self.front_port.name, 

785 'front_port_position': self.front_port_position, 

786 'rear_port': self.rear_port.name, 

787 'rear_port_position': self.rear_port_position, 

788 } 

789 

790 

791class FrontPortTemplate(ModularComponentTemplateModel): 

792 """ 

793 Template for a pass-through port on the front of a new Device. 

794 """ 

795 type = models.CharField( 

796 verbose_name=_('type'), 

797 max_length=50, 

798 choices=PortTypeChoices 

799 ) 

800 color = ColorField( 

801 verbose_name=_('color'), 

802 blank=True 

803 ) 

804 positions = models.PositiveSmallIntegerField( 

805 verbose_name=_('positions'), 

806 default=1, 

807 validators=[ 

808 MinValueValidator(PORT_POSITION_MIN), 

809 MaxValueValidator(PORT_POSITION_MAX) 

810 ], 

811 ) 

812 

813 component_model = FrontPort 

814 

815 class Meta(ModularComponentTemplateModel.Meta): 

816 constraints = ( 

817 models.UniqueConstraint( 

818 fields=('device_type', 'name'), 

819 name='%(app_label)s_%(class)s_unique_device_type_name' 

820 ), 

821 models.UniqueConstraint( 

822 fields=('module_type', 'name'), 

823 name='%(app_label)s_%(class)s_unique_module_type_name' 

824 ), 

825 ) 

826 verbose_name = _('front port template') 

827 verbose_name_plural = _('front port templates') 

828 

829 def clean(self): 

830 super().clean() 

831 

832 # Check that positions is greater than or equal to the number of associated RearPortTemplates 

833 if not self._state.adding: 

834 mapping_count = self.mappings.count() 

835 if self.positions < mapping_count: 

836 raise ValidationError({ 

837 "positions": _( 

838 "The number of positions cannot be less than the number of mapped rear port templates ({count})" 

839 ).format(count=mapping_count) 

840 }) 

841 

842 def instantiate(self, **kwargs): 

843 return self.component_model( 

844 name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), 

845 label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), 

846 type=self.type, 

847 color=self.color, 

848 positions=self.positions, 

849 **kwargs 

850 ) 

851 instantiate.do_not_call_in_templates = True 

852 

853 def to_yaml(self): 

854 return { 

855 'name': self.name, 

856 'type': self.type, 

857 'color': self.color, 

858 'positions': self.positions, 

859 'label': self.label, 

860 'description': self.description, 

861 } 

862 

863 

864class RearPortTemplate(ModularComponentTemplateModel): 

865 """ 

866 Template for a pass-through port on the rear of a new Device. 

867 """ 

868 type = models.CharField( 

869 verbose_name=_('type'), 

870 max_length=50, 

871 choices=PortTypeChoices 

872 ) 

873 color = ColorField( 

874 verbose_name=_('color'), 

875 blank=True 

876 ) 

877 positions = models.PositiveSmallIntegerField( 

878 verbose_name=_('positions'), 

879 default=1, 

880 validators=[ 

881 MinValueValidator(PORT_POSITION_MIN), 

882 MaxValueValidator(PORT_POSITION_MAX) 

883 ], 

884 ) 

885 

886 component_model = RearPort 

887 

888 class Meta(ModularComponentTemplateModel.Meta): 

889 verbose_name = _('rear port template') 

890 verbose_name_plural = _('rear port templates') 

891 

892 def clean(self): 

893 super().clean() 

894 

895 # Check that positions is greater than or equal to the number of associated FrontPortTemplates 

896 if not self._state.adding: 

897 mapping_count = self.mappings.count() 

898 if self.positions < mapping_count: 

899 raise ValidationError({ 

900 "positions": _( 

901 "The number of positions cannot be less than the number of mapped front port templates " 

902 "({count})" 

903 ).format(count=mapping_count) 

904 }) 

905 

906 def instantiate(self, **kwargs): 

907 return self.component_model( 

908 name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), 

909 label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), 

910 type=self.type, 

911 color=self.color, 

912 positions=self.positions, 

913 **kwargs 

914 ) 

915 instantiate.do_not_call_in_templates = True 

916 

917 def to_yaml(self): 

918 return { 

919 'name': self.name, 

920 'type': self.type, 

921 'color': self.color, 

922 'positions': self.positions, 

923 'label': self.label, 

924 'description': self.description, 

925 } 

926 

927 

928class ModuleBayTemplate(ModularComponentTemplateModel): 

929 """ 

930 A template for a ModuleBay to be created for a new parent Device. 

931 """ 

932 position = models.CharField( 

933 verbose_name=_('position'), 

934 max_length=30, 

935 blank=True, 

936 help_text=_('Identifier to reference when renaming installed components') 

937 ) 

938 enabled = models.BooleanField( 

939 verbose_name=_('enabled'), 

940 default=True, 

941 ) 

942 module_bay_types = models.ManyToManyField( 

943 to='dcim.ModuleBayType', 

944 related_name='module_bay_templates', 

945 blank=True, 

946 verbose_name=_('module bay types'), 

947 help_text=_('Types of modules that can be installed in this bay (empty = unconstrained)'), 

948 ) 

949 

950 component_model = ModuleBay 

951 

952 class Meta(ModularComponentTemplateModel.Meta): 

953 verbose_name = _('module bay template') 

954 verbose_name_plural = _('module bay templates') 

955 

956 def instantiate(self, **kwargs): 

957 module = kwargs.get('module') 

958 instance = self.component_model( 

959 name=self.resolve_name(module, kwargs.get('device')), 

960 label=self.resolve_label(module, kwargs.get('device')), 

961 position=self.resolve_position(module, kwargs.get('device')), 

962 enabled=self.enabled, 

963 # A module bay created for an installed module nests under that module's 

964 # bay. bulk_create() bypasses ModuleBay.save() (which would otherwise set 

965 # this), so the parent must be assigned here for the path trigger to nest 

966 # it correctly. Device-level bays are instantiated without a module and 

967 # remain roots (parent=None). 

968 parent=module.module_bay if module else None, 

969 **kwargs 

970 ) 

971 # Stash reference so callers (Module.save, Device._instantiate_components) can 

972 # copy M2M fields (e.g. module_bay_types) that bulk_create cannot handle. 

973 instance._source_template = self 

974 return instance 

975 instantiate.do_not_call_in_templates = True 

976 

977 def to_yaml(self): 

978 return { 

979 'name': self.name, 

980 'label': self.label, 

981 'position': self.position, 

982 'enabled': self.enabled, 

983 'description': self.description, 

984 'module_bay_types': [t.name for t in self.module_bay_types.all()], 

985 } 

986 

987 

988class DeviceBayTemplate(ComponentTemplateModel): 

989 """ 

990 A template for a DeviceBay to be created for a new parent Device. 

991 """ 

992 enabled = models.BooleanField( 

993 verbose_name=_('enabled'), 

994 default=True, 

995 ) 

996 

997 component_model = DeviceBay 

998 

999 class Meta(ComponentTemplateModel.Meta): 

1000 verbose_name = _('device bay template') 

1001 verbose_name_plural = _('device bay templates') 

1002 

1003 def instantiate(self, device): 

1004 return self.component_model( 

1005 device=device, 

1006 name=self.name, 

1007 label=self.label, 

1008 enabled=self.enabled, 

1009 ) 

1010 instantiate.do_not_call_in_templates = True 

1011 

1012 def clean(self): 

1013 if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT: 

1014 raise ValidationError( 

1015 _( 

1016 'Subdevice role of device type ({device_type}) must be set to "parent" to allow device bays.' 

1017 ).format(device_type=self.device_type) 

1018 ) 

1019 

1020 def to_yaml(self): 

1021 return { 

1022 'name': self.name, 

1023 'label': self.label, 

1024 'enabled': self.enabled, 

1025 'description': self.description, 

1026 } 

1027 

1028 

1029class InventoryItemTemplate(LtreeModel, ComponentTemplateModel): 

1030 """ 

1031 A template for an InventoryItem to be created for a new parent Device. 

1032 """ 

1033 parent = models.ForeignKey( 

1034 to='self', 

1035 on_delete=models.CASCADE, 

1036 related_name='child_items', 

1037 blank=True, 

1038 null=True, 

1039 db_index=True 

1040 ) 

1041 component_type = models.ForeignKey( 

1042 to='contenttypes.ContentType', 

1043 on_delete=models.PROTECT, 

1044 related_name='+', 

1045 blank=True, 

1046 null=True 

1047 ) 

1048 component_id = models.PositiveBigIntegerField( 

1049 blank=True, 

1050 null=True 

1051 ) 

1052 component = GenericForeignKey( 

1053 ct_field='component_type', 

1054 fk_field='component_id' 

1055 ) 

1056 role = models.ForeignKey( 

1057 to='dcim.InventoryItemRole', 

1058 on_delete=models.PROTECT, 

1059 related_name='inventory_item_templates', 

1060 blank=True, 

1061 null=True 

1062 ) 

1063 manufacturer = models.ForeignKey( 

1064 to='dcim.Manufacturer', 

1065 on_delete=models.PROTECT, 

1066 related_name='inventory_item_templates', 

1067 blank=True, 

1068 null=True 

1069 ) 

1070 part_id = models.CharField( 

1071 max_length=50, 

1072 verbose_name=_('part ID'), 

1073 blank=True, 

1074 help_text=_('Manufacturer-assigned part identifier') 

1075 ) 

1076 

1077 objects = LtreeManager() 

1078 component_model = InventoryItem 

1079 

1080 class Meta: 

1081 ordering = ('device_type__id', 'parent__id', 'name') 

1082 indexes = ( 

1083 models.Index(fields=('component_type', 'component_id')), 

1084 GistIndex(fields=['path'], name='dcim_inv_item_tmpl_path_gist'), 

1085 ) 

1086 constraints = ( 

1087 models.UniqueConstraint( 

1088 fields=('device_type', 'parent', 'name'), 

1089 name='%(app_label)s_%(class)s_unique_device_type_parent_name' 

1090 ), 

1091 ) 

1092 verbose_name = _('inventory item template') 

1093 verbose_name_plural = _('inventory item templates') 

1094 

1095 def instantiate(self, **kwargs): 

1096 parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None 

1097 if self.component: 

1098 model = self.component.component_model 

1099 component = model.objects.get(name=self.component.name, **kwargs) 

1100 else: 

1101 component = None 

1102 return self.component_model( 

1103 parent=parent, 

1104 name=self.name, 

1105 label=self.label, 

1106 component=component, 

1107 role=self.role, 

1108 manufacturer=self.manufacturer, 

1109 part_id=self.part_id, 

1110 **kwargs 

1111 ) 

1112 instantiate.do_not_call_in_templates = True