Browse Source

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
bctiemann 1 month ago
parent
commit
d13c98b9ea
40 changed files with 1377 additions and 33 deletions
  1. 4 0
      docs/models/dcim/module.md
  2. 4 0
      docs/models/dcim/modulebay.md
  3. 2 0
      docs/models/dcim/modulebaytemplate.md
  4. 35 0
      docs/models/dcim/modulebaytype.md
  5. 4 0
      docs/models/dcim/moduletype.md
  6. 1 0
      mkdocs.yml
  7. 9 1
      netbox/dcim/api/serializers_/device_components.py
  8. 2 1
      netbox/dcim/api/serializers_/devices.py
  9. 7 2
      netbox/dcim/api/serializers_/devicetype_components.py
  10. 25 3
      netbox/dcim/api/serializers_/devicetypes.py
  11. 1 0
      netbox/dcim/api/urls.py
  12. 14 2
      netbox/dcim/api/views.py
  13. 86 1
      netbox/dcim/filtersets.py
  14. 56 2
      netbox/dcim/forms/bulk_edit.py
  15. 16 0
      netbox/dcim/forms/bulk_import.py
  16. 40 3
      netbox/dcim/forms/filtersets.py
  17. 46 5
      netbox/dcim/forms/model_forms.py
  18. 23 0
      netbox/dcim/graphql/filters.py
  19. 3 0
      netbox/dcim/graphql/schema.py
  20. 18 1
      netbox/dcim/graphql/types.py
  21. 94 0
      netbox/dcim/migrations/0243_modulebaytype.py
  22. 12 1
      netbox/dcim/models/device_component_templates.py
  23. 33 0
      netbox/dcim/models/device_components.py
  24. 4 0
      netbox/dcim/models/devices.py
  25. 117 1
      netbox/dcim/models/modules.py
  26. 12 0
      netbox/dcim/search.py
  27. 6 2
      netbox/dcim/tables/devices.py
  28. 5 1
      netbox/dcim/tables/devicetypes.py
  29. 34 1
      netbox/dcim/tables/modules.py
  30. 6 4
      netbox/dcim/tests/query_counts.json
  31. 135 0
      netbox/dcim/tests/test_api.py
  32. 43 0
      netbox/dcim/tests/test_filtersets.py
  33. 154 0
      netbox/dcim/tests/test_models.py
  34. 4 0
      netbox/dcim/tests/test_tables.py
  35. 48 0
      netbox/dcim/tests/test_views.py
  36. 36 0
      netbox/dcim/ui/panels.py
  37. 3 0
      netbox/dcim/urls.py
  38. 187 2
      netbox/dcim/views.py
  39. 1 0
      netbox/netbox/navigation/menu.py
  40. 47 0
      netbox/templates/dcim/panels/bay_type_incompatibility.html

+ 4 - 0
docs/models/dcim/module.md

@@ -40,3 +40,7 @@ Controls whether templates module type components are automatically added when c
 ### Adopt Components
 ### 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.
 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.

+ 4 - 0
docs/models/dcim/modulebay.md

@@ -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.
 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
 ### Enabled
 
 
 Whether this module bay is enabled. Disabled module bays are not available for installation.
 Whether this module bay is enabled. Disabled module bays are not available for installation.

+ 2 - 0
docs/models/dcim/modulebaytemplate.md

@@ -1,3 +1,5 @@
 # Module Bay Templates
 # 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.
 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.

+ 35 - 0
docs/models/dcim/modulebaytype.md

@@ -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.

+ 4 - 0
docs/models/dcim/moduletype.md

@@ -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.
 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
 ### Attributes
 
 
 Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure.
 Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure.

+ 1 - 0
mkdocs.yml

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

+ 9 - 1
netbox/dcim/api/serializers_/device_components.py

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

+ 2 - 1
netbox/dcim/api/serializers_/devices.py

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

+ 7 - 2
netbox/dcim/api/serializers_/devicetype_components.py

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

+ 25 - 3
netbox/dcim/api/serializers_/devicetypes.py

@@ -4,7 +4,7 @@ from django.utils.translation import gettext as _
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from dcim.choices import *
 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.fields import AttributesField, ChoiceField
 from netbox.api.serializers import PrimaryModelSerializer
 from netbox.api.serializers import PrimaryModelSerializer
 from netbox.choices import *
 from netbox.choices import *
@@ -14,6 +14,7 @@ from .platforms import PlatformSerializer
 
 
 __all__ = (
 __all__ = (
     'DeviceTypeSerializer',
     'DeviceTypeSerializer',
+    'ModuleBayTypeSerializer',
     'ModuleTypeProfileSerializer',
     'ModuleTypeProfileSerializer',
     'ModuleTypeSerializer',
     'ModuleTypeSerializer',
 )
 )
@@ -63,6 +64,22 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
         brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
         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 ModuleTypeProfileSerializer(PrimaryModelSerializer):
 
 
     class Meta:
     class Meta:
@@ -83,6 +100,11 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
     manufacturer = ManufacturerSerializer(
     manufacturer = ManufacturerSerializer(
         nested=True
         nested=True
     )
     )
+    module_bay_types = ModuleBayTypeSerializer(
+        nested=True,
+        many=True,
+        required=False,
+    )
     weight_unit = ChoiceField(
     weight_unit = ChoiceField(
         choices=WeightUnitChoices,
         choices=WeightUnitChoices,
         allow_blank=True,
         allow_blank=True,
@@ -116,8 +138,8 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
         model = ModuleType
         model = ModuleType
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
             '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',
             'created', 'last_updated', 'module_count', 'console_port_template_count',
             'console_server_port_template_count', 'power_port_template_count', 'power_outlet_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',
             'interface_template_count', 'front_port_template_count', 'rear_port_template_count',

+ 1 - 0
netbox/dcim/api/urls.py

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

+ 14 - 2
netbox/dcim/api/views.py

@@ -286,6 +286,12 @@ class DeviceTypeViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.DeviceTypeFilterSet
     filterset_class = filtersets.DeviceTypeFilterSet
 
 
 
 
+class ModuleBayTypeViewSet(NetBoxModelViewSet):
+    queryset = ModuleBayType.objects.all()
+    serializer_class = serializers.ModuleBayTypeSerializer
+    filterset_class = filtersets.ModuleBayTypeFilterSet
+
+
 class ModuleTypeProfileViewSet(NetBoxModelViewSet):
 class ModuleTypeProfileViewSet(NetBoxModelViewSet):
     queryset = ModuleTypeProfile.objects.all()
     queryset = ModuleTypeProfile.objects.all()
     serializer_class = serializers.ModuleTypeProfileSerializer
     serializer_class = serializers.ModuleTypeProfileSerializer
@@ -427,7 +433,10 @@ class VirtualDeviceContextViewSet(NetBoxModelViewSet):
 
 
 
 
 class ModuleViewSet(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
     serializer_class = serializers.ModuleSerializer
     filterset_class = filtersets.ModuleFilterSet
     filterset_class = filtersets.ModuleFilterSet
 
 
@@ -512,7 +521,10 @@ class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
 
 
 
 
 class ModuleBayViewSet(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
     serializer_class = serializers.ModuleBaySerializer
     filterset_class = filtersets.ModuleBayFilterSet
     filterset_class = filtersets.ModuleBayFilterSet
 
 

+ 86 - 1
netbox/dcim/filtersets.py

@@ -1,7 +1,7 @@
 import django_filters
 import django_filters
 import netaddr
 import netaddr
 from django.contrib.contenttypes.models import ContentType
 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 django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
@@ -75,6 +75,7 @@ __all__ = (
     'ManufacturerFilterSet',
     'ManufacturerFilterSet',
     'ModuleBayFilterSet',
     'ModuleBayFilterSet',
     'ModuleBayTemplateFilterSet',
     'ModuleBayTemplateFilterSet',
+    'ModuleBayTypeFilterSet',
     'ModuleFilterSet',
     'ModuleFilterSet',
     'ModuleTypeFilterSet',
     'ModuleTypeFilterSet',
     'ModuleTypeProfileFilterSet',
     'ModuleTypeProfileFilterSet',
@@ -795,6 +796,51 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
         return queryset.exclude(inventoryitemtemplates__isnull=value)
         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
 @register_filterset
 class ModuleTypeProfileFilterSet(PrimaryModelFilterSet):
 class ModuleTypeProfileFilterSet(PrimaryModelFilterSet):
 
 
@@ -838,6 +884,19 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label=_('Manufacturer (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(
     console_ports = django_filters.BooleanFilter(
         method='_console_ports',
         method='_console_ports',
         label=_('Has console ports'),
         label=_('Has console ports'),
@@ -1062,6 +1121,19 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom
 
 
 @register_filterset
 @register_filterset
 class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 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:
     class Meta:
         model = ModuleBayTemplate
         model = ModuleBayTemplate
@@ -2440,6 +2512,19 @@ class ModuleBayFilterSet(ModularDeviceComponentFilterSet):
         queryset=ModuleBay.objects.all(),
         queryset=ModuleBay.objects.all(),
         label=_('Installed module (ID)'),
         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:
     class Meta:
         model = ModuleBay
         model = ModuleBay

+ 56 - 2
netbox/dcim/forms/bulk_edit.py

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

+ 16 - 0
netbox/dcim/forms/bulk_import.py

@@ -51,6 +51,7 @@ __all__ = (
     'MACAddressImportForm',
     'MACAddressImportForm',
     'ManufacturerImportForm',
     'ManufacturerImportForm',
     'ModuleBayImportForm',
     'ModuleBayImportForm',
+    'ModuleBayTypeImportForm',
     'ModuleImportForm',
     'ModuleImportForm',
     'ModuleTypeImportForm',
     'ModuleTypeImportForm',
     'ModuleTypeProfileImportForm',
     '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 ModuleTypeProfileImportForm(PrimaryModelImportForm):
 
 
     class Meta:
     class Meta:

+ 40 - 3
netbox/dcim/forms/filtersets.py

@@ -52,6 +52,7 @@ __all__ = (
     'ManufacturerFilterForm',
     'ManufacturerFilterForm',
     'ModuleBayFilterForm',
     'ModuleBayFilterForm',
     'ModuleBayTemplateFilterForm',
     'ModuleBayTemplateFilterForm',
+    'ModuleBayTypeFilterForm',
     'ModuleFilterForm',
     'ModuleFilterForm',
     'ModuleTypeFilterForm',
     'ModuleTypeFilterForm',
     'ModuleTypeProfileFilterForm',
     '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):
 class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
     model = ModuleTypeProfile
     model = ModuleTypeProfile
     fieldsets = (
     fieldsets = (
@@ -717,7 +738,7 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet(
         FieldSet(
             'profile_id', 'manufacturer_id', 'part_number', 'module_count',
             'profile_id', 'manufacturer_id', 'part_number', 'module_count',
-            'airflow', name=_('Hardware')
+            'airflow', 'module_bay_type_id', name=_('Hardware')
         ),
         ),
         FieldSet(
         FieldSet(
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
@@ -739,6 +760,12 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
         required=False,
         required=False,
         label=_('Manufacturer')
         label=_('Manufacturer')
     )
     )
+    module_bay_type_id = DynamicModelMultipleChoiceField(
+        queryset=ModuleBayType.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Module bay type')
+    )
     part_number = forms.CharField(
     part_number = forms.CharField(
         label=_('Part number'),
         label=_('Part number'),
         required=False
         required=False
@@ -1919,7 +1946,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
     model = ModuleBay
     model = ModuleBay
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         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('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',
@@ -1936,6 +1963,11 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
         required=False,
         required=False,
         widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
         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)
     tag = TagFilterField(model)
 
 
 
 
@@ -1943,7 +1975,7 @@ class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
     model = ModuleBayTemplate
     model = ModuleBayTemplate
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         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')),
         FieldSet('device_type_id', 'module_type_id', name=_('Device')),
     )
     )
     position = forms.CharField(
     position = forms.CharField(
@@ -1955,6 +1987,11 @@ class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
         required=False,
         required=False,
         widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
         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):
 class DeviceBayFilterForm(DeviceComponentFilterForm):

+ 46 - 5
netbox/dcim/forms/model_forms.py

@@ -69,6 +69,7 @@ __all__ = (
     'ManufacturerForm',
     'ManufacturerForm',
     'ModuleBayForm',
     'ModuleBayForm',
     'ModuleBayTemplateForm',
     'ModuleBayTemplateForm',
+    'ModuleBayTypeForm',
     'ModuleForm',
     'ModuleForm',
     'ModuleTypeForm',
     'ModuleTypeForm',
     'ModuleTypeProfileForm',
     '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):
 class ModuleTypeProfileForm(PrimaryModelForm):
     schema = JSONField(
     schema = JSONField(
         label=_('Schema'),
         label=_('Schema'),
@@ -572,12 +594,18 @@ class ModuleTypeForm(PrimaryModelForm):
         label=_('Manufacturer'),
         label=_('Manufacturer'),
         queryset=Manufacturer.objects.all()
         queryset=Manufacturer.objects.all()
     )
     )
+    module_bay_types = DynamicModelMultipleChoiceField(
+        label=_('Module bay types'),
+        queryset=ModuleBayType.objects.all(),
+        required=False,
+    )
 
 
     @property
     @property
     def fieldsets(self):
     def fieldsets(self):
         return [
         return [
             FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')),
             FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')),
             FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')),
             FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')),
+            FieldSet('module_bay_types', name=_('Bay Type Compatibility')),
             FieldSet('end_of_life', name=_('Lifecycle')),
             FieldSet('end_of_life', name=_('Lifecycle')),
             FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes'), html_id='profile-attributes')
             FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes'), html_id='profile-attributes')
         ]
         ]
@@ -586,7 +614,7 @@ class ModuleTypeForm(PrimaryModelForm):
         model = ModuleType
         model = ModuleType
         fields = [
         fields = [
             'profile', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit',
             '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 = {
         widgets = {
             'end_of_life': DatePicker(),
             'end_of_life': DatePicker(),
@@ -1461,20 +1489,26 @@ class RearPortTemplateForm(ModularComponentTemplateForm):
 
 
 
 
 class ModuleBayTemplateForm(ModularComponentTemplateForm):
 class ModuleBayTemplateForm(ModularComponentTemplateForm):
+    module_bay_types = DynamicModelMultipleChoiceField(
+        label=_('Module bay types'),
+        queryset=ModuleBayType.objects.all(),
+        required=False,
+    )
+
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
             TabbedGroups(
             TabbedGroups(
                 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', 'enabled', 'description',
+            'name', 'label', 'position', 'enabled', 'description', 'module_bay_types',
         ),
         ),
     )
     )
 
 
     class Meta:
     class Meta:
         model = ModuleBayTemplate
         model = ModuleBayTemplate
         fields = [
         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):
 class ModuleBayForm(ModularDeviceComponentForm):
+    module_bay_types = DynamicModelMultipleChoiceField(
+        label=_('Module bay types'),
+        queryset=ModuleBayType.objects.all(),
+        required=False,
+    )
+
     fieldsets = (
     fieldsets = (
-        FieldSet('device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'tags',),
+        FieldSet('device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'module_bay_types', 'tags'),
     )
     )
 
 
     class Meta:
     class Meta:
         model = ModuleBay
         model = ModuleBay
         fields = [
         fields = [
-            'device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'owner', 'tags',
+            'device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'module_bay_types', 'owner',
+            'tags',
         ]
         ]
 
 
 
 

+ 23 - 0
netbox/dcim/graphql/filters.py

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

+ 3 - 0
netbox/dcim/graphql/schema.py

@@ -78,6 +78,9 @@ class DCIMQuery:
     module_bay_template: ModuleBayTemplateType = strawberry_django.field()
     module_bay_template: ModuleBayTemplateType = strawberry_django.field()
     module_bay_template_list: list[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: ModuleTypeProfileType = strawberry_django.field()
     module_type_profile_list: list[ModuleTypeProfileType] = strawberry_django.field()
     module_type_profile_list: list[ModuleTypeProfileType] = strawberry_django.field()
 
 

+ 18 - 1
netbox/dcim/graphql/types.py

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

+ 94 - 0
netbox/dcim/migrations/0243_modulebaytype.py

@@ -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,
+            ),
+        ),
+    ]

+ 12 - 1
netbox/dcim/models/device_component_templates.py

@@ -744,6 +744,13 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
         verbose_name=_('enabled'),
         verbose_name=_('enabled'),
         default=True,
         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
     component_model = ModuleBay
 
 
@@ -753,7 +760,7 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         module = kwargs.get('module')
         module = kwargs.get('module')
-        return self.component_model(
+        instance = self.component_model(
             name=self.resolve_name(module, kwargs.get('device')),
             name=self.resolve_name(module, kwargs.get('device')),
             label=self.resolve_label(module, kwargs.get('device')),
             label=self.resolve_label(module, kwargs.get('device')),
             position=self.resolve_position(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,
             parent=module.module_bay if module else None,
             **kwargs
             **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
     instantiate.do_not_call_in_templates = True
 
 
     def to_yaml(self):
     def to_yaml(self):

+ 33 - 0
netbox/dcim/models/device_components.py

@@ -1351,6 +1351,13 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, LtreeModel):
         verbose_name=_('enabled'),
         verbose_name=_('enabled'),
         default=True,
         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),
     # 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
     # so ORDER BY sort_path sorts siblings naturally (Slot 0..Slot 13) — as MPTT's
     # order_insertion_by=('name',) did — rather than lexicographically.
     # 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'))
         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):
 class DeviceBay(ComponentModel, TrackingModelMixin):
     """
     """

+ 4 - 0
netbox/dcim/models/devices.py

@@ -1032,6 +1032,10 @@ class Device(
                 if cf_defaults := CustomField.objects.get_defaults_for_model(model):
                 if cf_defaults := CustomField.objects.get_defaults_for_model(model):
                     component.custom_field_data = cf_defaults
                     component.custom_field_data = cf_defaults
                 component.save()
                 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):
     def save(self, *args, **kwargs):
         is_new = not bool(self.pk)
         is_new = not bool(self.pk)

+ 117 - 1
netbox/dcim/models/modules.py

@@ -14,7 +14,7 @@ from extras.models import CustomField
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
 from netbox.models.features import ImageAttachmentsMixin
 from netbox.models.features import ImageAttachmentsMixin
 from netbox.models.mixins import WeightMixin
 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.jsonschema import validate_schema
 from utilities.string import title
 from utilities.string import title
 from utilities.tracking import TrackingModelMixin
 from utilities.tracking import TrackingModelMixin
@@ -23,11 +23,63 @@ from .device_components import *
 
 
 __all__ = (
 __all__ = (
     'Module',
     'Module',
+    'ModuleBayType',
     'ModuleType',
     'ModuleType',
     'ModuleTypeProfile',
     '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):
 class ModuleTypeProfile(PrimaryModel):
     """
     """
     A profile which defines the attributes which can be set on one or more ModuleTypes.
     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,
         null=True,
         verbose_name=_('attributes')
         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(
     module_count = CounterCacheField(
         to_model='dcim.Module',
         to_model='dcim.Module',
         to_field='module_type'
         to_field='module_type'
@@ -183,6 +242,30 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
             attrs[key] = value
             attrs[key] = value
         return dict(sorted(attrs.items()))
         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):
     def clean(self):
         super().clean()
         super().clean()
 
 
@@ -304,6 +387,22 @@ class Module(TrackingModelMixin, PrimaryModel):
     def get_status_color(self):
     def get_status_color(self):
         return ModuleStatusChoices.colors.get(self.status)
         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):
     def clean(self):
         super().clean()
         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
         # Prevent module from being installed in a disabled bay
         if hasattr(self, 'module_bay') and self.module_bay and not self.module_bay.enabled:
         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()
             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.
             # Get the template for the module type.
             for template in getattr(self.module_type, templates).all():
             for template in getattr(self.module_type, templates).all():
                 template_instance = template.instantiate(device=self.device, module=self)
                 template_instance = template.instantiate(device=self.device, module=self)
+                template_instance._source_template = template
 
 
                 if adopt_components:
                 if adopt_components:
                     existing_item = installed_components.get(template_instance.name)
                     existing_item = installed_components.get(template_instance.name)
@@ -412,6 +521,13 @@ class Module(TrackingModelMixin, PrimaryModel):
             # in ModuleBayTemplate.instantiate() (bulk_create bypasses ModuleBay.save()),
             # in ModuleBayTemplate.instantiate() (bulk_create bypasses ModuleBay.save()),
             # and the BEFORE INSERT trigger derives path/sort_path from parent_id per row.
             # and the BEFORE INSERT trigger derives path/sort_path from parent_id per row.
             component_model.objects.bulk_create(create_instances)
             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:
             for component in create_instances:
                 post_save.send(
                 post_save.send(
                     sender=component_model,
                     sender=component_model,

+ 12 - 0
netbox/dcim/search.py

@@ -209,6 +209,18 @@ class ModuleBayIndex(SearchIndex):
     display_attrs = ('device', 'label', 'position', 'description')
     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
 @register_search
 class ModuleTypeProfileIndex(SearchIndex):
 class ModuleTypeProfileIndex(SearchIndex):
     model = models.ModuleTypeProfile
     model = models.ModuleTypeProfile

+ 6 - 2
netbox/dcim/tables/devices.py

@@ -1015,12 +1015,16 @@ class ModuleBayTable(ModularDeviceComponentTable):
         template_code=MODULEBAY_STATUS,
         template_code=MODULEBAY_STATUS,
         verbose_name=_('Module Status')
         verbose_name=_('Module Status')
     )
     )
+    module_bay_types = columns.ManyToManyColumn(
+        verbose_name=_('Bay Types'),
+        linkify_item=True,
+    )
 
 
     class Meta(ModularDeviceComponentTable.Meta):
     class Meta(ModularDeviceComponentTable.Meta):
         model = models.ModuleBay
         model = models.ModuleBay
         fields = (
         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 = (
         default_columns = (
             'pk', 'name', 'device', 'enabled', 'parent', 'label', 'installed_module', 'module_status', 'description',
             'pk', 'name', 'device', 'enabled', 'parent', 'label', 'installed_module', 'module_status', 'description',

+ 5 - 1
netbox/dcim/tables/devicetypes.py

@@ -292,13 +292,17 @@ class ModuleBayTemplateTable(ComponentTemplateTable):
     enabled = columns.BooleanColumn(
     enabled = columns.BooleanColumn(
         verbose_name=_('Enabled'),
         verbose_name=_('Enabled'),
     )
     )
+    module_bay_types = columns.ManyToManyColumn(
+        verbose_name=_('Bay Types'),
+        linkify_item=True,
+    )
     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', 'enabled', 'description', 'actions')
+        fields = ('pk', 'name', 'label', 'position', 'enabled', 'module_bay_types', 'description', 'actions')
         empty_text = "None"
         empty_text = "None"
 
 
 
 

+ 34 - 1
netbox/dcim/tables/modules.py

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

+ 6 - 4
netbox/dcim/tests/query_counts.json

@@ -36,12 +36,14 @@
   "macaddress:list_objects_with_permission": 24,
   "macaddress:list_objects_with_permission": 24,
   "manufacturer:api_list_objects": 13,
   "manufacturer:api_list_objects": 13,
   "manufacturer:list_objects_with_permission": 20,
   "manufacturer:list_objects_with_permission": 20,
-  "module:api_list_objects": 18,
+  "module:api_list_objects": 20,
   "module:list_objects_with_permission": 24,
   "module:list_objects_with_permission": 24,
-  "modulebay:api_list_objects": 15,
+  "modulebay:api_list_objects": 16,
   "modulebay:list_objects_with_permission": 21,
   "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,
   "moduletype:list_objects_with_permission": 22,
   "moduletypeprofile:api_list_objects": 13,
   "moduletypeprofile:api_list_objects": 13,
   "moduletypeprofile:list_objects_with_permission": 20,
   "moduletypeprofile:list_objects_with_permission": 20,

+ 135 - 0
netbox/dcim/tests/test_api.py

@@ -1090,6 +1090,47 @@ class ModuleTypeProfileTestCase(APIViewTestCases.APIViewTestCase):
         ModuleTypeProfile.objects.bulk_create(module_type_profiles)
         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):
 class ConsolePortTemplateTestCase(APIViewTestCases.APIViewTestCase):
     model = ConsolePortTemplate
     model = ConsolePortTemplate
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     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):
     def test_replicate_components(self):
         """
         """
         Installing a module with replicate_components=True (the default) should create
         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):
 class DeviceBayTestCase(APIViewTestCases.APIViewTestCase):
     model = DeviceBay
     model = DeviceBay

+ 43 - 0
netbox/dcim/tests/test_filtersets.py

@@ -1886,6 +1886,49 @@ class ModuleTypeProfileTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
 class ConsolePortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = ConsolePortTemplate.objects.all()
     queryset = ConsolePortTemplate.objects.all()
     filterset = ConsolePortTemplateFilterSet
     filterset = ConsolePortTemplateFilterSet

+ 154 - 0
netbox/dcim/tests/test_models.py

@@ -1928,6 +1928,160 @@ class ModuleBayTestCase(TestCase):
         self.assertIn('disabled module bay', str(cm.exception.message_dict['module_bay']))
         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):
 class CableTestCase(TestCase):
 
 
     @classmethod
     @classmethod

+ 4 - 0
netbox/dcim/tests/test_tables.py

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

+ 48 - 0
netbox/dcim/tests/test_views.py

@@ -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
 # DeviceType components
 #
 #

+ 36 - 0
netbox/dcim/ui/panels.py

@@ -4,6 +4,30 @@ from django.utils.translation import gettext_lazy as _
 from netbox.ui import actions, attrs, panels
 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):
 class SitePanel(panels.ObjectAttributesPanel):
     region = attrs.NestedObjectAttr('region', linkify=True)
     region = attrs.NestedObjectAttr('region', linkify=True)
     group = attrs.NestedObjectAttr('group', 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)
     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):
 class ModuleTypeProfilePanel(panels.ObjectAttributesPanel):
     name = attrs.TextAttr('name')
     name = attrs.TextAttr('name')
     description = attrs.TextAttr('description')
     description = attrs.TextAttr('description')
@@ -187,6 +219,9 @@ class ModuleTypePanel(panels.ObjectAttributesPanel):
     airflow = attrs.ChoiceAttr('airflow')
     airflow = attrs.ChoiceAttr('airflow')
     weight = attrs.WeightAttr('weight')
     weight = attrs.WeightAttr('weight')
     end_of_life = attrs.DateTimeAttr('end_of_life', spec='date')
     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):
 class PlatformPanel(panels.NestedGroupObjectPanel):
@@ -267,6 +302,7 @@ class ModuleBayPanel(panels.ObjectAttributesPanel):
     label = attrs.TextAttr('label')
     label = attrs.TextAttr('label')
     position = attrs.TextAttr('position')
     position = attrs.TextAttr('position')
     description = attrs.TextAttr('description')
     description = attrs.TextAttr('description')
+    module_bay_types = attrs.RelatedObjectListAttr('module_bay_types', label=_('Bay Type Compatibility'), linkify=True)
 
 
 
 
 class InstalledModulePanel(panels.ObjectAttributesPanel):
 class InstalledModulePanel(panels.ObjectAttributesPanel):

+ 3 - 0
netbox/dcim/urls.py

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

+ 187 - 2
netbox/dcim/views.py

@@ -11,6 +11,7 @@ from django.urls import reverse
 from django.utils.html import escape
 from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
+from django.utils.translation import ngettext
 from django.views.generic import View
 from django.views.generic import View
 
 
 from circuits.models import Circuit, CircuitTermination
 from circuits.models import Circuit, CircuitTermination
@@ -1690,6 +1691,74 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
     table = tables.DeviceTypeTable
     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
 # Module type profiles
 #
 #
@@ -1833,6 +1902,33 @@ class ModuleTypeEditView(generic.ObjectEditView):
     queryset = ModuleType.objects.all()
     queryset = ModuleType.objects.all()
     form = forms.ModuleTypeForm
     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')
 @register_model_view(ModuleType, 'delete')
 class ModuleTypeDeleteView(generic.ObjectDeleteView):
 class ModuleTypeDeleteView(generic.ObjectDeleteView):
@@ -2000,6 +2096,35 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
     table = tables.ModuleTypeTable
     table = tables.ModuleTypeTable
     form = forms.ModuleTypeBulkEditForm
     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)
 @register_model_view(ModuleType, 'bulk_rename', path='rename', detail=False)
 class ModuleTypeBulkRenameView(generic.BulkRenameView):
 class ModuleTypeBulkRenameView(generic.BulkRenameView):
@@ -3000,12 +3125,16 @@ class ModuleListView(generic.ObjectListView):
 
 
 @register_model_view(Module)
 @register_model_view(Module)
 class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
 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(
     layout = layout.SimpleLayout(
         breadcrumbs=[
         breadcrumbs=[
             Breadcrumb('module_type', url=filtered_list_url('dcim:module_list', 'module_type_id')),
             Breadcrumb('module_type', url=filtered_list_url('dcim:module_list', 'module_type_id')),
         ],
         ],
         left_panels=[
         left_panels=[
+            panels.BayTypeIncompatibilityPanel(),
             panels.ModulePanel(),
             panels.ModulePanel(),
             TagsPanel(),
             TagsPanel(),
             CommentsPanel(),
             CommentsPanel(),
@@ -3851,12 +3980,16 @@ class ModuleBayListView(generic.ObjectListView):
 @register_model_view(ModuleBay)
 @register_model_view(ModuleBay)
 class ModuleBayView(generic.ObjectView):
 class ModuleBayView(generic.ObjectView):
     template_name = 'generic/object.html'
     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(
     layout = layout.SimpleLayout(
         breadcrumbs=[
         breadcrumbs=[
             Breadcrumb('device', url=object_view_url('dcim:device_modulebays')),
             Breadcrumb('device', url=object_view_url('dcim:device_modulebays')),
         ],
         ],
         left_panels=[
         left_panels=[
+            panels.BayTypeIncompatibilityPanel(),
             panels.ModuleBayPanel(),
             panels.ModuleBayPanel(),
             TagsPanel(),
             TagsPanel(),
         ],
         ],
@@ -3879,6 +4012,29 @@ class ModuleBayEditView(generic.ObjectEditView):
     queryset = ModuleBay.objects.all()
     queryset = ModuleBay.objects.all()
     form = forms.ModuleBayForm
     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')
 @register_model_view(ModuleBay, 'delete')
 class ModuleBayDeleteView(generic.ObjectDeleteView):
 class ModuleBayDeleteView(generic.ObjectDeleteView):
@@ -3898,6 +4054,35 @@ class ModuleBayBulkEditView(generic.BulkEditView):
     table = tables.ModuleBayTable
     table = tables.ModuleBayTable
     form = forms.ModuleBayBulkEditForm
     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)
 @register_model_view(ModuleBay, 'bulk_rename', path='rename', detail=False)
 class ModuleBayBulkRenameView(generic.BulkRenameView):
 class ModuleBayBulkRenameView(generic.BulkRenameView):

+ 1 - 0
netbox/netbox/navigation/menu.py

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

+ 47 - 0
netbox/templates/dcim/panels/bay_type_incompatibility.html

@@ -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>