feat(dcim): Add enabled field to Module and Device bays

Add an `enabled` boolean field to ModuleBay, ModuleBayTemplate,
DeviceBay, and DeviceBayTemplate models. Disabled bays prevent component
installation and display accordingly in the UI. Update serializers,
filters, forms, and tables to support the new field.

Fixes #20152
This commit is contained in:
Martin Hauser 2026-03-11 20:51:23 +01:00
parent 02165a28a0
commit 625c4eb5bb
No known key found for this signature in database
21 changed files with 423 additions and 88 deletions

View File

@ -23,3 +23,8 @@ The device bay's name. Must be unique to the parent device.
### Label ### Label
An alternative physical label identifying the device bay. An alternative physical label identifying the device bay.
### Enabled
Whether this device bay is enabled. Disabled device bays are not available for installation.

View File

@ -1,6 +1,6 @@
# Module Bays # Module Bays
Module bays represent a space or slot within a device in which a field-replaceable [module](./module.md) may be installed. A common example is that of a chassis-based switch such as the Cisco Nexus 9000 or Juniper EX9200. Modules in turn hold additional components that become available to the parent device. Module bays represent a space or slot within a device in which a field-replaceable [module](./module.md) may be installed. A common example is that of a chassis-based switch such as the Cisco Nexus 9000 or Juniper EX9200. Modules, in turn, hold additional components that become available to the parent device.
!!! note !!! note
If you need to model child devices rather than modules, use a [device bay](./devicebay.md) instead. If you need to model child devices rather than modules, use a [device bay](./devicebay.md) instead.
@ -29,3 +29,8 @@ An alternative physical label identifying the module bay.
### Position ### Position
The numeric position in which this module bay is situated. For example, this would be the number assigned to a slot within a chassis-based switch. The numeric position in which this module bay is situated. For example, this would be the number assigned to a slot within a chassis-based switch.
### Enabled
Whether this module bay is enabled. Disabled module bays are not available for installation.

View File

@ -423,27 +423,29 @@ class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer):
required=False, required=False,
allow_null=True allow_null=True
) )
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta: class Meta:
model = ModuleBay model = ModuleBay
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'installed_module', 'label', 'position', 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'position', 'enabled',
'description', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', 'description', 'installed_module', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description') brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'enabled', 'description', '_occupied')
class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer): class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer):
device = DeviceSerializer(nested=True) device = DeviceSerializer(nested=True)
installed_device = DeviceSerializer(nested=True, required=False, allow_null=True) installed_device = DeviceSerializer(nested=True, required=False, allow_null=True)
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta: class Meta:
model = DeviceBay model = DeviceBay
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'enabled', 'description',
'owner', 'tags', 'custom_fields', 'created', 'last_updated', 'installed_device', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description') brief_fields = ('id', 'url', 'display', 'device', 'name', 'enabled', 'description', '_occupied',)
class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer): class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer):

View File

@ -317,10 +317,10 @@ class ModuleBayTemplateSerializer(ComponentTemplateSerializer):
class Meta: class Meta:
model = ModuleBayTemplate model = ModuleBayTemplate
fields = [ fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'position', 'description', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'position', 'enabled', 'description',
'created', 'last_updated', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'enabled', 'description')
class DeviceBayTemplateSerializer(ComponentTemplateSerializer): class DeviceBayTemplateSerializer(ComponentTemplateSerializer):
@ -331,10 +331,10 @@ class DeviceBayTemplateSerializer(ComponentTemplateSerializer):
class Meta: class Meta:
model = DeviceBayTemplate model = DeviceBayTemplate
fields = [ fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'id', 'url', 'display', 'device_type', 'name', 'label', 'enabled', 'description',
'created', 'last_updated' 'created', 'last_updated'
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'enabled', 'description')
class InventoryItemTemplateSerializer(ComponentTemplateSerializer): class InventoryItemTemplateSerializer(ComponentTemplateSerializer):

View File

@ -1032,7 +1032,7 @@ class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
class Meta: class Meta:
model = ModuleBayTemplate model = ModuleBayTemplate
fields = ('id', 'name', 'label', 'position', 'description') fields = ('id', 'name', 'label', 'position', 'enabled', 'description')
@register_filterset @register_filterset
@ -1040,7 +1040,7 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
class Meta: class Meta:
model = DeviceBayTemplate model = DeviceBayTemplate
fields = ('id', 'name', 'label', 'description') fields = ('id', 'name', 'label', 'enabled', 'description')
@register_filterset @register_filterset
@ -2397,7 +2397,7 @@ class ModuleBayFilterSet(ModularDeviceComponentFilterSet):
class Meta: class Meta:
model = ModuleBay model = ModuleBay
fields = ('id', 'name', 'label', 'position', 'description') fields = ('id', 'name', 'label', 'position', 'enabled', 'description')
@register_filterset @register_filterset
@ -2417,7 +2417,7 @@ class DeviceBayFilterSet(DeviceComponentFilterSet):
class Meta: class Meta:
model = DeviceBay model = DeviceBay
fields = ('id', 'name', 'label', 'description') fields = ('id', 'name', 'label', 'enabled', 'description')
@register_filterset @register_filterset

View File

@ -108,10 +108,13 @@ class RearPortBulkCreateForm(
field_order = ('name', 'label', 'type', 'positions', 'mark_connected', 'description', 'tags') field_order = ('name', 'label', 'type', 'positions', 'mark_connected', 'description', 'tags')
class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): class ModuleBayBulkCreateForm(
form_from_model(ModuleBay, ['enabled']),
DeviceBulkAddComponentForm
):
model = ModuleBay model = ModuleBay
field_order = ('name', 'label', 'position', 'description', 'tags') field_order = ('name', 'label', 'position', 'enabled', 'description', 'tags')
replication_fields = ('name', 'label', 'position') replication_fields = ('name', 'label', 'position', 'enabled')
position = ExpandableNameField( position = ExpandableNameField(
label=_('Position'), label=_('Position'),
required=False, required=False,
@ -119,9 +122,12 @@ class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
) )
class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): class DeviceBayBulkCreateForm(
form_from_model(DeviceBay, ['enabled']),
DeviceBulkAddComponentForm
):
model = DeviceBay model = DeviceBay
field_order = ('name', 'label', 'description', 'tags') field_order = ('name', 'label', 'enabled', 'description', 'tags')
class InventoryItemBulkCreateForm( class InventoryItemBulkCreateForm(

View File

@ -1245,6 +1245,11 @@ class ModuleBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
label=_('Description'), label=_('Description'),
required=False required=False
) )
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=BulkEditNullBooleanSelect,
)
nullable_fields = ('label', 'position', 'description') nullable_fields = ('label', 'position', 'description')
@ -1263,6 +1268,11 @@ class DeviceBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
label=_('Description'), label=_('Description'),
required=False required=False
) )
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=BulkEditNullBooleanSelect,
)
nullable_fields = ('label', 'description') nullable_fields = ('label', 'description')
@ -1687,23 +1697,23 @@ class RearPortBulkEditForm(
class ModuleBayBulkEditForm( class ModuleBayBulkEditForm(
form_from_model(ModuleBay, ['label', 'position', 'description']), form_from_model(ModuleBay, ['label', 'position', 'enabled', 'description']),
NetBoxModelBulkEditForm NetBoxModelBulkEditForm
): ):
model = ModuleBay model = ModuleBay
fieldsets = ( fieldsets = (
FieldSet('label', 'position', 'description'), FieldSet('label', 'position', 'enabled', 'description'),
) )
nullable_fields = ('label', 'position', 'description') nullable_fields = ('label', 'position', 'description')
class DeviceBayBulkEditForm( class DeviceBayBulkEditForm(
form_from_model(DeviceBay, ['label', 'description']), form_from_model(DeviceBay, ['label', 'enabled', 'description']),
NetBoxModelBulkEditForm NetBoxModelBulkEditForm
): ):
model = DeviceBay model = DeviceBay
fieldsets = ( fieldsets = (
FieldSet('label', 'description'), FieldSet('label', 'enabled', 'description'),
) )
nullable_fields = ('label', 'description') nullable_fields = ('label', 'description')

View File

@ -1154,7 +1154,13 @@ class ModuleBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
class Meta: class Meta:
model = ModuleBay model = ModuleBay
fields = ('device', 'name', 'label', 'position', 'description', 'owner', 'tags') fields = ('device', 'name', 'label', 'position', 'enabled', 'description', 'owner', 'tags')
def clean_enabled(self):
# Make sure enabled is True when it's not included in the uploaded data
if 'enabled' not in self.data:
return True
return self.cleaned_data['enabled']
class DeviceBayImportForm(OwnerCSVMixin, NetBoxModelImportForm): class DeviceBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
@ -1176,7 +1182,7 @@ class DeviceBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
class Meta: class Meta:
model = DeviceBay model = DeviceBay
fields = ('device', 'name', 'label', 'installed_device', 'description', 'owner', 'tags') fields = ('device', 'name', 'label', 'enabled', 'installed_device', 'description', 'owner', 'tags')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -1204,6 +1210,12 @@ class DeviceBayImportForm(OwnerCSVMixin, NetBoxModelImportForm):
else: else:
self.fields['installed_device'].queryset = Device.objects.none() self.fields['installed_device'].queryset = Device.objects.none()
def clean_enabled(self):
# Make sure enabled is True when it's not included in the uploaded data
if 'enabled' not in self.data:
return True
return self.cleaned_data['enabled']
class InventoryItemImportForm(OwnerCSVMixin, NetBoxModelImportForm): class InventoryItemImportForm(OwnerCSVMixin, NetBoxModelImportForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(

View File

@ -1870,7 +1870,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
model = ModuleBay model = ModuleBay
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'position', name=_('Attributes')), FieldSet('name', 'label', 'position', 'enabled', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
@ -1878,31 +1878,41 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
), ),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model)
position = forms.CharField( position = forms.CharField(
label=_('Position'), label=_('Position'),
required=False required=False
) )
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
)
tag = TagFilterField(model)
class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm): class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = ModuleBayTemplate model = ModuleBayTemplate
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'position', name=_('Attributes')), FieldSet('name', 'label', 'position', 'enabled', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')), FieldSet('device_type_id', 'module_type_id', name=_('Device')),
) )
position = forms.CharField( position = forms.CharField(
label=_('Position'), label=_('Position'),
required=False, required=False,
) )
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
)
class DeviceBayFilterForm(DeviceComponentFilterForm): class DeviceBayFilterForm(DeviceComponentFilterForm):
model = DeviceBay model = DeviceBay
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', name=_('Attributes')), FieldSet('name', 'label', 'enabled', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
@ -1910,6 +1920,11 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
), ),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
)
tag = TagFilterField(model) tag = TagFilterField(model)
@ -1917,9 +1932,14 @@ class DeviceBayTemplateFilterForm(DeviceComponentTemplateFilterForm):
model = DeviceBayTemplate model = DeviceBayTemplate
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', name=_('Attributes')), FieldSet('name', 'label', 'enabled', name=_('Attributes')),
FieldSet('device_type_id', name=_('Device')), FieldSet('device_type_id', name=_('Device')),
) )
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
)
class InventoryItemFilterForm(DeviceComponentFilterForm): class InventoryItemFilterForm(DeviceComponentFilterForm):

View File

@ -777,7 +777,7 @@ class ModuleForm(ModuleCommonForm, PrimaryModelForm):
'device_id': '$device', 'device_id': '$device',
}, },
context={ context={
'disabled': 'installed_module', 'disabled': '_occupied',
}, },
) )
module_type = DynamicModelChoiceField( module_type = DynamicModelChoiceField(
@ -1233,26 +1233,26 @@ class ModuleBayTemplateForm(ModularComponentTemplateForm):
FieldSet('device_type', name=_('Device Type')), FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')), FieldSet('module_type', name=_('Module Type')),
), ),
'name', 'label', 'position', 'description', 'name', 'label', 'position', 'enabled', 'description',
), ),
) )
class Meta: class Meta:
model = ModuleBayTemplate model = ModuleBayTemplate
fields = [ fields = [
'device_type', 'module_type', 'name', 'label', 'position', 'description', 'device_type', 'module_type', 'name', 'label', 'position', 'enabled', 'description',
] ]
class DeviceBayTemplateForm(ComponentTemplateForm): class DeviceBayTemplateForm(ComponentTemplateForm):
fieldsets = ( fieldsets = (
FieldSet('device_type', 'name', 'label', 'description'), FieldSet('device_type', 'name', 'label', 'enabled', 'description'),
) )
class Meta: class Meta:
model = DeviceBayTemplate model = DeviceBayTemplate
fields = [ fields = [
'device_type', 'name', 'label', 'description', 'device_type', 'name', 'label', 'enabled', 'description',
] ]
@ -1698,25 +1698,25 @@ class RearPortForm(ModularDeviceComponentForm):
class ModuleBayForm(ModularDeviceComponentForm): class ModuleBayForm(ModularDeviceComponentForm):
fieldsets = ( fieldsets = (
FieldSet('device', 'module', 'name', 'label', 'position', 'description', 'tags',), FieldSet('device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'tags',),
) )
class Meta: class Meta:
model = ModuleBay model = ModuleBay
fields = [ fields = [
'device', 'module', 'name', 'label', 'position', 'description', 'owner', 'tags', 'device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'owner', 'tags',
] ]
class DeviceBayForm(DeviceComponentForm): class DeviceBayForm(DeviceComponentForm):
fieldsets = ( fieldsets = (
FieldSet('device', 'name', 'label', 'description', 'tags',), FieldSet('device', 'name', 'label', 'enabled', 'description', 'tags',),
) )
class Meta: class Meta:
model = DeviceBay model = DeviceBay
fields = [ fields = [
'device', 'name', 'label', 'description', 'owner', 'tags', 'device', 'name', 'label', 'enabled', 'description', 'owner', 'tags',
] ]

View File

@ -318,6 +318,7 @@ class DeviceFilter(
@strawberry_django.filter_type(models.DeviceBay, lookups=True) @strawberry_django.filter_type(models.DeviceBay, lookups=True)
class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter): class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter):
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
@ -326,7 +327,7 @@ class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter):
@strawberry_django.filter_type(models.DeviceBayTemplate, lookups=True) @strawberry_django.filter_type(models.DeviceBayTemplate, lookups=True)
class DeviceBayTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedModelFilter): class DeviceBayTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedModelFilter):
pass enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True) @strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True)
@ -742,11 +743,13 @@ class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
) )
parent_id: ID | None = strawberry_django.filter_field() parent_id: ID | None = strawberry_django.filter_field()
position: StrFilterLookup[str] | None = strawberry_django.filter_field() position: StrFilterLookup[str] | None = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True) @strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter): class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
position: StrFilterLookup[str] | None = strawberry_django.filter_field() position: StrFilterLookup[str] | None = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True) @strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)

View File

@ -0,0 +1,30 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0228_cable_bundle'),
]
operations = [
migrations.AddField(
model_name='devicebay',
name='enabled',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='devicebaytemplate',
name='enabled',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='modulebay',
name='enabled',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='modulebaytemplate',
name='enabled',
field=models.BooleanField(default=True),
),
]

View File

@ -722,6 +722,10 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
blank=True, blank=True,
help_text=_('Identifier to reference when renaming installed components') help_text=_('Identifier to reference when renaming installed components')
) )
enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True,
)
component_model = ModuleBay component_model = ModuleBay
@ -734,6 +738,7 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
label=self.resolve_label(kwargs.get('module')), label=self.resolve_label(kwargs.get('module')),
position=self.position, position=self.position,
enabled=self.enabled,
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True instantiate.do_not_call_in_templates = True
@ -743,6 +748,7 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
'name': self.name, 'name': self.name,
'label': self.label, 'label': self.label,
'position': self.position, 'position': self.position,
'enabled': self.enabled,
'description': self.description, 'description': self.description,
} }
@ -751,6 +757,11 @@ class DeviceBayTemplate(ComponentTemplateModel):
""" """
A template for a DeviceBay to be created for a new parent Device. A template for a DeviceBay to be created for a new parent Device.
""" """
enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True,
)
component_model = DeviceBay component_model = DeviceBay
class Meta(ComponentTemplateModel.Meta): class Meta(ComponentTemplateModel.Meta):
@ -761,7 +772,8 @@ class DeviceBayTemplate(ComponentTemplateModel):
return self.component_model( return self.component_model(
device=device, device=device,
name=self.name, name=self.name,
label=self.label label=self.label,
enabled=self.enabled,
) )
instantiate.do_not_call_in_templates = True instantiate.do_not_call_in_templates = True
@ -777,6 +789,7 @@ class DeviceBayTemplate(ComponentTemplateModel):
return { return {
'name': self.name, 'name': self.name,
'label': self.label, 'label': self.label,
'enabled': self.enabled,
'description': self.description, 'description': self.description,
} }

View File

@ -1257,10 +1257,14 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
blank=True, blank=True,
help_text=_('Identifier to reference when renaming installed components') help_text=_('Identifier to reference when renaming installed components')
) )
enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True,
)
objects = TreeManager() objects = TreeManager()
clone_fields = ('device',) clone_fields = ('device', 'enabled')
class Meta(ModularComponentModel.Meta): class Meta(ModularComponentModel.Meta):
# Empty tuple triggers Django migration detection for MPTT indexes # Empty tuple triggers Django migration detection for MPTT indexes
@ -1299,6 +1303,13 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
self.parent = None self.parent = None
super().save(*args, **kwargs) super().save(*args, **kwargs)
@property
def _occupied(self):
"""
Indicates whether the module bay is occupied by a module.
"""
return bool(not self.enabled or hasattr(self, 'installed_module'))
class DeviceBay(ComponentModel, TrackingModelMixin): class DeviceBay(ComponentModel, TrackingModelMixin):
""" """
@ -1311,8 +1322,12 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
blank=True, blank=True,
null=True null=True
) )
enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True,
)
clone_fields = ('device',) clone_fields = ('device', 'enabled')
class Meta(ComponentModel.Meta): class Meta(ComponentModel.Meta):
verbose_name = _('device bay') verbose_name = _('device bay')
@ -1327,6 +1342,16 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
device_type=self.device.device_type device_type=self.device.device_type
)) ))
# Prevent installing a device into a disabled bay
if self.installed_device and not self.enabled:
current_installed_device_id = (
DeviceBay.objects.filter(pk=self.pk).values_list('installed_device_id', flat=True).first()
)
if self.pk is None or current_installed_device_id != self.installed_device_id:
raise ValidationError({
'installed_device': _("Cannot install a device in a disabled device bay.")
})
# Cannot install a device into itself, obviously # Cannot install a device into itself, obviously
if self.installed_device and getattr(self, 'device', None) == self.installed_device: if self.installed_device and getattr(self, 'device', None) == self.installed_device:
raise ValidationError(_("Cannot install a device into itself.")) raise ValidationError(_("Cannot install a device into itself."))
@ -1341,6 +1366,13 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
).format(bay=current_bay) ).format(bay=current_bay)
}) })
@property
def _occupied(self):
"""
Indicates whether the device bay is occupied by a child device.
"""
return bool(not self.enabled or self.installed_device_id)
# #
# Inventory items # Inventory items

View File

@ -258,6 +258,14 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
) )
) )
# Prevent module from being installed in a disabled bay
if hasattr(self, 'module_bay') and self.module_bay and not self.module_bay.enabled:
current_module_bay_id = Module.objects.filter(pk=self.pk).values_list('module_bay_id', flat=True).first()
if self.pk is None or current_module_bay_id != self.module_bay_id:
raise ValidationError({
'module_bay': _("Cannot install a module in a disabled module bay.")
})
# Check for recursion # Check for recursion
module = self module = self
module_bays = [] module_bays = []

View File

@ -888,6 +888,9 @@ class DeviceBayTable(DeviceComponentTable):
'args': [Accessor('device_id')], 'args': [Accessor('device_id')],
} }
) )
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
status = tables.TemplateColumn( status = tables.TemplateColumn(
verbose_name=_('Status'), verbose_name=_('Status'),
template_code=DEVICEBAY_STATUS, template_code=DEVICEBAY_STATUS,
@ -925,12 +928,12 @@ class DeviceBayTable(DeviceComponentTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = models.DeviceBay model = models.DeviceBay
fields = ( fields = (
'pk', 'id', 'name', 'device', 'label', 'status', 'description', 'installed_device', 'installed_role', 'pk', 'id', 'name', 'device', 'label', 'enabled', 'status', 'description', 'installed_device',
'installed_device_type', 'installed_description', 'installed_serial', 'installed_asset_tag', 'tags', 'installed_role', 'installed_device_type', 'installed_description', 'installed_serial',
'created', 'last_updated', 'installed_asset_tag', 'tags', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description') default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'status', 'installed_device', 'description')
class DeviceDeviceBayTable(DeviceBayTable): class DeviceDeviceBayTable(DeviceBayTable):
@ -940,6 +943,9 @@ class DeviceDeviceBayTable(DeviceBayTable):
'"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>', '"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
extra_buttons=DEVICEBAY_BUTTONS extra_buttons=DEVICEBAY_BUTTONS
) )
@ -947,9 +953,9 @@ class DeviceDeviceBayTable(DeviceBayTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = models.DeviceBay model = models.DeviceBay
fields = ( fields = (
'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions', 'pk', 'id', 'name', 'label', 'enabled', 'status', 'installed_device', 'description', 'tags', 'actions',
) )
default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description') default_columns = ('pk', 'name', 'label', 'enabled', 'status', 'installed_device', 'description')
class ModuleBayTable(ModularDeviceComponentTable): class ModuleBayTable(ModularDeviceComponentTable):
@ -960,6 +966,9 @@ class ModuleBayTable(ModularDeviceComponentTable):
'args': [Accessor('device_id')], 'args': [Accessor('device_id')],
} }
) )
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
parent = tables.Column( parent = tables.Column(
linkify=True, linkify=True,
verbose_name=_('Parent'), verbose_name=_('Parent'),
@ -988,11 +997,11 @@ class ModuleBayTable(ModularDeviceComponentTable):
class Meta(ModularDeviceComponentTable.Meta): class Meta(ModularDeviceComponentTable.Meta):
model = models.ModuleBay model = models.ModuleBay
fields = ( fields = (
'pk', 'id', 'name', 'device', 'parent', 'label', 'position', 'installed_module', 'module_status', 'pk', 'id', 'name', 'device', 'enabled', 'parent', 'label', 'position', 'installed_module', 'module_status',
'module_serial', 'module_asset_tag', 'description', 'tags', 'module_serial', 'module_asset_tag', 'description', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'name', 'device', 'parent', 'label', 'installed_module', 'module_status', 'description', 'pk', 'name', 'device', 'enabled', 'parent', 'label', 'installed_module', 'module_status', 'description',
) )
def render_parent_bay(self, value): def render_parent_bay(self, value):
@ -1007,6 +1016,9 @@ class DeviceModuleBayTable(ModuleBayTable):
verbose_name=_('Name'), verbose_name=_('Name'),
linkify=True, linkify=True,
) )
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
extra_buttons=MODULEBAY_BUTTONS extra_buttons=MODULEBAY_BUTTONS
) )
@ -1014,10 +1026,10 @@ class DeviceModuleBayTable(ModuleBayTable):
class Meta(ModuleBayTable.Meta): class Meta(ModuleBayTable.Meta):
model = models.ModuleBay model = models.ModuleBay
fields = ( fields = (
'pk', 'id', 'parent', 'name', 'label', 'position', 'installed_module', 'module_status', 'module_serial', 'pk', 'id', 'parent', 'name', 'label', 'enabled', 'position', 'installed_module', 'module_status',
'module_asset_tag', 'description', 'tags', 'actions', 'module_serial', 'module_asset_tag', 'description', 'tags', 'actions',
) )
default_columns = ('pk', 'name', 'label', 'installed_module', 'module_status', 'description') default_columns = ('pk', 'name', 'label', 'enabled', 'installed_module', 'module_status', 'description')
class InventoryItemTable(DeviceComponentTable): class InventoryItemTable(DeviceComponentTable):

View File

@ -289,24 +289,30 @@ class RearPortTemplateTable(ComponentTemplateTable):
class ModuleBayTemplateTable(ComponentTemplateTable): class ModuleBayTemplateTable(ComponentTemplateTable):
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
actions=('edit', 'delete') actions=('edit', 'delete')
) )
class Meta(ComponentTemplateTable.Meta): class Meta(ComponentTemplateTable.Meta):
model = models.ModuleBayTemplate model = models.ModuleBayTemplate
fields = ('pk', 'name', 'label', 'position', 'description', 'actions') fields = ('pk', 'name', 'label', 'position', 'enabled', 'description', 'actions')
empty_text = "None" empty_text = "None"
class DeviceBayTemplateTable(ComponentTemplateTable): class DeviceBayTemplateTable(ComponentTemplateTable):
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
actions=('edit', 'delete') actions=('edit', 'delete')
) )
class Meta(ComponentTemplateTable.Meta): class Meta(ComponentTemplateTable.Meta):
model = models.DeviceBayTemplate model = models.DeviceBayTemplate
fields = ('pk', 'name', 'label', 'description', 'actions') fields = ('pk', 'name', 'label', 'enabled', 'description', 'actions')
empty_text = "None" empty_text = "None"

View File

@ -565,7 +565,7 @@ DEVICEBAY_BUTTONS = """
<a href="{% url 'dcim:devicebay_depopulate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-danger btn-sm"> <a href="{% url 'dcim:devicebay_depopulate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove device"></i> <i class="mdi mdi-server-minus" aria-hidden="true" title="Remove device"></i>
</a> </a>
{% else %} {% elif record.enabled %}
<a href="{% url 'dcim:devicebay_populate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-success btn-sm"> <a href="{% url 'dcim:devicebay_populate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-success btn-sm">
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install device"></i> <i class="mdi mdi-server-plus" aria-hidden="true" title="Install device"></i>
</a> </a>
@ -579,7 +579,7 @@ MODULEBAY_BUTTONS = """
<a href="{% url 'dcim:module_delete' pk=record.installed_module.pk %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-danger btn-sm"> <a href="{% url 'dcim:module_delete' pk=record.installed_module.pk %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove module"></i> <i class="mdi mdi-server-minus" aria-hidden="true" title="Remove module"></i>
</a> </a>
{% else %} {% elif record.enabled %}
<a href="{% url 'dcim:module_add' %}?device={{ record.device_id }}&module_bay={{ record.pk }}&manufacturer={{ object.device_type.manufacturer_id }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm"> <a href="{% url 'dcim:module_add' %}?device={{ record.device_id }}&module_bay={{ record.pk }}&manufacturer={{ object.device_type.manufacturer_id }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install module"></i> <i class="mdi mdi-server-plus" aria-hidden="true" title="Install module"></i>
</a> </a>

View File

@ -1226,7 +1226,7 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase): class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
model = ModuleBayTemplate model = ModuleBayTemplate
brief_fields = ['description', 'display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'enabled', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -1243,9 +1243,9 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
) )
module_bay_templates = ( module_bay_templates = (
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1'), ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1', enabled=True),
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2'), ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2', enabled=False),
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3'), ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3', enabled=True),
) )
ModuleBayTemplate.objects.bulk_create(module_bay_templates) ModuleBayTemplate.objects.bulk_create(module_bay_templates)
@ -1253,6 +1253,7 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
{ {
'device_type': devicetype.pk, 'device_type': devicetype.pk,
'name': 'Module Bay Template 4', 'name': 'Module Bay Template 4',
'enabled': False,
}, },
{ {
'device_type': devicetype.pk, 'device_type': devicetype.pk,
@ -1267,7 +1268,7 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
model = DeviceBayTemplate model = DeviceBayTemplate
brief_fields = ['description', 'display', 'id', 'name', 'url'] brief_fields = ['description', 'display', 'enabled', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -1284,9 +1285,9 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
) )
device_bay_templates = ( device_bay_templates = (
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 1'), DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 1', enabled=True),
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 2'), DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 2', enabled=False),
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 3'), DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 3', enabled=True),
) )
DeviceBayTemplate.objects.bulk_create(device_bay_templates) DeviceBayTemplate.objects.bulk_create(device_bay_templates)
@ -1294,6 +1295,7 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
{ {
'device_type': devicetype.pk, 'device_type': devicetype.pk,
'name': 'Device Bay Template 4', 'name': 'Device Bay Template 4',
'enabled': False,
}, },
{ {
'device_type': devicetype.pk, 'device_type': devicetype.pk,
@ -2594,7 +2596,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
class ModuleBayTest(APIViewTestCases.APIViewTestCase): class ModuleBayTest(APIViewTestCases.APIViewTestCase):
model = ModuleBay model = ModuleBay
brief_fields = ['description', 'display', 'id', 'installed_module', 'name', 'url'] brief_fields = ['_occupied', 'description', 'display', 'enabled', 'id', 'installed_module', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -2610,9 +2612,9 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
device = Device.objects.create(device_type=device_type, role=role, name='Device 1', site=site) device = Device.objects.create(device_type=device_type, role=role, name='Device 1', site=site)
module_bays = ( module_bays = (
ModuleBay(device=device, name='Device Bay 1'), ModuleBay(device=device, name='Device Bay 1', enabled=True),
ModuleBay(device=device, name='Device Bay 2'), ModuleBay(device=device, name='Device Bay 2', enabled=False),
ModuleBay(device=device, name='Device Bay 3'), ModuleBay(device=device, name='Device Bay 3', enabled=True),
) )
for module_bay in module_bays: for module_bay in module_bays:
module_bay.save() module_bay.save()
@ -2621,6 +2623,7 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
{ {
'device': device.pk, 'device': device.pk,
'name': 'Device Bay 4', 'name': 'Device Bay 4',
'enabled': False,
}, },
{ {
'device': device.pk, 'device': device.pk,
@ -2635,7 +2638,7 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
class DeviceBayTest(APIViewTestCases.APIViewTestCase): class DeviceBayTest(APIViewTestCases.APIViewTestCase):
model = DeviceBay model = DeviceBay
brief_fields = ['description', 'device', 'display', 'id', 'name', 'url'] brief_fields = ['_occupied', 'description', 'device', 'display', 'enabled', 'id', 'name', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
@ -2672,9 +2675,9 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase):
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
device_bays = ( device_bays = (
DeviceBay(device=devices[0], name='Device Bay 1'), DeviceBay(device=devices[0], name='Device Bay 1', enabled=True),
DeviceBay(device=devices[0], name='Device Bay 2'), DeviceBay(device=devices[0], name='Device Bay 2', enabled=False),
DeviceBay(device=devices[0], name='Device Bay 3'), DeviceBay(device=devices[0], name='Device Bay 3', enabled=True),
) )
DeviceBay.objects.bulk_create(device_bays) DeviceBay.objects.bulk_create(device_bays)

View File

@ -2247,13 +2247,21 @@ class ModuleBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests,
ModuleBayTemplate.objects.bulk_create( ModuleBayTemplate.objects.bulk_create(
( (
ModuleBayTemplate( ModuleBayTemplate(
device_type=device_types[0], name='Module Bay 1', description='foobar1' device_type=device_types[0], name='Module Bay 1', enabled=True, description='foobar1'
), ),
ModuleBayTemplate( ModuleBayTemplate(
device_type=device_types[1], name='Module Bay 2', description='foobar2', module_type=module_types[0] device_type=device_types[1],
name='Module Bay 2',
enabled=False,
description='foobar2',
module_type=module_types[0],
), ),
ModuleBayTemplate( ModuleBayTemplate(
device_type=device_types[2], name='Module Bay 3', description='foobar3', module_type=module_types[1] device_type=device_types[2],
name='Module Bay 3',
enabled=True,
description='foobar3',
module_type=module_types[1],
), ),
) )
) )
@ -2262,6 +2270,12 @@ class ModuleBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests,
params = {'name': ['Module Bay 1', 'Module Bay 2']} params = {'name': ['Module Bay 1', 'Module Bay 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enabled(self):
params = {'enabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'enabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_module_type(self): def test_module_type(self):
module_types = ModuleType.objects.all()[:2] module_types = ModuleType.objects.all()[:2]
params = {'module_type_id': [module_types[0].pk, module_types[1].pk]} params = {'module_type_id': [module_types[0].pk, module_types[1].pk]}
@ -2284,16 +2298,30 @@ class DeviceBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests,
) )
DeviceType.objects.bulk_create(device_types) DeviceType.objects.bulk_create(device_types)
DeviceBayTemplate.objects.bulk_create(( DeviceBayTemplate.objects.bulk_create(
DeviceBayTemplate(device_type=device_types[0], name='Device Bay 1', description='foobar1'), (
DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2', description='foobar2'), DeviceBayTemplate(
DeviceBayTemplate(device_type=device_types[2], name='Device Bay 3', description='foobar3'), device_type=device_types[0], name='Device Bay 1', enabled=True, description='foobar1'
)) ),
DeviceBayTemplate(
device_type=device_types[1], name='Device Bay 2', enabled=False, description='foobar2'
),
DeviceBayTemplate(
device_type=device_types[2], name='Device Bay 3', enabled=True, description='foobar3'
),
)
)
def test_name(self): def test_name(self):
params = {'name': ['Device Bay 1', 'Device Bay 2']} params = {'name': ['Device Bay 1', 'Device Bay 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enabled(self):
params = {'enabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'enabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class InventoryItemTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): class InventoryItemTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
queryset = InventoryItemTemplate.objects.all() queryset = InventoryItemTemplate.objects.all()
@ -5778,11 +5806,11 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
module_bays = ( module_bays = (
ModuleBay(device=devices[0], name='Module Bay 1', label='A', description='First'), ModuleBay(device=devices[0], name='Module Bay 1', label='A', enabled=True, description='First'),
ModuleBay(device=devices[1], name='Module Bay 2', label='B', description='Second'), ModuleBay(device=devices[1], name='Module Bay 2', label='B', enabled=False, description='Second'),
ModuleBay(device=devices[2], name='Module Bay 3', label='C', description='Third'), ModuleBay(device=devices[2], name='Module Bay 3', label='C', enabled=True, description='Third'),
ModuleBay(device=devices[2], name='Module Bay 4', label='D', description='Fourth'), ModuleBay(device=devices[2], name='Module Bay 4', label='D', enabled=False, description='Fourth'),
ModuleBay(device=devices[2], name='Module Bay 5', label='E', description='Fifth'), ModuleBay(device=devices[2], name='Module Bay 5', label='E', enabled=True, description='Fifth'),
) )
for module_bay in module_bays: for module_bay in module_bays:
module_bay.save() module_bay.save()
@ -5806,6 +5834,12 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
params = {'label': ['A', 'B']} params = {'label': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enabled(self):
params = {'enabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'enabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self): def test_description(self):
params = {'description': ['First', 'Second']} params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -5965,6 +5999,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
device=devices[0], device=devices[0],
name='Device Bay 1', name='Device Bay 1',
label='A', label='A',
enabled=True,
description='First', description='First',
_site=devices[0].site, _site=devices[0].site,
_location=devices[0].location, _location=devices[0].location,
@ -5974,6 +6009,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
device=devices[1], device=devices[1],
name='Device Bay 2', name='Device Bay 2',
label='B', label='B',
enabled=False,
description='Second', description='Second',
_site=devices[1].site, _site=devices[1].site,
_location=devices[1].location, _location=devices[1].location,
@ -5983,6 +6019,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
device=devices[2], device=devices[2],
name='Device Bay 3', name='Device Bay 3',
label='C', label='C',
enabled=True,
description='Third', description='Third',
_site=devices[2].site, _site=devices[2].site,
_location=devices[2].location, _location=devices[2].location,
@ -5999,6 +6036,12 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
params = {'label': ['A', 'B']} params = {'label': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enabled(self):
params = {'enabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'enabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self): def test_description(self):
params = {'description': ['First', 'Second']} params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -712,6 +712,112 @@ class DeviceTestCase(TestCase):
).full_clean() ).full_clean()
class DeviceBayTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
# Parent device type must support device bays (is_parent_device=True)
parent_device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Parent Device Type',
slug='parent-device-type',
subdevice_role=SubdeviceRoleChoices.ROLE_PARENT
)
# Child device type for installation
child_device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Child Device Type',
slug='child-device-type',
u_height=0,
subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
)
device_role = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
cls.parent_device = Device.objects.create(
name='Parent Device',
device_type=parent_device_type,
role=device_role,
site=site
)
cls.child_device = Device.objects.create(
name='Child Device',
device_type=child_device_type,
role=device_role,
site=site
)
cls.child_device_2 = Device.objects.create(
name='Child Device 2',
device_type=child_device_type,
role=device_role,
site=site
)
def test_cannot_install_device_in_disabled_bay(self):
"""
Test that a device cannot be installed into a disabled DeviceBay.
"""
# Create a disabled device bay with a device being installed
device_bay = DeviceBay(
device=self.parent_device,
name='Disabled Bay',
enabled=False,
installed_device=self.child_device
)
with self.assertRaises(ValidationError) as cm:
device_bay.clean()
self.assertIn('installed_device', cm.exception.message_dict)
self.assertIn('disabled device bay', str(cm.exception.message_dict['installed_device']))
def test_can_disable_bay_with_existing_device(self):
"""
Test that disabling a bay that already has a device installed does NOT raise an error
(same installed_device_id).
"""
# First, create an enabled device bay with a device installed
device_bay = DeviceBay.objects.create(
device=self.parent_device,
name='Bay To Disable',
enabled=True,
installed_device=self.child_device
)
# Now disable the bay while keeping the same installed device
device_bay.enabled = False
# This should NOT raise a ValidationError
device_bay.clean()
device_bay.save()
device_bay.refresh_from_db()
self.assertFalse(device_bay.enabled)
self.assertEqual(device_bay.installed_device, self.child_device)
def test_cannot_change_installed_device_in_disabled_bay(self):
"""
Test that changing the installed device in a disabled bay raises a ValidationError.
"""
# Create an enabled device bay with a device installed
device_bay = DeviceBay.objects.create(
device=self.parent_device,
name='Bay With Device',
enabled=True,
installed_device=self.child_device
)
# Disable the bay and try to change the installed device
device_bay.enabled = False
device_bay.installed_device = self.child_device_2
with self.assertRaises(ValidationError) as cm:
device_bay.clean()
self.assertIn('installed_device', cm.exception.message_dict)
class ModuleBayTestCase(TestCase): class ModuleBayTestCase(TestCase):
@classmethod @classmethod
@ -1011,6 +1117,25 @@ class ModuleBayTestCase(TestCase):
self.assertEqual(RearPort.objects.filter(module=module).count(), 1) self.assertEqual(RearPort.objects.filter(module=module).count(), 1)
self.assertEqual(PortMapping.objects.filter(front_port__module=module).count(), 0) self.assertEqual(PortMapping.objects.filter(front_port__module=module).count(), 0)
def test_cannot_install_module_in_disabled_bay(self):
"""
Test that a Module cannot be installed into a disabled ModuleBay.
"""
device = Device.objects.first()
manufacturer = Manufacturer.objects.first()
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Test Module Type Disabled')
# Create a disabled module bay
disabled_bay = ModuleBay.objects.create(device=device, name='Disabled Bay', enabled=False)
# Attempt to install a module into the disabled bay
module = Module(device=device, module_bay=disabled_bay, module_type=module_type)
with self.assertRaises(ValidationError) as cm:
module.clean()
self.assertIn('module_bay', cm.exception.message_dict)
self.assertIn('disabled module bay', str(cm.exception.message_dict['module_bay']))
class CableTestCase(TestCase): class CableTestCase(TestCase):