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
« 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 _
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
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)
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)
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 )
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 )
90 def __str__(self):
91 if self.label:
92 return f"{self.name} ({self.label})"
93 return self.name
95 def instantiate(self, device):
96 """
97 Instantiate a new component on the specified Device.
98 """
99 raise NotImplementedError()
101 def __init__(self, *args, **kwargs):
102 super().__init__(*args, **kwargs)
104 # Cache the original DeviceType ID for reference under clean()
105 self._original_device_type = self.__dict__.get('device_type_id')
107 def to_objectchange(self, action):
108 objectchange = super().to_objectchange(action)
109 objectchange.related_object = self.device_type
110 return objectchange
112 def clean(self):
113 super().clean()
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 })
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 )
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 )
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
165 def clean(self):
166 super().clean()
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 )
178 @staticmethod
179 def _resolve_vc_position(value: str, device) -> str:
180 """
181 Resolves {vc_position} and {vc_position:X} tokens.
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'
196 return VC_POSITION_RE.sub(replacer, value)
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
215 def resolve_name(self, module=None, device=None):
216 return self._resolve_all_placeholders(self.name, module, device)
218 def resolve_label(self, module=None, device=None):
219 return self._resolve_all_placeholders(self.label, module, device)
221 def resolve_position(self, module=None, device=None):
222 return self._resolve_all_placeholders(self.position, module, device)
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 )
237 component_model = ConsolePort
239 class Meta(ModularComponentTemplateModel.Meta):
240 verbose_name = _('console port template')
241 verbose_name_plural = _('console port templates')
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 )
251 def to_yaml(self):
252 return {
253 'name': self.name,
254 'type': self.type,
255 'label': self.label,
256 'description': self.description,
257 }
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 )
272 component_model = ConsoleServerPort
274 class Meta(ModularComponentTemplateModel.Meta):
275 verbose_name = _('console server port template')
276 verbose_name_plural = _('console server port templates')
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
287 def to_yaml(self):
288 return {
289 'name': self.name,
290 'type': self.type,
291 'label': self.label,
292 'description': self.description,
293 }
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 )
322 component_model = PowerPort
324 class Meta(ModularComponentTemplateModel.Meta):
325 verbose_name = _('power port template')
326 verbose_name_plural = _('power port templates')
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
339 def clean(self):
340 super().clean()
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 })
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 }
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 )
392 component_model = PowerOutlet
394 class Meta(ModularComponentTemplateModel.Meta):
395 verbose_name = _('power outlet template')
396 verbose_name_plural = _('power outlet templates')
398 def clean(self):
399 super().clean()
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 )
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
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 }
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
459 component_model = CoolingIntake
461 class Meta(ModularComponentTemplateModel.Meta):
462 verbose_name = _('cooling intake template')
463 verbose_name_plural = _('cooling intake templates')
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
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 }
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 )
515 component_model = CoolingOutflow
517 class Meta(ModularComponentTemplateModel.Meta):
518 verbose_name = _('cooling outflow template')
519 verbose_name_plural = _('cooling outflow templates')
521 def clean(self):
522 super().clean()
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 )
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
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 }
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 )
653 component_model = Interface
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')
666 def clean(self):
667 super().clean()
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 })
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 })
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
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 }
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 )
761 class Meta(PortMappingBase.Meta):
762 # Inherit the unique constraints from PortMappingBase.Meta.
763 pass
765 def clean(self):
766 super().clean()
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 })
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)
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 }
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 )
813 component_model = FrontPort
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')
829 def clean(self):
830 super().clean()
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 })
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
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 }
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 )
886 component_model = RearPort
888 class Meta(ModularComponentTemplateModel.Meta):
889 verbose_name = _('rear port template')
890 verbose_name_plural = _('rear port templates')
892 def clean(self):
893 super().clean()
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 })
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
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 }
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 )
950 component_model = ModuleBay
952 class Meta(ModularComponentTemplateModel.Meta):
953 verbose_name = _('module bay template')
954 verbose_name_plural = _('module bay templates')
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
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 }
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 )
997 component_model = DeviceBay
999 class Meta(ComponentTemplateModel.Meta):
1000 verbose_name = _('device bay template')
1001 verbose_name_plural = _('device bay templates')
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
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 )
1020 def to_yaml(self):
1021 return {
1022 'name': self.name,
1023 'label': self.label,
1024 'enabled': self.enabled,
1025 'description': self.description,
1026 }
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 )
1077 objects = LtreeManager()
1078 component_model = InventoryItem
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')
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