Closes #19731: Add ModuleBayType to restrict which module types can be installed into a module bay (#22648)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes #19731
This commit is contained in:
bctiemann 2026-07-14 12:44:49 -04:00 committed by GitHub
parent d88b6a65dd
commit d13c98b9ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1377 additions and 33 deletions

View File

@ -40,3 +40,7 @@ Controls whether templates module type components are automatically added when c
### Adopt Components
Controls whether pre-existing components assigned to the device with the same names as components that would be created automatically will be assigned to the new module.
## Bay Type Compatibility
If the module bay has [bay types](./modulebaytype.md) assigned and the module's type also has bay types assigned, NetBox verifies that the two sets share at least one type in common. An installation that fails this check will be rejected. The `is_bay_compatible` flag is exposed in the REST API to indicate compatibility status without performing a write.

View File

@ -30,6 +30,10 @@ An alternative physical label identifying the module bay.
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.
### Bay Types
Zero or more [module bay types](./modulebaytype.md) assigned to this bay. When at least one bay type is set, only module types that share a common bay type may be installed. Leave empty to allow any module type.
### Enabled
Whether this module bay is enabled. Disabled module bays are not available for installation.

View File

@ -1,3 +1,5 @@
# Module Bay Templates
A template for a module bay that will be created on all instantiations of the parent device type. See the [module bay](./modulebay.md) documentation for more detail.
[Bay types](./modulebaytype.md) assigned to a module bay template are copied to each instantiated module bay, so constraints defined on the device type propagate automatically to all devices of that type.

View File

@ -0,0 +1,35 @@
# Module Bay Types
Module bay types are user-defined labels that can be assigned to [module bays](./modulebay.md) and [module types](./moduletype.md) to restrict which modules may be installed into which bays. This is useful for modeling chassis hardware where not every bay accepts every type of line card.
When **both** a module bay and the module type being installed have at least one bay type assigned, NetBox will check for a non-empty intersection. If the two sets share no bay types in common, the installation will be rejected as incompatible.
If either the bay or the module type has **no bay types assigned**, the constraint is not applied and any module type may be installed — this preserves backwards compatibility with existing data.
!!! tip
Bay types function as an allow-list: assign the same type to a bay and to the module types that fit it, and leave the type unset on bays or module types where no restriction is needed.
!!! note "GraphQL naming"
In the GraphQL API, the type for the `ModuleBay` *component* is named `ModuleBayType` (following the project's `<Model>Type` suffix convention), while the type for the `ModuleBayType` *model* is named `ModuleBayTypeType`. This is an unavoidable consequence of the naming convention colliding with this model's name.
## Fields
### Name
A unique human-readable name for the bay type (e.g. `LC Line Card`, `Power Supply`, `Fan Tray`).
### Slug
A URL-friendly identifier derived from the name.
### Manufacturer
An optional [manufacturer](./manufacturer.md) associated with this bay type. Useful when a vendor uses proprietary slot designations.
### Description
A brief description of the bay type.
### Comments
Free-form Markdown-supported notes.

View File

@ -83,6 +83,10 @@ The date after which this module type is no longer supported by its manufacturer
The assigned [profile](./moduletypeprofile.md) for the type of module. Profiles can be used to classify module types by function (e.g. power supply, hard disk, etc.), and they support the addition of user-configurable attributes on module types. The assignment of a module type to a profile is optional.
### Bay Types
Zero or more [module bay types](./modulebaytype.md) that this module type is compatible with. When at least one bay type is set, the module type may only be installed into bays that share a common type. Leave empty to allow installation into any bay.
### Attributes
Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure.

View File

@ -218,6 +218,7 @@ nav:
- Module: 'models/dcim/module.md'
- ModuleBay: 'models/dcim/modulebay.md'
- ModuleBayTemplate: 'models/dcim/modulebaytemplate.md'
- ModuleBayType: 'models/dcim/modulebaytype.md'
- ModuleType: 'models/dcim/moduletype.md'
- ModuleTypeProfile: 'models/dcim/moduletypeprofile.md'
- Platform: 'models/dcim/platform.md'

View File

@ -35,6 +35,7 @@ from wireless.models import WirelessLAN
from .base import ConnectedEndpointsSerializer, PortSerializer
from .cables import CabledObjectSerializer
from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer
from .devicetypes import ModuleBayTypeSerializer
from .manufacturers import ManufacturerSerializer
from .mixins import _UNSET, MACAddressShortcutMixin
from .nested import NestedInterfaceSerializer
@ -445,13 +446,20 @@ class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer):
required=False,
allow_null=True
)
module_bay_types = ModuleBayTypeSerializer(
nested=True,
many=True,
required=False,
)
_occupied = serializers.BooleanField(required=False, read_only=True)
is_module_compatible = serializers.BooleanField(read_only=True)
class Meta:
model = ModuleBay
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'position', 'enabled',
'description', 'installed_module', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'description', 'module_bay_types', 'installed_module', 'owner', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied', 'is_module_compatible',
]
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'enabled', 'description', '_occupied')

View File

@ -185,13 +185,14 @@ class ModuleSerializer(PrimaryModelSerializer):
label=_('Adopt components'),
help_text=_('Adopt already existing components')
)
is_bay_compatible = serializers.BooleanField(read_only=True)
class Meta:
model = Module
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial',
'asset_tag', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'replicate_components', 'adopt_components',
'replicate_components', 'adopt_components', 'is_bay_compatible',
]
brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')

View File

@ -22,7 +22,7 @@ from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSer
from wireless.choices import *
from .base import PortSerializer
from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer
from .devicetypes import DeviceTypeSerializer, ModuleBayTypeSerializer, ModuleTypeSerializer
from .manufacturers import ManufacturerSerializer
from .nested import NestedInterfaceTemplateSerializer
from .roles import InventoryItemRoleSerializer
@ -313,12 +313,17 @@ class ModuleBayTemplateSerializer(ComponentTemplateSerializer):
allow_null=True,
default=None
)
module_bay_types = ModuleBayTypeSerializer(
nested=True,
many=True,
required=False,
)
class Meta:
model = ModuleBayTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'position', 'enabled', 'description',
'created', 'last_updated',
'module_bay_types', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'enabled', 'description')

View File

@ -4,7 +4,7 @@ from django.utils.translation import gettext as _
from rest_framework import serializers
from dcim.choices import *
from dcim.models import DeviceType, ModuleType, ModuleTypeProfile
from dcim.models import DeviceType, ModuleBayType, ModuleType, ModuleTypeProfile
from netbox.api.fields import AttributesField, ChoiceField
from netbox.api.serializers import PrimaryModelSerializer
from netbox.choices import *
@ -14,6 +14,7 @@ from .platforms import PlatformSerializer
__all__ = (
'DeviceTypeSerializer',
'ModuleBayTypeSerializer',
'ModuleTypeProfileSerializer',
'ModuleTypeSerializer',
)
@ -63,6 +64,22 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
class ModuleBayTypeSerializer(PrimaryModelSerializer):
manufacturer = ManufacturerSerializer(
nested=True,
required=False,
allow_null=True,
)
class Meta:
model = ModuleBayType
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'manufacturer', 'color', 'description', 'owner',
'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'manufacturer', 'color', 'description')
class ModuleTypeProfileSerializer(PrimaryModelSerializer):
class Meta:
@ -83,6 +100,11 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
manufacturer = ManufacturerSerializer(
nested=True
)
module_bay_types = ModuleBayTypeSerializer(
nested=True,
many=True,
required=False,
)
weight_unit = ChoiceField(
choices=WeightUnitChoices,
allow_blank=True,
@ -116,8 +138,8 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
model = ModuleType
fields = [
'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
'weight', 'weight_unit', 'end_of_life', 'description', 'attributes', 'owner', 'comments',
'tags', 'custom_fields',
'weight', 'weight_unit', 'end_of_life', 'description', 'attributes', 'module_bay_types', 'owner',
'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'module_count', 'console_port_template_count',
'console_server_port_template_count', 'power_port_template_count', 'power_outlet_template_count',
'interface_template_count', 'front_port_template_count', 'rear_port_template_count',

View File

@ -23,6 +23,7 @@ router.register('manufacturers', views.ManufacturerViewSet)
router.register('device-types', views.DeviceTypeViewSet)
router.register('module-types', views.ModuleTypeViewSet)
router.register('module-type-profiles', views.ModuleTypeProfileViewSet)
router.register('module-bay-types', views.ModuleBayTypeViewSet)
# Device type components
router.register('console-port-templates', views.ConsolePortTemplateViewSet)

View File

@ -286,6 +286,12 @@ class DeviceTypeViewSet(NetBoxModelViewSet):
filterset_class = filtersets.DeviceTypeFilterSet
class ModuleBayTypeViewSet(NetBoxModelViewSet):
queryset = ModuleBayType.objects.all()
serializer_class = serializers.ModuleBayTypeSerializer
filterset_class = filtersets.ModuleBayTypeFilterSet
class ModuleTypeProfileViewSet(NetBoxModelViewSet):
queryset = ModuleTypeProfile.objects.all()
serializer_class = serializers.ModuleTypeProfileSerializer
@ -427,7 +433,10 @@ class VirtualDeviceContextViewSet(NetBoxModelViewSet):
class ModuleViewSet(NetBoxModelViewSet):
queryset = Module.objects.all()
queryset = Module.objects.prefetch_related(
'module_bay__module_bay_types',
'module_type__module_bay_types',
)
serializer_class = serializers.ModuleSerializer
filterset_class = filtersets.ModuleFilterSet
@ -512,7 +521,10 @@ class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
class ModuleBayViewSet(NetBoxModelViewSet):
queryset = ModuleBay.objects.all()
queryset = ModuleBay.objects.prefetch_related(
'module_bay_types',
'installed_module__module_type__module_bay_types',
)
serializer_class = serializers.ModuleBaySerializer
filterset_class = filtersets.ModuleBayFilterSet

View File

@ -1,7 +1,7 @@
import django_filters
import netaddr
from django.contrib.contenttypes.models import ContentType
from django.db.models import Func, IntegerField
from django.db.models import Func, IntegerField, Q
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
@ -75,6 +75,7 @@ __all__ = (
'ManufacturerFilterSet',
'ModuleBayFilterSet',
'ModuleBayTemplateFilterSet',
'ModuleBayTypeFilterSet',
'ModuleFilterSet',
'ModuleTypeFilterSet',
'ModuleTypeProfileFilterSet',
@ -795,6 +796,51 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
return queryset.exclude(inventoryitemtemplates__isnull=value)
@register_filterset
class ModuleBayTypeFilterSet(PrimaryModelFilterSet):
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
queryset=Manufacturer.objects.all(),
distinct=False,
label=_('Manufacturer (ID)'),
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer__slug',
queryset=Manufacturer.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Manufacturer (slug)'),
)
module_type_id = django_filters.ModelMultipleChoiceFilter(
field_name='module_types',
queryset=ModuleType.objects.all(),
label=_('Module type (ID)'),
)
module_bay_template_id = django_filters.ModelMultipleChoiceFilter(
field_name='module_bay_templates',
queryset=ModuleBayTemplate.objects.all(),
label=_('Module bay template (ID)'),
)
module_bay_id = django_filters.ModelMultipleChoiceFilter(
field_name='module_bays',
queryset=ModuleBay.objects.all(),
label=_('Module bay (ID)'),
)
class Meta:
model = ModuleBayType
fields = ('id', 'name', 'slug', 'color', 'description')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(slug__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
@register_filterset
class ModuleTypeProfileFilterSet(PrimaryModelFilterSet):
@ -838,6 +884,19 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
to_field_name='slug',
label=_('Manufacturer (slug)'),
)
module_bay_type_id = django_filters.ModelMultipleChoiceFilter(
field_name='module_bay_types',
queryset=ModuleBayType.objects.all(),
distinct=False,
label=_('Module bay type (ID)'),
)
module_bay_type = django_filters.ModelMultipleChoiceFilter(
field_name='module_bay_types__slug',
queryset=ModuleBayType.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Module bay type (slug)'),
)
console_ports = django_filters.BooleanFilter(
method='_console_ports',
label=_('Has console ports'),
@ -1062,6 +1121,19 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom
@register_filterset
class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
module_bay_type_id = django_filters.ModelMultipleChoiceFilter(
field_name='module_bay_types',
queryset=ModuleBayType.objects.all(),
distinct=False,
label=_('Module bay type (ID)'),
)
module_bay_type = django_filters.ModelMultipleChoiceFilter(
field_name='module_bay_types__slug',
queryset=ModuleBayType.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Module bay type (slug)'),
)
class Meta:
model = ModuleBayTemplate
@ -2440,6 +2512,19 @@ class ModuleBayFilterSet(ModularDeviceComponentFilterSet):
queryset=ModuleBay.objects.all(),
label=_('Installed module (ID)'),
)
module_bay_type_id = django_filters.ModelMultipleChoiceFilter(
field_name='module_bay_types',
queryset=ModuleBayType.objects.all(),
distinct=False,
label=_('Module bay type (ID)'),
)
module_bay_type = django_filters.ModelMultipleChoiceFilter(
field_name='module_bay_types__slug',
queryset=ModuleBayType.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Module bay type (slug)'),
)
class Meta:
model = ModuleBay

View File

@ -58,6 +58,7 @@ __all__ = (
'ManufacturerBulkEditForm',
'ModuleBayBulkEditForm',
'ModuleBayTemplateBulkEditForm',
'ModuleBayTypeBulkEditForm',
'ModuleBulkEditForm',
'ModuleTypeBulkEditForm',
'ModuleTypeProfileBulkEditForm',
@ -568,6 +569,24 @@ class DeviceTypeBulkEditForm(PrimaryModelBulkEditForm):
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'end_of_life', 'description', 'comments')
class ModuleBayTypeBulkEditForm(PrimaryModelBulkEditForm):
manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
required=False,
)
color = ColorField(
label=_('Color'),
required=False,
)
model = ModuleBayType
fieldsets = (
FieldSet('manufacturer', 'color', 'description', name=_('Module Bay Type')),
)
nullable_fields = ('manufacturer', 'color', 'description', 'comments')
class ModuleTypeProfileBulkEditForm(PrimaryModelBulkEditForm):
schema = JSONField(
label=_('Schema'),
@ -618,6 +637,17 @@ class ModuleTypeBulkEditForm(PrimaryModelBulkEditForm):
widget=DatePicker()
)
add_module_bay_types = DynamicModelMultipleChoiceField(
label=_('Add bay types'),
queryset=ModuleBayType.objects.all(),
required=False,
)
remove_module_bay_types = DynamicModelMultipleChoiceField(
label=_('Remove bay types'),
queryset=ModuleBayType.objects.all(),
required=False,
)
model = ModuleType
fieldsets = (
FieldSet('profile', 'manufacturer', 'part_number', 'description', name=_('Module Type')),
@ -626,6 +656,7 @@ class ModuleTypeBulkEditForm(PrimaryModelBulkEditForm):
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
name=_('Chassis')
),
FieldSet('add_module_bay_types', 'remove_module_bay_types', name=_('Bay Types')),
FieldSet('end_of_life', name=_('Lifecycle')),
)
nullable_fields = ('part_number', 'weight', 'weight_unit', 'profile', 'end_of_life', 'description', 'comments')
@ -1716,20 +1747,43 @@ class RearPortBulkEditForm(
class ModuleBayBulkEditForm(
form_from_model(ModuleBay, ['label', 'position', 'enabled', 'description']),
form_from_model(ModuleBay, ['label', 'position', 'description']),
NetBoxModelBulkEditForm
):
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=BulkEditNullBooleanSelect,
)
add_module_bay_types = DynamicModelMultipleChoiceField(
label=_('Add bay types'),
queryset=ModuleBayType.objects.all(),
required=False,
)
remove_module_bay_types = DynamicModelMultipleChoiceField(
label=_('Remove bay types'),
queryset=ModuleBayType.objects.all(),
required=False,
)
model = ModuleBay
fieldsets = (
FieldSet('label', 'position', 'enabled', 'description'),
FieldSet('add_module_bay_types', 'remove_module_bay_types', name=_('Bay Types')),
)
nullable_fields = ('label', 'position', 'description')
class DeviceBayBulkEditForm(
form_from_model(DeviceBay, ['label', 'enabled', 'description']),
form_from_model(DeviceBay, ['label', 'description']),
NetBoxModelBulkEditForm
):
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=BulkEditNullBooleanSelect,
)
model = DeviceBay
fieldsets = (
FieldSet('label', 'enabled', 'description'),

View File

@ -51,6 +51,7 @@ __all__ = (
'MACAddressImportForm',
'ManufacturerImportForm',
'ModuleBayImportForm',
'ModuleBayTypeImportForm',
'ModuleImportForm',
'ModuleTypeImportForm',
'ModuleTypeProfileImportForm',
@ -463,6 +464,21 @@ class DeviceTypeImportForm(PrimaryModelImportForm):
]
class ModuleBayTypeImportForm(PrimaryModelImportForm):
manufacturer = CSVModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
to_field_name='name',
required=False,
)
class Meta:
model = ModuleBayType
fields = [
'name', 'slug', 'manufacturer', 'color', 'description', 'owner', 'comments', 'tags',
]
class ModuleTypeProfileImportForm(PrimaryModelImportForm):
class Meta:

View File

@ -52,6 +52,7 @@ __all__ = (
'ManufacturerFilterForm',
'ModuleBayFilterForm',
'ModuleBayTemplateFilterForm',
'ModuleBayTypeFilterForm',
'ModuleFilterForm',
'ModuleTypeFilterForm',
'ModuleTypeProfileFilterForm',
@ -701,6 +702,26 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
)
class ModuleBayTypeFilterForm(PrimaryModelFilterSetForm):
model = ModuleBayType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('manufacturer_id', 'color', name=_('Module Bay Type')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label=_('Manufacturer')
)
color = ColorField(
label=_('Color'),
required=False,
)
tag = TagFilterField(model)
class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
model = ModuleTypeProfile
fieldsets = (
@ -717,7 +738,7 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'profile_id', 'manufacturer_id', 'part_number', 'module_count',
'airflow', name=_('Hardware')
'airflow', 'module_bay_type_id', name=_('Hardware')
),
FieldSet(
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
@ -739,6 +760,12 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
required=False,
label=_('Manufacturer')
)
module_bay_type_id = DynamicModelMultipleChoiceField(
queryset=ModuleBayType.objects.all(),
required=False,
null_option='None',
label=_('Module bay type')
)
part_number = forms.CharField(
label=_('Part number'),
required=False
@ -1919,7 +1946,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
model = ModuleBay
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'position', 'enabled', name=_('Attributes')),
FieldSet('name', 'label', 'position', 'enabled', 'module_bay_type_id', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
@ -1936,6 +1963,11 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
required=False,
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
)
module_bay_type_id = DynamicModelMultipleChoiceField(
queryset=ModuleBayType.objects.all(),
required=False,
label=_('Module bay type')
)
tag = TagFilterField(model)
@ -1943,7 +1975,7 @@ class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
model = ModuleBayTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'position', 'enabled', name=_('Attributes')),
FieldSet('name', 'label', 'position', 'enabled', 'module_bay_type_id', name=_('Attributes')),
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
)
position = forms.CharField(
@ -1955,6 +1987,11 @@ class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
required=False,
widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
)
module_bay_type_id = DynamicModelMultipleChoiceField(
queryset=ModuleBayType.objects.all(),
required=False,
label=_('Module bay type')
)
class DeviceBayFilterForm(DeviceComponentFilterForm):

View File

@ -69,6 +69,7 @@ __all__ = (
'ManufacturerForm',
'ModuleBayForm',
'ModuleBayTemplateForm',
'ModuleBayTypeForm',
'ModuleForm',
'ModuleTypeForm',
'ModuleTypeProfileForm',
@ -533,6 +534,27 @@ class DeviceTypeForm(PrimaryModelForm):
}
class ModuleBayTypeForm(PrimaryModelForm):
manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
required=False,
)
slug = SlugField(
slug_source='name',
)
fieldsets = (
FieldSet('name', 'slug', 'manufacturer', 'color', 'description', 'tags', name=_('Module Bay Type')),
)
class Meta:
model = ModuleBayType
fields = [
'name', 'slug', 'manufacturer', 'color', 'description', 'owner', 'comments', 'tags',
]
class ModuleTypeProfileForm(PrimaryModelForm):
schema = JSONField(
label=_('Schema'),
@ -572,12 +594,18 @@ class ModuleTypeForm(PrimaryModelForm):
label=_('Manufacturer'),
queryset=Manufacturer.objects.all()
)
module_bay_types = DynamicModelMultipleChoiceField(
label=_('Module bay types'),
queryset=ModuleBayType.objects.all(),
required=False,
)
@property
def fieldsets(self):
return [
FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')),
FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')),
FieldSet('module_bay_types', name=_('Bay Type Compatibility')),
FieldSet('end_of_life', name=_('Lifecycle')),
FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes'), html_id='profile-attributes')
]
@ -586,7 +614,7 @@ class ModuleTypeForm(PrimaryModelForm):
model = ModuleType
fields = [
'profile', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit',
'end_of_life', 'owner', 'comments', 'tags',
'module_bay_types', 'end_of_life', 'owner', 'comments', 'tags',
]
widgets = {
'end_of_life': DatePicker(),
@ -1461,20 +1489,26 @@ class RearPortTemplateForm(ModularComponentTemplateForm):
class ModuleBayTemplateForm(ModularComponentTemplateForm):
module_bay_types = DynamicModelMultipleChoiceField(
label=_('Module bay types'),
queryset=ModuleBayType.objects.all(),
required=False,
)
fieldsets = (
FieldSet(
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'position', 'enabled', 'description',
'name', 'label', 'position', 'enabled', 'description', 'module_bay_types',
),
)
class Meta:
model = ModuleBayTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'position', 'enabled', 'description',
'device_type', 'module_type', 'name', 'label', 'position', 'enabled', 'description', 'module_bay_types',
]
@ -2003,14 +2037,21 @@ class RearPortForm(ModularDeviceComponentForm):
class ModuleBayForm(ModularDeviceComponentForm):
module_bay_types = DynamicModelMultipleChoiceField(
label=_('Module bay types'),
queryset=ModuleBayType.objects.all(),
required=False,
)
fieldsets = (
FieldSet('device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'tags',),
FieldSet('device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'module_bay_types', 'tags'),
)
class Meta:
model = ModuleBay
fields = [
'device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'owner', 'tags',
'device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'module_bay_types', 'owner',
'tags',
]

View File

@ -93,6 +93,7 @@ __all__ = (
'ManufacturerFilter',
'ModuleBayFilter',
'ModuleBayTemplateFilter',
'ModuleBayTypeFilter',
'ModuleFilter',
'ModuleTypeFilter',
'ModuleTypeProfileFilter',
@ -757,12 +758,30 @@ class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
parent_id: ID | None = strawberry_django.filter_field()
position: StrFilterLookup | None = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
module_bay_types: Annotated['ModuleBayTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
module_bay_type_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
position: StrFilterLookup | None = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
module_bay_types: Annotated['ModuleBayTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
module_bay_type_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleBayType, lookups=True)
class ModuleBayTypeFilter(PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
slug: StrFilterLookup | None = strawberry_django.filter_field()
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
manufacturer_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
@ -780,6 +799,10 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
strawberry_django.filter_field()
)
profile_id: ID | None = strawberry_django.filter_field()
module_bay_types: Annotated['ModuleBayTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
module_bay_type_id: ID | None = strawberry_django.filter_field()
model: StrFilterLookup | None = strawberry_django.filter_field()
part_number: StrFilterLookup | None = strawberry_django.filter_field()
instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (

View File

@ -78,6 +78,9 @@ class DCIMQuery:
module_bay_template: ModuleBayTemplateType = strawberry_django.field()
module_bay_template_list: list[ModuleBayTemplateType] = strawberry_django.field()
module_bay_type: ModuleBayTypeType = strawberry_django.field()
module_bay_type_list: list[ModuleBayTypeType] = strawberry_django.field()
module_type_profile: ModuleTypeProfileType = strawberry_django.field()
module_type_profile_list: list[ModuleTypeProfileType] = strawberry_django.field()

View File

@ -69,6 +69,7 @@ __all__ = (
'ModularComponentType',
'ModuleBayTemplateType',
'ModuleBayType',
'ModuleBayTypeType',
'ModuleType',
'ModuleTypeProfileType',
'ModuleTypeType',
@ -611,6 +612,7 @@ class ModuleBayType(LtreeNodeMixin, ModularComponentType):
installed_module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None
children: list[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]]
module_bay_types: list[Annotated["ModuleBayTypeType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.field(prefetch_related='parent')
def parent(self) -> Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')] | None:
@ -624,7 +626,21 @@ class ModuleBayType(LtreeNodeMixin, ModularComponentType):
pagination=True
)
class ModuleBayTemplateType(ModularComponentTemplateType):
pass
module_bay_types: list[Annotated["ModuleBayTypeType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
models.ModuleBayType,
fields='__all__',
filters=ModuleBayTypeFilter,
pagination=True
)
class ModuleBayTypeType(PrimaryObjectType):
color: str
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None
module_types: list[Annotated["ModuleTypeType", strawberry.lazy('dcim.graphql.types')]]
module_bays: list[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]]
module_bay_templates: list[Annotated["ModuleBayTemplateType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@ -655,6 +671,7 @@ class ModuleTypeType(PrimaryObjectType):
module_bay_template_count: BigInt
profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
module_bay_types: list[Annotated["ModuleBayTypeType", strawberry.lazy('dcim.graphql.types')]]
frontporttemplates: list[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
consoleserverporttemplates: list[Annotated["ConsoleServerPortTemplateType", strawberry.lazy('dcim.graphql.types')]]

View File

@ -0,0 +1,94 @@
import django.db.models.deletion
import taggit.managers
from django.db import migrations, models
import netbox.models.deletion
import utilities.fields
import utilities.json
class Migration(migrations.Migration):
dependencies = [
('dcim', '0242_add_devicetype_end_of_life'),
('extras', '0141_custom_field_nulls_first'),
('users', '0016_default_ordering_indexes'),
]
operations = [
migrations.CreateModel(
name='ModuleBayType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
(
'custom_field_data',
models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('name', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100)),
('color', utilities.fields.ColorField(blank=True, max_length=6)),
(
'manufacturer',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='module_bay_types',
to='dcim.manufacturer',
),
),
(
'owner',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='users.owner',
),
),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'module bay type',
'verbose_name_plural': 'module bay types',
'ordering': ('manufacturer', 'name'),
},
bases=(netbox.models.deletion.DeleteMixin, models.Model),
),
migrations.AddField(
model_name='modulebay',
name='module_bay_types',
field=models.ManyToManyField(blank=True, related_name='module_bays', to='dcim.modulebaytype'),
),
migrations.AddField(
model_name='modulebaytemplate',
name='module_bay_types',
field=models.ManyToManyField(blank=True, related_name='module_bay_templates', to='dcim.modulebaytype'),
),
migrations.AddField(
model_name='moduletype',
name='module_bay_types',
field=models.ManyToManyField(blank=True, related_name='module_types', to='dcim.modulebaytype'),
),
migrations.AddConstraint(
model_name='modulebaytype',
constraint=models.UniqueConstraint(
fields=('manufacturer', 'name'),
name='dcim_modulebaytype_unique_manufacturer_name',
nulls_distinct=False,
),
),
migrations.AddConstraint(
model_name='modulebaytype',
constraint=models.UniqueConstraint(
fields=('manufacturer', 'slug'),
name='dcim_modulebaytype_unique_manufacturer_slug',
nulls_distinct=False,
),
),
]

View File

@ -744,6 +744,13 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
verbose_name=_('enabled'),
default=True,
)
module_bay_types = models.ManyToManyField(
to='dcim.ModuleBayType',
related_name='module_bay_templates',
blank=True,
verbose_name=_('module bay types'),
help_text=_('Types of modules that can be installed in this bay (empty = unconstrained)'),
)
component_model = ModuleBay
@ -753,7 +760,7 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs):
module = kwargs.get('module')
return self.component_model(
instance = self.component_model(
name=self.resolve_name(module, kwargs.get('device')),
label=self.resolve_label(module, kwargs.get('device')),
position=self.resolve_position(module, kwargs.get('device')),
@ -766,6 +773,10 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
parent=module.module_bay if module else None,
**kwargs
)
# Stash reference so callers (Module.save, Device._instantiate_components) can
# copy M2M fields (e.g. module_bay_types) that bulk_create cannot handle.
instance._source_template = self
return instance
instantiate.do_not_call_in_templates = True
def to_yaml(self):

View File

@ -1351,6 +1351,13 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, LtreeModel):
verbose_name=_('enabled'),
default=True,
)
module_bay_types = models.ManyToManyField(
to='dcim.ModuleBayType',
related_name='module_bays',
blank=True,
verbose_name=_('module bay types'),
help_text=_('Types of modules that can be installed in this bay (empty = unconstrained)'),
)
# sort_path inherits `name`'s natural_sort collation automatically (LtreeModelBase),
# so ORDER BY sort_path sorts siblings naturally (Slot 0..Slot 13) — as MPTT's
# order_insertion_by=('name',) did — rather than lexicographically.
@ -1419,6 +1426,32 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, LtreeModel):
"""
return bool(not self.enabled or hasattr(self, 'installed_module'))
@property
def is_module_compatible(self):
"""
Return True if the installed module (if any) is compatible with this bay's type constraints,
or if this bay has no type constraints, or if no module is installed.
Returns False when this bay and the installed module's type have non-empty, disjoint bay type sets.
"""
module = getattr(self, 'installed_module', None)
if module is None:
return True
# Use .all() so a prefetch cache is honoured; see Module.is_bay_compatible for details.
bay_types = {t.pk for t in self.module_bay_types.all()}
if not bay_types:
return True
type_types = {t.pk for t in module.module_type.module_bay_types.all()}
if type_types and not (bay_types & type_types):
return False
return True
def get_incompatible_module(self):
"""
Return the installed Module if it is incompatible with this bay's type constraints, else None.
"""
module = getattr(self, 'installed_module', None)
return module if module and not self.is_module_compatible else None
class DeviceBay(ComponentModel, TrackingModelMixin):
"""

View File

@ -1032,6 +1032,10 @@ class Device(
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
component.custom_field_data = cf_defaults
component.save()
# Copy module_bay_types from the source template (set by instantiate()).
if src := getattr(component, '_source_template', None):
if hasattr(component, 'module_bay_types'):
component.module_bay_types.set(src.module_bay_types.all())
def save(self, *args, **kwargs):
is_new = not bool(self.pk)

View File

@ -14,7 +14,7 @@ from extras.models import CustomField
from netbox.models import PrimaryModel
from netbox.models.features import ImageAttachmentsMixin
from netbox.models.mixins import WeightMixin
from utilities.fields import CounterCacheField
from utilities.fields import ColorField, CounterCacheField
from utilities.jsonschema import validate_schema
from utilities.string import title
from utilities.tracking import TrackingModelMixin
@ -23,11 +23,63 @@ from .device_components import *
__all__ = (
'Module',
'ModuleBayType',
'ModuleType',
'ModuleTypeProfile',
)
class ModuleBayType(PrimaryModel):
"""
A type classification for module bays. When bay types are assigned to both a ModuleBay and a
ModuleType, module installation is permitted only if the two sets share at least one common
member (i.e. an empty set on either side means unconstrained).
"""
name = models.CharField(
verbose_name=_('name'),
max_length=100,
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
)
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
on_delete=models.PROTECT,
related_name='module_bay_types',
blank=True,
null=True,
)
color = ColorField(
verbose_name=_('color'),
blank=True,
)
clone_fields = ('manufacturer', 'color')
class Meta:
ordering = ('manufacturer', 'name')
constraints = (
models.UniqueConstraint(
fields=('manufacturer', 'name'),
name='%(app_label)s_%(class)s_unique_manufacturer_name',
nulls_distinct=False,
),
models.UniqueConstraint(
fields=('manufacturer', 'slug'),
name='%(app_label)s_%(class)s_unique_manufacturer_slug',
nulls_distinct=False,
),
)
verbose_name = _('module bay type')
verbose_name_plural = _('module bay types')
def __str__(self):
if self.manufacturer:
return f'{self.manufacturer} {self.name}'
return self.name
class ModuleTypeProfile(PrimaryModel):
"""
A profile which defines the attributes which can be set on one or more ModuleTypes.
@ -102,6 +154,13 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
null=True,
verbose_name=_('attributes')
)
module_bay_types = models.ManyToManyField(
to='dcim.ModuleBayType',
related_name='module_types',
blank=True,
verbose_name=_('module bay types'),
help_text=_('Types of module bays this module type can be installed in (empty = unconstrained)'),
)
module_count = CounterCacheField(
to_model='dcim.Module',
to_field='module_type'
@ -183,6 +242,30 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
attrs[key] = value
return dict(sorted(attrs.items()))
def get_incompatible_modules(self):
"""
Return a queryset of Module instances of this type that are installed in bays whose
bay type sets are non-empty and share no members with this type's bay type set.
If this type has no bay type constraints, no installation can be incompatible.
"""
type_type_pks = list(self.module_bay_types.values_list('pk', flat=True))
if not type_type_pks:
return Module.objects.none()
# Bays that have any compatible type (the intersection is non-empty)
compatible_bay_pks = ModuleBay.objects.filter(
module_bay_types__pk__in=type_type_pks
).values_list('pk', flat=True)
# Bays with at least one type set (constrained bays); distinct() prevents M2M join duplicates
constrained_bay_pks = ModuleBay.objects.filter(
module_bay_types__isnull=False
).distinct().values_list('pk', flat=True)
return Module.objects.filter(
module_type=self,
module_bay__pk__in=constrained_bay_pks,
).exclude(
module_bay__pk__in=compatible_bay_pks,
)
def clean(self):
super().clean()
@ -304,6 +387,22 @@ class Module(TrackingModelMixin, PrimaryModel):
def get_status_color(self):
return ModuleStatusChoices.colors.get(self.status)
@property
def is_bay_compatible(self):
"""
Return True if this module's type is compatible with its installed bay, or if either has no type constraints.
Returns False when both the bay and module type have non-empty, disjoint bay type sets.
"""
if not (self.module_bay_id and self.module_type_id):
return True
# Use .all() so a prefetch cache (populated by the API list queryset) is honoured; .values_list()
# creates a fresh queryset that bypasses the cache and causes N+1 hits in list views.
bay_types = {t.pk for t in self.module_bay.module_bay_types.all()}
type_types = {t.pk for t in self.module_type.module_bay_types.all()}
if bay_types and type_types and not (bay_types & type_types):
return False
return True
def clean(self):
super().clean()
@ -314,6 +413,15 @@ class Module(TrackingModelMixin, PrimaryModel):
)
)
if not self.is_bay_compatible:
raise ValidationError(
_('Module type {module_type} is not compatible with module bay {module_bay}: '
'their bay type sets have no common members.').format(
module_type=self.module_type,
module_bay=self.module_bay,
)
)
# 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()
@ -382,6 +490,7 @@ class Module(TrackingModelMixin, PrimaryModel):
# Get the template for the module type.
for template in getattr(self.module_type, templates).all():
template_instance = template.instantiate(device=self.device, module=self)
template_instance._source_template = template
if adopt_components:
existing_item = installed_components.get(template_instance.name)
@ -412,6 +521,13 @@ class Module(TrackingModelMixin, PrimaryModel):
# in ModuleBayTemplate.instantiate() (bulk_create bypasses ModuleBay.save()),
# and the BEFORE INSERT trigger derives path/sort_path from parent_id per row.
component_model.objects.bulk_create(create_instances)
# Copy M2M module_bay_types from template to new ModuleBay instances.
if component_model is ModuleBay:
for component in create_instances:
if src := getattr(component, '_source_template', None):
component.module_bay_types.set(src.module_bay_types.all())
for component in create_instances:
post_save.send(
sender=component_model,

View File

@ -209,6 +209,18 @@ class ModuleBayIndex(SearchIndex):
display_attrs = ('device', 'label', 'position', 'description')
@register_search
class ModuleBayTypeIndex(SearchIndex):
model = models.ModuleBayType
fields = (
('name', 100),
('slug', 110),
('description', 500),
('comments', 5000),
)
display_attrs = ('name', 'manufacturer', 'description')
@register_search
class ModuleTypeProfileIndex(SearchIndex):
model = models.ModuleTypeProfile

View File

@ -1015,12 +1015,16 @@ class ModuleBayTable(ModularDeviceComponentTable):
template_code=MODULEBAY_STATUS,
verbose_name=_('Module Status')
)
module_bay_types = columns.ManyToManyColumn(
verbose_name=_('Bay Types'),
linkify_item=True,
)
class Meta(ModularDeviceComponentTable.Meta):
model = models.ModuleBay
fields = (
'pk', 'id', 'name', 'device', 'enabled', 'parent', 'label', 'position', 'installed_module', 'module_status',
'module_serial', 'module_asset_tag', 'description', 'tags',
'pk', 'id', 'name', 'device', 'enabled', 'parent', 'label', 'position', 'module_bay_types',
'installed_module', 'module_status', 'module_serial', 'module_asset_tag', 'description', 'tags',
)
default_columns = (
'pk', 'name', 'device', 'enabled', 'parent', 'label', 'installed_module', 'module_status', 'description',

View File

@ -292,13 +292,17 @@ class ModuleBayTemplateTable(ComponentTemplateTable):
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
module_bay_types = columns.ManyToManyColumn(
verbose_name=_('Bay Types'),
linkify_item=True,
)
actions = columns.ActionsColumn(
actions=('edit', 'delete')
)
class Meta(ComponentTemplateTable.Meta):
model = models.ModuleBayTemplate
fields = ('pk', 'name', 'label', 'position', 'enabled', 'description', 'actions')
fields = ('pk', 'name', 'label', 'position', 'enabled', 'module_bay_types', 'description', 'actions')
empty_text = "None"

View File

@ -1,18 +1,46 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from dcim.models import Module, ModuleType, ModuleTypeProfile
from dcim.models import Module, ModuleBayType, ModuleType, ModuleTypeProfile
from netbox.tables import PrimaryModelTable, columns
from .template_code import MODULETYPEPROFILE_ATTRIBUTES, WEIGHT
__all__ = (
'ModuleBayTypeTable',
'ModuleTable',
'ModuleTypeProfileTable',
'ModuleTypeTable',
)
class ModuleBayTypeTable(PrimaryModelTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
)
manufacturer = tables.Column(
verbose_name=_('Manufacturer'),
linkify=True
)
color = columns.ColorColumn(
verbose_name=_('Color'),
)
tags = columns.TagColumn(
url_name='dcim:modulebaytype_list'
)
class Meta(PrimaryModelTable.Meta):
model = ModuleBayType
fields = (
'pk', 'id', 'name', 'slug', 'manufacturer', 'color', 'description', 'comments', 'tags',
'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'manufacturer', 'color', 'description',
)
class ModuleTypeProfileTable(PrimaryModelTable):
name = tables.Column(
verbose_name=_('Name'),
@ -47,6 +75,10 @@ class ModuleTypeTable(PrimaryModelTable):
verbose_name=_('Manufacturer'),
linkify=True
)
module_bay_types = columns.ManyToManyColumn(
verbose_name=_('Bay Types'),
linkify_item=True,
)
model = tables.Column(
linkify=True,
verbose_name=_('Module Type')
@ -72,6 +104,7 @@ class ModuleTypeTable(PrimaryModelTable):
model = ModuleType
fields = (
'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'end_of_life',
'module_bay_types',
'description', 'attributes', 'module_count', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (

View File

@ -36,12 +36,14 @@
"macaddress:list_objects_with_permission": 24,
"manufacturer:api_list_objects": 13,
"manufacturer:list_objects_with_permission": 20,
"module:api_list_objects": 18,
"module:api_list_objects": 20,
"module:list_objects_with_permission": 24,
"modulebay:api_list_objects": 15,
"modulebay:api_list_objects": 16,
"modulebay:list_objects_with_permission": 21,
"modulebaytemplate:api_list_objects": 11,
"moduletype:api_list_objects": 14,
"modulebaytemplate:api_list_objects": 12,
"modulebaytype:api_list_objects": 14,
"modulebaytype:list_objects_with_permission": 21,
"moduletype:api_list_objects": 15,
"moduletype:list_objects_with_permission": 22,
"moduletypeprofile:api_list_objects": 13,
"moduletypeprofile:list_objects_with_permission": 20,

View File

@ -1090,6 +1090,47 @@ class ModuleTypeProfileTestCase(APIViewTestCases.APIViewTestCase):
ModuleTypeProfile.objects.bulk_create(module_type_profiles)
class ModuleBayTypeTestCase(APIViewTestCases.APIViewTestCase):
model = ModuleBayType
brief_fields = ['color', 'description', 'display', 'id', 'manufacturer', 'name', 'slug', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
manufacturers = (
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
)
Manufacturer.objects.bulk_create(manufacturers)
module_bay_types = (
ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 1', slug='module-bay-type-1'),
ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 2', slug='module-bay-type-2'),
ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 3', slug='module-bay-type-3'),
)
ModuleBayType.objects.bulk_create(module_bay_types)
cls.create_data = [
{
'manufacturer': manufacturers[1].pk,
'name': 'Module Bay Type 4',
'slug': 'module-bay-type-4',
},
{
'manufacturer': manufacturers[1].pk,
'name': 'Module Bay Type 5',
'slug': 'module-bay-type-5',
},
{
'manufacturer': manufacturers[1].pk,
'name': 'Module Bay Type 6',
'slug': 'module-bay-type-6',
},
]
class ConsolePortTemplateTestCase(APIViewTestCases.APIViewTestCase):
model = ConsolePortTemplate
brief_fields = ['description', 'display', 'id', 'name', 'url']
@ -2239,6 +2280,54 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase):
},
]
def test_is_bay_compatible_flag(self):
"""
is_bay_compatible should be True when no bay types are set, and False when the
bay's types and the module type's types are both set but share no common members.
"""
self.add_permissions('dcim.view_module')
manufacturer = Manufacturer.objects.get(name='Generic')
device = create_test_device('Compat Test Device')
bay_type_a = ModuleBayType.objects.create(manufacturer=manufacturer, name='Bay Type A', slug='bay-type-a')
bay_type_b = ModuleBayType.objects.create(manufacturer=manufacturer, name='Bay Type B', slug='bay-type-b')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Compat Module Type')
module_type.module_bay_types.set([bay_type_a])
compatible_bay = ModuleBay.objects.create(device=device, name='Compatible Bay')
compatible_bay.module_bay_types.set([bay_type_a])
incompatible_bay = ModuleBay.objects.create(device=device, name='Incompatible Bay')
incompatible_bay.module_bay_types.set([bay_type_b])
unconstrained_bay = ModuleBay.objects.create(device=device, name='Unconstrained Bay')
compatible_module = Module.objects.create(
device=device, module_bay=compatible_bay, module_type=module_type
)
incompatible_module = Module.objects.create(
device=device, module_bay=incompatible_bay, module_type=module_type
)
unconstrained_module = Module.objects.create(
device=device, module_bay=unconstrained_bay, module_type=module_type
)
url = reverse('dcim-api:module-detail', kwargs={'pk': compatible_module.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, 200)
self.assertTrue(response.data['is_bay_compatible'])
url = reverse('dcim-api:module-detail', kwargs={'pk': incompatible_module.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, 200)
self.assertFalse(response.data['is_bay_compatible'])
url = reverse('dcim-api:module-detail', kwargs={'pk': unconstrained_module.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, 200)
self.assertTrue(response.data['is_bay_compatible'])
def test_replicate_components(self):
"""
Installing a module with replicate_components=True (the default) should create
@ -3268,6 +3357,52 @@ class ModuleBayTestCase(APIViewTestCases.APIViewTestCase):
},
]
def test_is_module_compatible_flag(self):
"""
is_module_compatible should be True when no bay types restrict the bay, and False
when the bay's types and the installed module type's types share no common members.
"""
self.add_permissions('dcim.view_modulebay', 'dcim.view_module', 'dcim.view_moduletype')
manufacturer = Manufacturer.objects.create(
name='Compat Manufacturer', slug='compat-manufacturer'
)
device = create_test_device('Compat Bay Test Device')
bay_type_a = ModuleBayType.objects.create(
manufacturer=manufacturer, name='Compat Bay Type A', slug='compat-bay-type-a'
)
bay_type_b = ModuleBayType.objects.create(
manufacturer=manufacturer, name='Compat Bay Type B', slug='compat-bay-type-b'
)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Compat MT')
module_type.module_bay_types.set([bay_type_a])
compatible_bay = ModuleBay.objects.create(device=device, name='Compat Bay C')
compatible_bay.module_bay_types.set([bay_type_a])
Module.objects.create(device=device, module_bay=compatible_bay, module_type=module_type)
incompatible_bay = ModuleBay.objects.create(device=device, name='Compat Bay I')
incompatible_bay.module_bay_types.set([bay_type_b])
Module.objects.create(device=device, module_bay=incompatible_bay, module_type=module_type)
empty_bay = ModuleBay.objects.create(device=device, name='Compat Bay E')
url = reverse('dcim-api:modulebay-detail', kwargs={'pk': compatible_bay.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, 200)
self.assertTrue(response.data['is_module_compatible'])
url = reverse('dcim-api:modulebay-detail', kwargs={'pk': incompatible_bay.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, 200)
self.assertFalse(response.data['is_module_compatible'])
url = reverse('dcim-api:modulebay-detail', kwargs={'pk': empty_bay.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, 200)
self.assertTrue(response.data['is_module_compatible'])
class DeviceBayTestCase(APIViewTestCases.APIViewTestCase):
model = DeviceBay

View File

@ -1886,6 +1886,49 @@ class ModuleTypeProfileTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ModuleBayTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ModuleBayType.objects.all()
filterset = ModuleBayTypeFilterSet
@classmethod
def setUpTestData(cls):
manufacturers = (
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
)
Manufacturer.objects.bulk_create(manufacturers)
module_bay_types = (
ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 1', slug='module-bay-type-1'),
ModuleBayType(manufacturer=manufacturers[1], name='Module Bay Type 2', slug='module-bay-type-2'),
ModuleBayType(manufacturer=manufacturers[2], name='Module Bay Type 3', slug='module-bay-type-3'),
)
ModuleBayType.objects.bulk_create(module_bay_types)
def test_q(self):
params = {'q': 'Module Bay Type 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Module Bay Type 1', 'Module Bay Type 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_slug(self):
params = {'slug': ['module-bay-type-1', 'module-bay-type-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_manufacturer(self):
manufacturers = Manufacturer.objects.filter(name__in=['Manufacturer 1', 'Manufacturer 2'])
params = {'manufacturer': [m.slug for m in manufacturers]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_manufacturer_id(self):
manufacturers = Manufacturer.objects.filter(name__in=['Manufacturer 1', 'Manufacturer 2'])
params = {'manufacturer_id': [m.pk for m in manufacturers]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ConsolePortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsolePortTemplate.objects.all()
filterset = ConsolePortTemplateFilterSet

View File

@ -1928,6 +1928,160 @@ class ModuleBayTestCase(TestCase):
self.assertIn('disabled module bay', str(cm.exception.message_dict['module_bay']))
class ModuleBayTypeCompatibilityTestCase(TestCase):
"""Tests for bay type compatibility: Module.is_bay_compatible, ModuleType.get_incompatible_modules,
ModuleBay.is_module_compatible, and Module.clean() validation."""
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Compat Site', slug='compat-site')
manufacturer = Manufacturer.objects.create(name='Compat Mfr', slug='compat-mfr')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Compat Device Type', slug='compat-dt'
)
device_role = DeviceRole.objects.create(name='Compat Role', slug='compat-role')
cls.device = Device.objects.create(
name='Compat Device', device_type=device_type, role=device_role, site=site
)
cls.bay_type_a = ModuleBayType.objects.create(
manufacturer=manufacturer, name='Bay Type A', slug='bay-type-a'
)
cls.bay_type_b = ModuleBayType.objects.create(
manufacturer=manufacturer, name='Bay Type B', slug='bay-type-b'
)
cls.module_type_a = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type A')
cls.module_type_a.module_bay_types.set([cls.bay_type_a])
cls.module_type_b = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type B')
cls.module_type_b.module_bay_types.set([cls.bay_type_b])
cls.module_type_any = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type Any')
def _make_bay(self, name, *bay_types):
bay = ModuleBay.objects.create(device=self.device, name=name)
if bay_types:
bay.module_bay_types.set(bay_types)
return bay
def _install(self, bay, module_type):
return Module.objects.create(device=self.device, module_bay=bay, module_type=module_type)
# --- Module.clean() validation ---
def test_clean_blocks_incompatible_install(self):
"""Module.clean() raises ValidationError when bay and module type have disjoint type sets."""
bay = self._make_bay('Bay Compat 1', self.bay_type_b)
module = Module(device=self.device, module_bay=bay, module_type=self.module_type_a)
with self.assertRaises(ValidationError):
module.clean()
def test_clean_allows_compatible_install(self):
"""Module.clean() passes when bay and module type share at least one bay type."""
bay = self._make_bay('Bay Compat 2', self.bay_type_a)
module = Module(device=self.device, module_bay=bay, module_type=self.module_type_a)
module.clean() # should not raise
def test_clean_allows_unconstrained_module_type(self):
"""Module.clean() passes when the module type has no bay type constraints."""
bay = self._make_bay('Bay Compat 3', self.bay_type_a)
module = Module(device=self.device, module_bay=bay, module_type=self.module_type_any)
module.clean() # should not raise
def test_clean_allows_unconstrained_bay(self):
"""Module.clean() passes when the bay has no bay type constraints."""
bay = self._make_bay('Bay Compat 4')
module = Module(device=self.device, module_bay=bay, module_type=self.module_type_a)
module.clean() # should not raise
# --- Module.is_bay_compatible ---
def test_is_bay_compatible_false_when_disjoint(self):
"""Module.is_bay_compatible returns False when bay and module type sets are disjoint."""
bay = self._make_bay('Bay Compat 5', self.bay_type_b)
# Bypass clean() to create an incompatible installation for testing the property
module = Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type_a)
module.refresh_from_db()
self.assertFalse(module.is_bay_compatible)
def test_is_bay_compatible_true_when_overlapping(self):
"""Module.is_bay_compatible returns True when bay and module type share a bay type."""
bay = self._make_bay('Bay Compat 6', self.bay_type_a)
module = self._install(bay, self.module_type_a)
self.assertTrue(module.is_bay_compatible)
def test_is_bay_compatible_true_when_module_type_unconstrained(self):
"""Module.is_bay_compatible returns True when module type has no constraints."""
bay = self._make_bay('Bay Compat 7', self.bay_type_a)
module = self._install(bay, self.module_type_any)
self.assertTrue(module.is_bay_compatible)
def test_is_bay_compatible_true_when_bay_unconstrained(self):
"""Module.is_bay_compatible returns True when bay has no constraints."""
bay = self._make_bay('Bay Compat 8')
module = self._install(bay, self.module_type_a)
self.assertTrue(module.is_bay_compatible)
# --- ModuleType.get_incompatible_modules ---
def test_get_incompatible_modules_returns_incompatible(self):
"""ModuleType.get_incompatible_modules includes modules in bays with disjoint type sets."""
bay = self._make_bay('Bay Compat 9', self.bay_type_b)
module = Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type_a)
qs = self.module_type_a.get_incompatible_modules()
self.assertIn(module, qs)
def test_get_incompatible_modules_excludes_compatible(self):
"""ModuleType.get_incompatible_modules excludes modules in bays with matching type sets."""
bay = self._make_bay('Bay Compat 10', self.bay_type_a)
module = self._install(bay, self.module_type_a)
qs = self.module_type_a.get_incompatible_modules()
self.assertNotIn(module, qs)
def test_get_incompatible_modules_excludes_unconstrained_bay(self):
"""ModuleType.get_incompatible_modules excludes modules in unconstrained bays."""
bay = self._make_bay('Bay Compat 11')
module = self._install(bay, self.module_type_a)
qs = self.module_type_a.get_incompatible_modules()
self.assertNotIn(module, qs)
def test_get_incompatible_modules_empty_when_type_unconstrained(self):
"""ModuleType.get_incompatible_modules returns empty queryset when type has no constraints."""
bay = self._make_bay('Bay Compat 12', self.bay_type_a)
self._install(bay, self.module_type_any)
qs = self.module_type_any.get_incompatible_modules()
self.assertFalse(qs.exists())
# --- ModuleBay.is_module_compatible ---
def test_bay_is_module_compatible_false_when_disjoint(self):
"""ModuleBay.is_module_compatible returns False when bay and installed module sets are disjoint."""
bay = self._make_bay('Bay Compat 13', self.bay_type_b)
Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type_a)
bay.refresh_from_db()
self.assertFalse(bay.is_module_compatible)
def test_bay_is_module_compatible_true_when_overlapping(self):
"""ModuleBay.is_module_compatible returns True when sets overlap."""
bay = self._make_bay('Bay Compat 14', self.bay_type_a)
self._install(bay, self.module_type_a)
bay.refresh_from_db()
self.assertTrue(bay.is_module_compatible)
def test_bay_is_module_compatible_true_when_no_module(self):
"""ModuleBay.is_module_compatible returns True when nothing is installed."""
bay = self._make_bay('Bay Compat 15', self.bay_type_a)
self.assertTrue(bay.is_module_compatible)
def test_bay_is_module_compatible_true_when_bay_unconstrained(self):
"""ModuleBay.is_module_compatible returns True when bay has no constraints."""
bay = self._make_bay('Bay Compat 16')
Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type_a)
bay.refresh_from_db()
self.assertTrue(bay.is_module_compatible)
class CableTestCase(TestCase):
@classmethod

View File

@ -67,6 +67,10 @@ class ModuleTypeProfileTableTestCase(TableTestCases.StandardTableTestCase):
table = ModuleTypeProfileTable
class ModuleBayTypeTableTestCase(TableTestCases.StandardTableTestCase):
table = ModuleBayTypeTable
class ModuleTypeTableTestCase(TableTestCases.StandardTableTestCase):
table = ModuleTypeTable

View File

@ -1722,6 +1722,54 @@ class ModuleTypeProfileTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
}
class ModuleBayTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ModuleBayType
@classmethod
def setUpTestData(cls):
manufacturers = (
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
)
Manufacturer.objects.bulk_create(manufacturers)
module_bay_types = (
ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 1', slug='module-bay-type-1'),
ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 2', slug='module-bay-type-2'),
ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 3', slug='module-bay-type-3'),
)
ModuleBayType.objects.bulk_create(module_bay_types)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'manufacturer': manufacturers[1].pk,
'name': 'Module Bay Type X',
'slug': 'module-bay-type-x',
'color': 'aa1409',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"name,slug,manufacturer",
"Module Bay Type 4,module-bay-type-4,Manufacturer 1",
"Module Bay Type 5,module-bay-type-5,Manufacturer 1",
"Module Bay Type 6,module-bay-type-6,Manufacturer 1",
)
cls.csv_update_data = (
"id,description",
f"{module_bay_types[0].pk},New description",
f"{module_bay_types[1].pk},New description",
f"{module_bay_types[2].pk},New description",
)
cls.bulk_edit_data = {
'manufacturer': manufacturers[1].pk,
'description': 'New description',
}
#
# DeviceType components
#

View File

@ -4,6 +4,30 @@ from django.utils.translation import gettext_lazy as _
from netbox.ui import actions, attrs, panels
class BayTypeIncompatibilityPanel(panels.Panel):
"""
Renders a warning banner when a Module is incompatibly installed (its type's bay type set and
the bay's bay type set are both non-empty and share no common members).
Silently omitted when the installation is compatible or unconstrained.
"""
template_name = 'dcim/panels/bay_type_incompatibility.html'
def should_render(self, context):
from dcim.models import Module, ModuleBay
obj = context.get('object')
if isinstance(obj, Module):
return not obj.is_bay_compatible
if isinstance(obj, ModuleBay):
return not obj.is_module_compatible
return False
def get_context(self, context):
from dcim.models import Module
ctx = super().get_context(context)
ctx['is_module_view'] = isinstance(context.get('object'), Module)
return ctx
class SitePanel(panels.ObjectAttributesPanel):
region = attrs.NestedObjectAttr('region', linkify=True)
group = attrs.NestedObjectAttr('group', linkify=True)
@ -173,6 +197,14 @@ class ModulePanel(panels.ObjectAttributesPanel):
asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True)
class ModuleBayTypePanel(panels.ObjectAttributesPanel):
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
name = attrs.TextAttr('name')
color = attrs.ColorAttr('color')
description = attrs.TextAttr('description')
module_types = attrs.RelatedObjectListAttr('module_types', label=_('Compatible Module Types'), linkify=True)
class ModuleTypeProfilePanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
@ -187,6 +219,9 @@ class ModuleTypePanel(panels.ObjectAttributesPanel):
airflow = attrs.ChoiceAttr('airflow')
weight = attrs.WeightAttr('weight')
end_of_life = attrs.DateTimeAttr('end_of_life', spec='date')
module_bay_types = attrs.RelatedObjectListAttr(
'module_bay_types', label=_('Bay Type Compatibility'), linkify=True
)
class PlatformPanel(panels.NestedGroupObjectPanel):
@ -267,6 +302,7 @@ class ModuleBayPanel(panels.ObjectAttributesPanel):
label = attrs.TextAttr('label')
position = attrs.TextAttr('position')
description = attrs.TextAttr('description')
module_bay_types = attrs.RelatedObjectListAttr('module_bay_types', label=_('Bay Type Compatibility'), linkify=True)
class InstalledModulePanel(panels.ObjectAttributesPanel):

View File

@ -41,6 +41,9 @@ urlpatterns = [
path('device-types/', include(get_model_urls('dcim', 'devicetype', detail=False))),
path('device-types/<int:pk>/', include(get_model_urls('dcim', 'devicetype'))),
path('module-bay-types/', include(get_model_urls('dcim', 'modulebaytype', detail=False))),
path('module-bay-types/<int:pk>/', include(get_model_urls('dcim', 'modulebaytype'))),
path('module-type-profiles/', include(get_model_urls('dcim', 'moduletypeprofile', detail=False))),
path('module-type-profiles/<int:pk>/', include(get_model_urls('dcim', 'moduletypeprofile'))),

View File

@ -11,6 +11,7 @@ from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
from django.views.generic import View
from circuits.models import Circuit, CircuitTermination
@ -1690,6 +1691,74 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
table = tables.DeviceTypeTable
#
# Module bay types
#
@register_model_view(ModuleBayType, 'list', path='', detail=False)
class ModuleBayTypeListView(generic.ObjectListView):
queryset = ModuleBayType.objects.all()
filterset = filtersets.ModuleBayTypeFilterSet
filterset_form = forms.ModuleBayTypeFilterForm
table = tables.ModuleBayTypeTable
@register_model_view(ModuleBayType)
class ModuleBayTypeView(generic.ObjectView):
template_name = 'generic/object.html'
queryset = ModuleBayType.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ModuleBayTypePanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
CustomFieldsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='dcim.ModuleBay',
title=_('Module Bays'),
filters={'module_bay_type_id': lambda ctx: ctx['object'].pk},
),
],
)
@register_model_view(ModuleBayType, 'add', detail=False)
@register_model_view(ModuleBayType, 'edit')
class ModuleBayTypeEditView(generic.ObjectEditView):
queryset = ModuleBayType.objects.all()
form = forms.ModuleBayTypeForm
@register_model_view(ModuleBayType, 'delete')
class ModuleBayTypeDeleteView(generic.ObjectDeleteView):
queryset = ModuleBayType.objects.all()
@register_model_view(ModuleBayType, 'bulk_import', detail=False)
class ModuleBayTypeBulkImportView(generic.BulkImportView):
queryset = ModuleBayType.objects.all()
model_form = forms.ModuleBayTypeImportForm
@register_model_view(ModuleBayType, 'bulk_edit', path='edit', detail=False)
class ModuleBayTypeBulkEditView(generic.BulkEditView):
queryset = ModuleBayType.objects.all()
filterset = filtersets.ModuleBayTypeFilterSet
table = tables.ModuleBayTypeTable
form = forms.ModuleBayTypeBulkEditForm
@register_model_view(ModuleBayType, 'bulk_delete', path='delete', detail=False)
class ModuleBayTypeBulkDeleteView(generic.BulkDeleteView):
queryset = ModuleBayType.objects.all()
filterset = filtersets.ModuleBayTypeFilterSet
table = tables.ModuleBayTypeTable
#
# Module type profiles
#
@ -1833,6 +1902,33 @@ class ModuleTypeEditView(generic.ObjectEditView):
queryset = ModuleType.objects.all()
form = forms.ModuleTypeForm
def post(self, request, *args, **kwargs):
response = super().post(request, *args, **kwargs)
# A successful save always redirects (302) or returns an HTMX redirect header.
# Quick-add creates new objects (no pk in kwargs), so it is excluded by the pk guard below.
saved = (
getattr(response, 'status_code', None) == 302 or
(hasattr(response, 'headers') and 'HX-Location' in response.headers)
)
if saved and kwargs.get('pk'):
try:
module_type = ModuleType.objects.prefetch_related('module_bay_types').get(pk=kwargs['pk'])
count = module_type.get_incompatible_modules().count()
if count:
messages.warning(
request,
ngettext(
'%(count)d installed module of this type is now incompatible with its module bay '
'due to conflicting bay type constraints.',
'%(count)d installed modules of this type are now incompatible with their module bays '
'due to conflicting bay type constraints.',
count,
) % {'count': count}
)
except ModuleType.DoesNotExist:
pass
return response
@register_model_view(ModuleType, 'delete')
class ModuleTypeDeleteView(generic.ObjectDeleteView):
@ -2000,6 +2096,35 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
table = tables.ModuleTypeTable
form = forms.ModuleTypeBulkEditForm
def post_save_operations(self, form, obj):
super().post_save_operations(form, obj)
add = form.cleaned_data.get('add_module_bay_types')
remove = form.cleaned_data.get('remove_module_bay_types')
if add:
obj.module_bay_types.add(*add)
if remove:
obj.module_bay_types.remove(*remove)
if add or remove:
# Counts current incompatibilities, not just newly-introduced ones; may over-warn
# if pre-existing incompatibilities exist, but safe to under-warn on.
self._incompatible_count += obj.get_incompatible_modules().count()
def post(self, request, **kwargs):
self._incompatible_count = 0
response = super().post(request, **kwargs)
if self._incompatible_count and getattr(response, 'status_code', None) == 302:
messages.warning(
request,
ngettext(
'%(count)d installed module is now incompatible with its module bay '
'due to conflicting bay type constraints.',
'%(count)d installed modules are now incompatible with their module bays '
'due to conflicting bay type constraints.',
self._incompatible_count,
) % {'count': self._incompatible_count}
)
return response
@register_model_view(ModuleType, 'bulk_rename', path='rename', detail=False)
class ModuleTypeBulkRenameView(generic.BulkRenameView):
@ -3000,12 +3125,16 @@ class ModuleListView(generic.ObjectListView):
@register_model_view(Module)
class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Module.objects.all()
queryset = Module.objects.prefetch_related(
'module_bay__module_bay_types',
'module_type__module_bay_types',
)
layout = layout.SimpleLayout(
breadcrumbs=[
Breadcrumb('module_type', url=filtered_list_url('dcim:module_list', 'module_type_id')),
],
left_panels=[
panels.BayTypeIncompatibilityPanel(),
panels.ModulePanel(),
TagsPanel(),
CommentsPanel(),
@ -3851,12 +3980,16 @@ class ModuleBayListView(generic.ObjectListView):
@register_model_view(ModuleBay)
class ModuleBayView(generic.ObjectView):
template_name = 'generic/object.html'
queryset = ModuleBay.objects.all()
queryset = ModuleBay.objects.prefetch_related(
'module_bay_types',
'installed_module__module_type__module_bay_types',
)
layout = layout.SimpleLayout(
breadcrumbs=[
Breadcrumb('device', url=object_view_url('dcim:device_modulebays')),
],
left_panels=[
panels.BayTypeIncompatibilityPanel(),
panels.ModuleBayPanel(),
TagsPanel(),
],
@ -3879,6 +4012,29 @@ class ModuleBayEditView(generic.ObjectEditView):
queryset = ModuleBay.objects.all()
form = forms.ModuleBayForm
def post(self, request, *args, **kwargs):
response = super().post(request, *args, **kwargs)
# A successful save always redirects (302) or returns an HTMX redirect header.
# Quick-add creates new objects (no pk in kwargs), so it is excluded by the pk guard below.
saved = (
getattr(response, 'status_code', None) == 302 or
(hasattr(response, 'headers') and 'HX-Location' in response.headers)
)
if saved and kwargs.get('pk'):
try:
bay = ModuleBay.objects.prefetch_related(
'module_bay_types', 'installed_module__module_type__module_bay_types'
).get(pk=kwargs['pk'])
if not bay.is_module_compatible:
messages.warning(
request,
_('The module currently installed in this bay is incompatible with the new bay type '
'constraints. Consider removing or replacing it.')
)
except ModuleBay.DoesNotExist:
pass
return response
@register_model_view(ModuleBay, 'delete')
class ModuleBayDeleteView(generic.ObjectDeleteView):
@ -3898,6 +4054,35 @@ class ModuleBayBulkEditView(generic.BulkEditView):
table = tables.ModuleBayTable
form = forms.ModuleBayBulkEditForm
def post_save_operations(self, form, obj):
super().post_save_operations(form, obj)
add = form.cleaned_data.get('add_module_bay_types')
remove = form.cleaned_data.get('remove_module_bay_types')
if add:
obj.module_bay_types.add(*add)
if remove:
obj.module_bay_types.remove(*remove)
if add or remove:
# Counts current incompatibilities, not just newly-introduced ones; may over-warn
# if pre-existing incompatibilities exist, but safe to under-warn on.
self._incompatible_count += int(not obj.is_module_compatible)
def post(self, request, **kwargs):
self._incompatible_count = 0
response = super().post(request, **kwargs)
if self._incompatible_count and getattr(response, 'status_code', None) == 302:
messages.warning(
request,
ngettext(
'%(count)d module bay now has an incompatible module installed '
'due to conflicting bay type constraints.',
'%(count)d module bays now have incompatible modules installed '
'due to conflicting bay type constraints.',
self._incompatible_count,
) % {'count': self._incompatible_count}
)
return response
@register_model_view(ModuleBay, 'bulk_rename', path='rename', detail=False)
class ModuleBayBulkRenameView(generic.BulkRenameView):

View File

@ -95,6 +95,7 @@ DEVICES_MENU = Menu(
get_model_item('dcim', 'devicetype', _('Device Types')),
get_model_item('dcim', 'moduletype', _('Module Types')),
get_model_item('dcim', 'moduletypeprofile', _('Module Type Profiles')),
get_model_item('dcim', 'modulebaytype', _('Module Bay Types')),
get_model_item('dcim', 'manufacturer', _('Manufacturers')),
),
),

View File

@ -0,0 +1,47 @@
{% load i18n %}
<div class="card border-warning">
<h2 class="card-header bg-warning-subtle text-warning-emphasis">
<i class="mdi mdi-alert"></i> {% trans "Bay Type Incompatibility" %}
</h2>
<div class="card-body">
{% if is_module_view %}
{# Module detail view #}
<p class="mb-1">
{% blocktrans with module_type=object.module_type module_bay=object.module_bay %}
Module type <strong>{{ module_type }}</strong> is not compatible with module bay
<strong>{{ module_bay }}</strong>: their bay type sets share no common members.
{% endblocktrans %}
</p>
<p class="mb-0 text-muted small">
{% trans "Bay types on bay:" %}
{% for bt in object.module_bay.module_bay_types.all %}
<a href="{{ bt.get_absolute_url }}">{{ bt }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
&nbsp;&mdash;&nbsp;
{% trans "Bay types on module type:" %}
{% for bt in object.module_type.module_bay_types.all %}
<a href="{{ bt.get_absolute_url }}">{{ bt }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
</p>
{% else %}
{# ModuleBay detail view #}
<p class="mb-1">
{% blocktrans with module_type=object.installed_module.module_type bay=object %}
Module type <strong>{{ module_type }}</strong> is not compatible with this bay
(<strong>{{ bay }}</strong>): their bay type sets share no common members.
{% endblocktrans %}
</p>
<p class="mb-0 text-muted small">
{% trans "Bay types on bay:" %}
{% for bt in object.module_bay_types.all %}
<a href="{{ bt.get_absolute_url }}">{{ bt }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
&nbsp;&mdash;&nbsp;
{% trans "Bay types on module type:" %}
{% for bt in object.installed_module.module_type.module_bay_types.all %}
<a href="{{ bt.get_absolute_url }}">{{ bt }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
</p>
{% endif %}
</div>
</div>