diff --git a/docs/models/dcim/module.md b/docs/models/dcim/module.md index 060c2b094..c526f46e5 100644 --- a/docs/models/dcim/module.md +++ b/docs/models/dcim/module.md @@ -40,3 +40,7 @@ Controls whether templates module type components are automatically added when c ### Adopt Components Controls whether pre-existing components assigned to the device with the same names as components that would be created automatically will be assigned to the new module. + +## Bay Type Compatibility + +If the module bay has [bay types](./modulebaytype.md) assigned and the module's type also has bay types assigned, NetBox verifies that the two sets share at least one type in common. An installation that fails this check will be rejected. The `is_bay_compatible` flag is exposed in the REST API to indicate compatibility status without performing a write. diff --git a/docs/models/dcim/modulebay.md b/docs/models/dcim/modulebay.md index d42828b83..4df243ca1 100644 --- a/docs/models/dcim/modulebay.md +++ b/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. +### Bay Types + +Zero or more [module bay types](./modulebaytype.md) assigned to this bay. When at least one bay type is set, only module types that share a common bay type may be installed. Leave empty to allow any module type. + ### Enabled Whether this module bay is enabled. Disabled module bays are not available for installation. diff --git a/docs/models/dcim/modulebaytemplate.md b/docs/models/dcim/modulebaytemplate.md index 3d5845d2e..93b5f8b21 100644 --- a/docs/models/dcim/modulebaytemplate.md +++ b/docs/models/dcim/modulebaytemplate.md @@ -1,3 +1,5 @@ # Module Bay Templates A template for a module bay that will be created on all instantiations of the parent device type. See the [module bay](./modulebay.md) documentation for more detail. + +[Bay types](./modulebaytype.md) assigned to a module bay template are copied to each instantiated module bay, so constraints defined on the device type propagate automatically to all devices of that type. diff --git a/docs/models/dcim/modulebaytype.md b/docs/models/dcim/modulebaytype.md new file mode 100644 index 000000000..7652ebecb --- /dev/null +++ b/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 `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. \ No newline at end of file diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md index 8c9604ecc..51b0afbbb 100644 --- a/docs/models/dcim/moduletype.md +++ b/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. +### Bay Types + +Zero or more [module bay types](./modulebaytype.md) that this module type is compatible with. When at least one bay type is set, the module type may only be installed into bays that share a common type. Leave empty to allow installation into any bay. + ### Attributes Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure. diff --git a/mkdocs.yml b/mkdocs.yml index 2b496fabd..8866a2721 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -218,6 +218,7 @@ nav: - Module: 'models/dcim/module.md' - ModuleBay: 'models/dcim/modulebay.md' - ModuleBayTemplate: 'models/dcim/modulebaytemplate.md' + - ModuleBayType: 'models/dcim/modulebaytype.md' - ModuleType: 'models/dcim/moduletype.md' - ModuleTypeProfile: 'models/dcim/moduletypeprofile.md' - Platform: 'models/dcim/platform.md' diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index f1b592e98..b565c8bd0 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -35,6 +35,7 @@ from wireless.models import WirelessLAN from .base import ConnectedEndpointsSerializer, PortSerializer from .cables import CabledObjectSerializer from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer +from .devicetypes import ModuleBayTypeSerializer from .manufacturers import ManufacturerSerializer from .mixins import _UNSET, MACAddressShortcutMixin from .nested import NestedInterfaceSerializer @@ -445,13 +446,20 @@ class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer): required=False, allow_null=True ) + module_bay_types = ModuleBayTypeSerializer( + nested=True, + many=True, + required=False, + ) _occupied = serializers.BooleanField(required=False, read_only=True) + is_module_compatible = serializers.BooleanField(read_only=True) class Meta: model = ModuleBay fields = [ 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'position', 'enabled', - 'description', 'installed_module', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'description', 'module_bay_types', 'installed_module', 'owner', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', 'is_module_compatible', ] brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'enabled', 'description', '_occupied') diff --git a/netbox/dcim/api/serializers_/devices.py b/netbox/dcim/api/serializers_/devices.py index f5655c649..e8c7777c6 100644 --- a/netbox/dcim/api/serializers_/devices.py +++ b/netbox/dcim/api/serializers_/devices.py @@ -185,13 +185,14 @@ class ModuleSerializer(PrimaryModelSerializer): label=_('Adopt components'), help_text=_('Adopt already existing components') ) + is_bay_compatible = serializers.BooleanField(read_only=True) class Meta: model = Module fields = [ 'id', 'url', 'display_url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - 'replicate_components', 'adopt_components', + 'replicate_components', 'adopt_components', 'is_bay_compatible', ] brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description') diff --git a/netbox/dcim/api/serializers_/devicetype_components.py b/netbox/dcim/api/serializers_/devicetype_components.py index 0863d8c17..ee9c161a7 100644 --- a/netbox/dcim/api/serializers_/devicetype_components.py +++ b/netbox/dcim/api/serializers_/devicetype_components.py @@ -22,7 +22,7 @@ from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSer from wireless.choices import * from .base import PortSerializer -from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer +from .devicetypes import DeviceTypeSerializer, ModuleBayTypeSerializer, ModuleTypeSerializer from .manufacturers import ManufacturerSerializer from .nested import NestedInterfaceTemplateSerializer from .roles import InventoryItemRoleSerializer @@ -313,12 +313,17 @@ class ModuleBayTemplateSerializer(ComponentTemplateSerializer): allow_null=True, default=None ) + module_bay_types = ModuleBayTypeSerializer( + nested=True, + many=True, + required=False, + ) class Meta: model = ModuleBayTemplate fields = [ 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'position', 'enabled', 'description', - 'created', 'last_updated', + 'module_bay_types', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'enabled', 'description') diff --git a/netbox/dcim/api/serializers_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py index fb2e13806..8d5eea816 100644 --- a/netbox/dcim/api/serializers_/devicetypes.py +++ b/netbox/dcim/api/serializers_/devicetypes.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext as _ from rest_framework import serializers from dcim.choices import * -from dcim.models import DeviceType, ModuleType, ModuleTypeProfile +from dcim.models import DeviceType, ModuleBayType, ModuleType, ModuleTypeProfile from netbox.api.fields import AttributesField, ChoiceField from netbox.api.serializers import PrimaryModelSerializer from netbox.choices import * @@ -14,6 +14,7 @@ from .platforms import PlatformSerializer __all__ = ( 'DeviceTypeSerializer', + 'ModuleBayTypeSerializer', 'ModuleTypeProfileSerializer', 'ModuleTypeSerializer', ) @@ -63,6 +64,22 @@ class DeviceTypeSerializer(PrimaryModelSerializer): brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count') +class ModuleBayTypeSerializer(PrimaryModelSerializer): + manufacturer = ManufacturerSerializer( + nested=True, + required=False, + allow_null=True, + ) + + class Meta: + model = ModuleBayType + fields = [ + 'id', 'url', 'display_url', 'display', 'name', 'slug', 'manufacturer', 'color', 'description', 'owner', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'manufacturer', 'color', 'description') + + class ModuleTypeProfileSerializer(PrimaryModelSerializer): class Meta: @@ -83,6 +100,11 @@ class ModuleTypeSerializer(PrimaryModelSerializer): manufacturer = ManufacturerSerializer( nested=True ) + module_bay_types = ModuleBayTypeSerializer( + nested=True, + many=True, + required=False, + ) weight_unit = ChoiceField( choices=WeightUnitChoices, allow_blank=True, @@ -116,8 +138,8 @@ class ModuleTypeSerializer(PrimaryModelSerializer): model = ModuleType fields = [ 'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow', - 'weight', 'weight_unit', 'end_of_life', 'description', 'attributes', 'owner', 'comments', - 'tags', 'custom_fields', + 'weight', 'weight_unit', 'end_of_life', 'description', 'attributes', 'module_bay_types', 'owner', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'module_count', 'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count', 'power_outlet_template_count', 'interface_template_count', 'front_port_template_count', 'rear_port_template_count', diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 852a871bb..664e7bb38 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -23,6 +23,7 @@ router.register('manufacturers', views.ManufacturerViewSet) router.register('device-types', views.DeviceTypeViewSet) router.register('module-types', views.ModuleTypeViewSet) router.register('module-type-profiles', views.ModuleTypeProfileViewSet) +router.register('module-bay-types', views.ModuleBayTypeViewSet) # Device type components router.register('console-port-templates', views.ConsolePortTemplateViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index ea2650c44..a7a28a196 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -286,6 +286,12 @@ class DeviceTypeViewSet(NetBoxModelViewSet): filterset_class = filtersets.DeviceTypeFilterSet +class ModuleBayTypeViewSet(NetBoxModelViewSet): + queryset = ModuleBayType.objects.all() + serializer_class = serializers.ModuleBayTypeSerializer + filterset_class = filtersets.ModuleBayTypeFilterSet + + class ModuleTypeProfileViewSet(NetBoxModelViewSet): queryset = ModuleTypeProfile.objects.all() serializer_class = serializers.ModuleTypeProfileSerializer @@ -427,7 +433,10 @@ class VirtualDeviceContextViewSet(NetBoxModelViewSet): class ModuleViewSet(NetBoxModelViewSet): - queryset = Module.objects.all() + queryset = Module.objects.prefetch_related( + 'module_bay__module_bay_types', + 'module_type__module_bay_types', + ) serializer_class = serializers.ModuleSerializer filterset_class = filtersets.ModuleFilterSet @@ -512,7 +521,10 @@ class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): class ModuleBayViewSet(NetBoxModelViewSet): - queryset = ModuleBay.objects.all() + queryset = ModuleBay.objects.prefetch_related( + 'module_bay_types', + 'installed_module__module_type__module_bay_types', + ) serializer_class = serializers.ModuleBaySerializer filterset_class = filtersets.ModuleBayFilterSet diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 5a6b277de..6acddc4ae 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1,7 +1,7 @@ import django_filters import netaddr from django.contrib.contenttypes.models import ContentType -from django.db.models import Func, IntegerField +from django.db.models import Func, IntegerField, Q from django.utils.translation import gettext as _ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field @@ -75,6 +75,7 @@ __all__ = ( 'ManufacturerFilterSet', 'ModuleBayFilterSet', 'ModuleBayTemplateFilterSet', + 'ModuleBayTypeFilterSet', 'ModuleFilterSet', 'ModuleTypeFilterSet', 'ModuleTypeProfileFilterSet', @@ -795,6 +796,51 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): return queryset.exclude(inventoryitemtemplates__isnull=value) +@register_filterset +class ModuleBayTypeFilterSet(PrimaryModelFilterSet): + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + queryset=Manufacturer.objects.all(), + distinct=False, + label=_('Manufacturer (ID)'), + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + field_name='manufacturer__slug', + queryset=Manufacturer.objects.all(), + distinct=False, + to_field_name='slug', + label=_('Manufacturer (slug)'), + ) + module_type_id = django_filters.ModelMultipleChoiceFilter( + field_name='module_types', + queryset=ModuleType.objects.all(), + label=_('Module type (ID)'), + ) + module_bay_template_id = django_filters.ModelMultipleChoiceFilter( + field_name='module_bay_templates', + queryset=ModuleBayTemplate.objects.all(), + label=_('Module bay template (ID)'), + ) + module_bay_id = django_filters.ModelMultipleChoiceFilter( + field_name='module_bays', + queryset=ModuleBay.objects.all(), + label=_('Module bay (ID)'), + ) + + class Meta: + model = ModuleBayType + fields = ('id', 'name', 'slug', 'color', 'description') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(slug__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ) + + @register_filterset class ModuleTypeProfileFilterSet(PrimaryModelFilterSet): @@ -838,6 +884,19 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet): to_field_name='slug', label=_('Manufacturer (slug)'), ) + module_bay_type_id = django_filters.ModelMultipleChoiceFilter( + field_name='module_bay_types', + queryset=ModuleBayType.objects.all(), + distinct=False, + label=_('Module bay type (ID)'), + ) + module_bay_type = django_filters.ModelMultipleChoiceFilter( + field_name='module_bay_types__slug', + queryset=ModuleBayType.objects.all(), + distinct=False, + to_field_name='slug', + label=_('Module bay type (slug)'), + ) console_ports = django_filters.BooleanFilter( method='_console_ports', label=_('Has console ports'), @@ -1062,6 +1121,19 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom @register_filterset class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): + module_bay_type_id = django_filters.ModelMultipleChoiceFilter( + field_name='module_bay_types', + queryset=ModuleBayType.objects.all(), + distinct=False, + label=_('Module bay type (ID)'), + ) + module_bay_type = django_filters.ModelMultipleChoiceFilter( + field_name='module_bay_types__slug', + queryset=ModuleBayType.objects.all(), + distinct=False, + to_field_name='slug', + label=_('Module bay type (slug)'), + ) class Meta: model = ModuleBayTemplate @@ -2440,6 +2512,19 @@ class ModuleBayFilterSet(ModularDeviceComponentFilterSet): queryset=ModuleBay.objects.all(), label=_('Installed module (ID)'), ) + module_bay_type_id = django_filters.ModelMultipleChoiceFilter( + field_name='module_bay_types', + queryset=ModuleBayType.objects.all(), + distinct=False, + label=_('Module bay type (ID)'), + ) + module_bay_type = django_filters.ModelMultipleChoiceFilter( + field_name='module_bay_types__slug', + queryset=ModuleBayType.objects.all(), + distinct=False, + to_field_name='slug', + label=_('Module bay type (slug)'), + ) class Meta: model = ModuleBay diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 411b0a780..8613262f6 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -58,6 +58,7 @@ __all__ = ( 'ManufacturerBulkEditForm', 'ModuleBayBulkEditForm', 'ModuleBayTemplateBulkEditForm', + 'ModuleBayTypeBulkEditForm', 'ModuleBulkEditForm', 'ModuleTypeBulkEditForm', 'ModuleTypeProfileBulkEditForm', @@ -568,6 +569,24 @@ class DeviceTypeBulkEditForm(PrimaryModelBulkEditForm): nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'end_of_life', 'description', 'comments') +class ModuleBayTypeBulkEditForm(PrimaryModelBulkEditForm): + manufacturer = DynamicModelChoiceField( + label=_('Manufacturer'), + queryset=Manufacturer.objects.all(), + required=False, + ) + color = ColorField( + label=_('Color'), + required=False, + ) + + model = ModuleBayType + fieldsets = ( + FieldSet('manufacturer', 'color', 'description', name=_('Module Bay Type')), + ) + nullable_fields = ('manufacturer', 'color', 'description', 'comments') + + class ModuleTypeProfileBulkEditForm(PrimaryModelBulkEditForm): schema = JSONField( label=_('Schema'), @@ -618,6 +637,17 @@ class ModuleTypeBulkEditForm(PrimaryModelBulkEditForm): widget=DatePicker() ) + add_module_bay_types = DynamicModelMultipleChoiceField( + label=_('Add bay types'), + queryset=ModuleBayType.objects.all(), + required=False, + ) + remove_module_bay_types = DynamicModelMultipleChoiceField( + label=_('Remove bay types'), + queryset=ModuleBayType.objects.all(), + required=False, + ) + model = ModuleType fieldsets = ( FieldSet('profile', 'manufacturer', 'part_number', 'description', name=_('Module Type')), @@ -626,6 +656,7 @@ class ModuleTypeBulkEditForm(PrimaryModelBulkEditForm): InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), name=_('Chassis') ), + FieldSet('add_module_bay_types', 'remove_module_bay_types', name=_('Bay Types')), FieldSet('end_of_life', name=_('Lifecycle')), ) nullable_fields = ('part_number', 'weight', 'weight_unit', 'profile', 'end_of_life', 'description', 'comments') @@ -1716,20 +1747,43 @@ class RearPortBulkEditForm( class ModuleBayBulkEditForm( - form_from_model(ModuleBay, ['label', 'position', 'enabled', 'description']), + form_from_model(ModuleBay, ['label', 'position', 'description']), NetBoxModelBulkEditForm ): + enabled = forms.NullBooleanField( + label=_('Enabled'), + required=False, + widget=BulkEditNullBooleanSelect, + ) + add_module_bay_types = DynamicModelMultipleChoiceField( + label=_('Add bay types'), + queryset=ModuleBayType.objects.all(), + required=False, + ) + remove_module_bay_types = DynamicModelMultipleChoiceField( + label=_('Remove bay types'), + queryset=ModuleBayType.objects.all(), + required=False, + ) + model = ModuleBay fieldsets = ( FieldSet('label', 'position', 'enabled', 'description'), + FieldSet('add_module_bay_types', 'remove_module_bay_types', name=_('Bay Types')), ) nullable_fields = ('label', 'position', 'description') class DeviceBayBulkEditForm( - form_from_model(DeviceBay, ['label', 'enabled', 'description']), + form_from_model(DeviceBay, ['label', 'description']), NetBoxModelBulkEditForm ): + enabled = forms.NullBooleanField( + label=_('Enabled'), + required=False, + widget=BulkEditNullBooleanSelect, + ) + model = DeviceBay fieldsets = ( FieldSet('label', 'enabled', 'description'), diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 6e97c7efe..475b5aa51 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -51,6 +51,7 @@ __all__ = ( 'MACAddressImportForm', 'ManufacturerImportForm', 'ModuleBayImportForm', + 'ModuleBayTypeImportForm', 'ModuleImportForm', 'ModuleTypeImportForm', 'ModuleTypeProfileImportForm', @@ -463,6 +464,21 @@ class DeviceTypeImportForm(PrimaryModelImportForm): ] +class ModuleBayTypeImportForm(PrimaryModelImportForm): + manufacturer = CSVModelChoiceField( + label=_('Manufacturer'), + queryset=Manufacturer.objects.all(), + to_field_name='name', + required=False, + ) + + class Meta: + model = ModuleBayType + fields = [ + 'name', 'slug', 'manufacturer', 'color', 'description', 'owner', 'comments', 'tags', + ] + + class ModuleTypeProfileImportForm(PrimaryModelImportForm): class Meta: diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 7d2efb1dc..874a0436d 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -52,6 +52,7 @@ __all__ = ( 'ManufacturerFilterForm', 'ModuleBayFilterForm', 'ModuleBayTemplateFilterForm', + 'ModuleBayTypeFilterForm', 'ModuleFilterForm', 'ModuleTypeFilterForm', 'ModuleTypeProfileFilterForm', @@ -701,6 +702,26 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm): ) +class ModuleBayTypeFilterForm(PrimaryModelFilterSetForm): + model = ModuleBayType + fieldsets = ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet('manufacturer_id', 'color', name=_('Module Bay Type')), + FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), + ) + selector_fields = ('filter_id', 'q', 'manufacturer_id') + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer') + ) + color = ColorField( + label=_('Color'), + required=False, + ) + tag = TagFilterField(model) + + class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm): model = ModuleTypeProfile fieldsets = ( @@ -717,7 +738,7 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm): FieldSet('q', 'filter_id', 'tag'), FieldSet( 'profile_id', 'manufacturer_id', 'part_number', 'module_count', - 'airflow', name=_('Hardware') + 'airflow', 'module_bay_type_id', name=_('Hardware') ), FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', @@ -739,6 +760,12 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm): required=False, label=_('Manufacturer') ) + module_bay_type_id = DynamicModelMultipleChoiceField( + queryset=ModuleBayType.objects.all(), + required=False, + null_option='None', + label=_('Module bay type') + ) part_number = forms.CharField( label=_('Part number'), required=False @@ -1919,7 +1946,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): model = ModuleBay fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('name', 'label', 'position', 'enabled', name=_('Attributes')), + FieldSet('name', 'label', 'position', 'enabled', 'module_bay_type_id', name=_('Attributes')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet( 'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', @@ -1936,6 +1963,11 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): required=False, widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES), ) + module_bay_type_id = DynamicModelMultipleChoiceField( + queryset=ModuleBayType.objects.all(), + required=False, + label=_('Module bay type') + ) tag = TagFilterField(model) @@ -1943,7 +1975,7 @@ class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm): model = ModuleBayTemplate fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('name', 'label', 'position', 'enabled', name=_('Attributes')), + FieldSet('name', 'label', 'position', 'enabled', 'module_bay_type_id', name=_('Attributes')), FieldSet('device_type_id', 'module_type_id', name=_('Device')), ) position = forms.CharField( @@ -1955,6 +1987,11 @@ class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm): required=False, widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES), ) + module_bay_type_id = DynamicModelMultipleChoiceField( + queryset=ModuleBayType.objects.all(), + required=False, + label=_('Module bay type') + ) class DeviceBayFilterForm(DeviceComponentFilterForm): diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index dc63aaf68..3f97c8e99 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -69,6 +69,7 @@ __all__ = ( 'ManufacturerForm', 'ModuleBayForm', 'ModuleBayTemplateForm', + 'ModuleBayTypeForm', 'ModuleForm', 'ModuleTypeForm', 'ModuleTypeProfileForm', @@ -533,6 +534,27 @@ class DeviceTypeForm(PrimaryModelForm): } +class ModuleBayTypeForm(PrimaryModelForm): + manufacturer = DynamicModelChoiceField( + label=_('Manufacturer'), + queryset=Manufacturer.objects.all(), + required=False, + ) + slug = SlugField( + slug_source='name', + ) + + fieldsets = ( + FieldSet('name', 'slug', 'manufacturer', 'color', 'description', 'tags', name=_('Module Bay Type')), + ) + + class Meta: + model = ModuleBayType + fields = [ + 'name', 'slug', 'manufacturer', 'color', 'description', 'owner', 'comments', 'tags', + ] + + class ModuleTypeProfileForm(PrimaryModelForm): schema = JSONField( label=_('Schema'), @@ -572,12 +594,18 @@ class ModuleTypeForm(PrimaryModelForm): label=_('Manufacturer'), queryset=Manufacturer.objects.all() ) + module_bay_types = DynamicModelMultipleChoiceField( + label=_('Module bay types'), + queryset=ModuleBayType.objects.all(), + required=False, + ) @property def fieldsets(self): return [ FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')), FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')), + FieldSet('module_bay_types', name=_('Bay Type Compatibility')), FieldSet('end_of_life', name=_('Lifecycle')), FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes'), html_id='profile-attributes') ] @@ -586,7 +614,7 @@ class ModuleTypeForm(PrimaryModelForm): model = ModuleType fields = [ 'profile', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', - 'end_of_life', 'owner', 'comments', 'tags', + 'module_bay_types', 'end_of_life', 'owner', 'comments', 'tags', ] widgets = { 'end_of_life': DatePicker(), @@ -1461,20 +1489,26 @@ class RearPortTemplateForm(ModularComponentTemplateForm): class ModuleBayTemplateForm(ModularComponentTemplateForm): + module_bay_types = DynamicModelMultipleChoiceField( + label=_('Module bay types'), + queryset=ModuleBayType.objects.all(), + required=False, + ) + fieldsets = ( FieldSet( TabbedGroups( FieldSet('device_type', name=_('Device Type')), FieldSet('module_type', name=_('Module Type')), ), - 'name', 'label', 'position', 'enabled', 'description', + 'name', 'label', 'position', 'enabled', 'description', 'module_bay_types', ), ) class Meta: model = ModuleBayTemplate fields = [ - 'device_type', 'module_type', 'name', 'label', 'position', 'enabled', 'description', + 'device_type', 'module_type', 'name', 'label', 'position', 'enabled', 'description', 'module_bay_types', ] @@ -2003,14 +2037,21 @@ class RearPortForm(ModularDeviceComponentForm): class ModuleBayForm(ModularDeviceComponentForm): + module_bay_types = DynamicModelMultipleChoiceField( + label=_('Module bay types'), + queryset=ModuleBayType.objects.all(), + required=False, + ) + fieldsets = ( - FieldSet('device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'tags',), + FieldSet('device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'module_bay_types', 'tags'), ) class Meta: model = ModuleBay fields = [ - 'device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'owner', 'tags', + 'device', 'module', 'name', 'label', 'position', 'enabled', 'description', 'module_bay_types', 'owner', + 'tags', ] diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 58f1b9bf5..1fc767793 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -93,6 +93,7 @@ __all__ = ( 'ManufacturerFilter', 'ModuleBayFilter', 'ModuleBayTemplateFilter', + 'ModuleBayTypeFilter', 'ModuleFilter', 'ModuleTypeFilter', 'ModuleTypeProfileFilter', @@ -757,12 +758,30 @@ class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter): parent_id: ID | None = strawberry_django.filter_field() position: StrFilterLookup | None = strawberry_django.filter_field() enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + module_bay_types: Annotated['ModuleBayTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + module_bay_type_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True) class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter): position: StrFilterLookup | None = strawberry_django.filter_field() enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + module_bay_types: Annotated['ModuleBayTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + module_bay_type_id: ID | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.ModuleBayType, lookups=True) +class ModuleBayTypeFilter(PrimaryModelFilter): + name: StrFilterLookup | None = strawberry_django.filter_field() + slug: StrFilterLookup | None = strawberry_django.filter_field() + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True) @@ -780,6 +799,10 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod strawberry_django.filter_field() ) profile_id: ID | None = strawberry_django.filter_field() + module_bay_types: Annotated['ModuleBayTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + module_bay_type_id: ID | None = strawberry_django.filter_field() model: StrFilterLookup | None = strawberry_django.filter_field() part_number: StrFilterLookup | None = strawberry_django.filter_field() instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 710254120..397e074bf 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -78,6 +78,9 @@ class DCIMQuery: module_bay_template: ModuleBayTemplateType = strawberry_django.field() module_bay_template_list: list[ModuleBayTemplateType] = strawberry_django.field() + module_bay_type: ModuleBayTypeType = strawberry_django.field() + module_bay_type_list: list[ModuleBayTypeType] = strawberry_django.field() + module_type_profile: ModuleTypeProfileType = strawberry_django.field() module_type_profile_list: list[ModuleTypeProfileType] = strawberry_django.field() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 903296d03..9249f7e91 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -69,6 +69,7 @@ __all__ = ( 'ModularComponentType', 'ModuleBayTemplateType', 'ModuleBayType', + 'ModuleBayTypeType', 'ModuleType', 'ModuleTypeProfileType', 'ModuleTypeType', @@ -611,6 +612,7 @@ class ModuleBayType(LtreeNodeMixin, ModularComponentType): installed_module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None children: list[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]] + module_bay_types: list[Annotated["ModuleBayTypeType", strawberry.lazy('dcim.graphql.types')]] @strawberry_django.field(prefetch_related='parent') def parent(self) -> Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')] | None: @@ -624,7 +626,21 @@ class ModuleBayType(LtreeNodeMixin, ModularComponentType): pagination=True ) class ModuleBayTemplateType(ModularComponentTemplateType): - pass + module_bay_types: list[Annotated["ModuleBayTypeType", strawberry.lazy('dcim.graphql.types')]] + + +@strawberry_django.type( + models.ModuleBayType, + fields='__all__', + filters=ModuleBayTypeFilter, + pagination=True +) +class ModuleBayTypeType(PrimaryObjectType): + color: str + manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None + module_types: list[Annotated["ModuleTypeType", strawberry.lazy('dcim.graphql.types')]] + module_bays: list[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]] + module_bay_templates: list[Annotated["ModuleBayTemplateType", strawberry.lazy('dcim.graphql.types')]] @strawberry_django.type( @@ -655,6 +671,7 @@ class ModuleTypeType(PrimaryObjectType): module_bay_template_count: BigInt profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] + module_bay_types: list[Annotated["ModuleBayTypeType", strawberry.lazy('dcim.graphql.types')]] frontporttemplates: list[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] consoleserverporttemplates: list[Annotated["ConsoleServerPortTemplateType", strawberry.lazy('dcim.graphql.types')]] diff --git a/netbox/dcim/migrations/0243_modulebaytype.py b/netbox/dcim/migrations/0243_modulebaytype.py new file mode 100644 index 000000000..d9183df42 --- /dev/null +++ b/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, + ), + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 728bd15a9..834f27b98 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -744,6 +744,13 @@ class ModuleBayTemplate(ModularComponentTemplateModel): verbose_name=_('enabled'), default=True, ) + module_bay_types = models.ManyToManyField( + to='dcim.ModuleBayType', + related_name='module_bay_templates', + blank=True, + verbose_name=_('module bay types'), + help_text=_('Types of modules that can be installed in this bay (empty = unconstrained)'), + ) component_model = ModuleBay @@ -753,7 +760,7 @@ class ModuleBayTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): module = kwargs.get('module') - return self.component_model( + instance = self.component_model( name=self.resolve_name(module, kwargs.get('device')), label=self.resolve_label(module, kwargs.get('device')), position=self.resolve_position(module, kwargs.get('device')), @@ -766,6 +773,10 @@ class ModuleBayTemplate(ModularComponentTemplateModel): parent=module.module_bay if module else None, **kwargs ) + # Stash reference so callers (Module.save, Device._instantiate_components) can + # copy M2M fields (e.g. module_bay_types) that bulk_create cannot handle. + instance._source_template = self + return instance instantiate.do_not_call_in_templates = True def to_yaml(self): diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index af008ceca..e3eb15ece 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1351,6 +1351,13 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, LtreeModel): verbose_name=_('enabled'), default=True, ) + module_bay_types = models.ManyToManyField( + to='dcim.ModuleBayType', + related_name='module_bays', + blank=True, + verbose_name=_('module bay types'), + help_text=_('Types of modules that can be installed in this bay (empty = unconstrained)'), + ) # sort_path inherits `name`'s natural_sort collation automatically (LtreeModelBase), # so ORDER BY sort_path sorts siblings naturally (Slot 0..Slot 13) — as MPTT's # order_insertion_by=('name',) did — rather than lexicographically. @@ -1419,6 +1426,32 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, LtreeModel): """ return bool(not self.enabled or hasattr(self, 'installed_module')) + @property + def is_module_compatible(self): + """ + Return True if the installed module (if any) is compatible with this bay's type constraints, + or if this bay has no type constraints, or if no module is installed. + Returns False when this bay and the installed module's type have non-empty, disjoint bay type sets. + """ + module = getattr(self, 'installed_module', None) + if module is None: + return True + # Use .all() so a prefetch cache is honoured; see Module.is_bay_compatible for details. + bay_types = {t.pk for t in self.module_bay_types.all()} + if not bay_types: + return True + type_types = {t.pk for t in module.module_type.module_bay_types.all()} + if type_types and not (bay_types & type_types): + return False + return True + + def get_incompatible_module(self): + """ + Return the installed Module if it is incompatible with this bay's type constraints, else None. + """ + module = getattr(self, 'installed_module', None) + return module if module and not self.is_module_compatible else None + class DeviceBay(ComponentModel, TrackingModelMixin): """ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 6e090e33d..359a93fe7 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1032,6 +1032,10 @@ class Device( if cf_defaults := CustomField.objects.get_defaults_for_model(model): component.custom_field_data = cf_defaults component.save() + # Copy module_bay_types from the source template (set by instantiate()). + if src := getattr(component, '_source_template', None): + if hasattr(component, 'module_bay_types'): + component.module_bay_types.set(src.module_bay_types.all()) def save(self, *args, **kwargs): is_new = not bool(self.pk) diff --git a/netbox/dcim/models/modules.py b/netbox/dcim/models/modules.py index 69aaa482e..f97bd5ba8 100644 --- a/netbox/dcim/models/modules.py +++ b/netbox/dcim/models/modules.py @@ -14,7 +14,7 @@ from extras.models import CustomField from netbox.models import PrimaryModel from netbox.models.features import ImageAttachmentsMixin from netbox.models.mixins import WeightMixin -from utilities.fields import CounterCacheField +from utilities.fields import ColorField, CounterCacheField from utilities.jsonschema import validate_schema from utilities.string import title from utilities.tracking import TrackingModelMixin @@ -23,11 +23,63 @@ from .device_components import * __all__ = ( 'Module', + 'ModuleBayType', 'ModuleType', 'ModuleTypeProfile', ) +class ModuleBayType(PrimaryModel): + """ + A type classification for module bays. When bay types are assigned to both a ModuleBay and a + ModuleType, module installation is permitted only if the two sets share at least one common + member (i.e. an empty set on either side means unconstrained). + """ + name = models.CharField( + verbose_name=_('name'), + max_length=100, + ) + slug = models.SlugField( + verbose_name=_('slug'), + max_length=100, + ) + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='module_bay_types', + blank=True, + null=True, + ) + color = ColorField( + verbose_name=_('color'), + blank=True, + ) + + clone_fields = ('manufacturer', 'color') + + class Meta: + ordering = ('manufacturer', 'name') + constraints = ( + models.UniqueConstraint( + fields=('manufacturer', 'name'), + name='%(app_label)s_%(class)s_unique_manufacturer_name', + nulls_distinct=False, + ), + models.UniqueConstraint( + fields=('manufacturer', 'slug'), + name='%(app_label)s_%(class)s_unique_manufacturer_slug', + nulls_distinct=False, + ), + ) + verbose_name = _('module bay type') + verbose_name_plural = _('module bay types') + + def __str__(self): + if self.manufacturer: + return f'{self.manufacturer} {self.name}' + return self.name + + class ModuleTypeProfile(PrimaryModel): """ A profile which defines the attributes which can be set on one or more ModuleTypes. @@ -102,6 +154,13 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): null=True, verbose_name=_('attributes') ) + module_bay_types = models.ManyToManyField( + to='dcim.ModuleBayType', + related_name='module_types', + blank=True, + verbose_name=_('module bay types'), + help_text=_('Types of module bays this module type can be installed in (empty = unconstrained)'), + ) module_count = CounterCacheField( to_model='dcim.Module', to_field='module_type' @@ -183,6 +242,30 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): attrs[key] = value return dict(sorted(attrs.items())) + def get_incompatible_modules(self): + """ + Return a queryset of Module instances of this type that are installed in bays whose + bay type sets are non-empty and share no members with this type's bay type set. + If this type has no bay type constraints, no installation can be incompatible. + """ + type_type_pks = list(self.module_bay_types.values_list('pk', flat=True)) + if not type_type_pks: + return Module.objects.none() + # Bays that have any compatible type (the intersection is non-empty) + compatible_bay_pks = ModuleBay.objects.filter( + module_bay_types__pk__in=type_type_pks + ).values_list('pk', flat=True) + # Bays with at least one type set (constrained bays); distinct() prevents M2M join duplicates + constrained_bay_pks = ModuleBay.objects.filter( + module_bay_types__isnull=False + ).distinct().values_list('pk', flat=True) + return Module.objects.filter( + module_type=self, + module_bay__pk__in=constrained_bay_pks, + ).exclude( + module_bay__pk__in=compatible_bay_pks, + ) + def clean(self): super().clean() @@ -304,6 +387,22 @@ class Module(TrackingModelMixin, PrimaryModel): def get_status_color(self): return ModuleStatusChoices.colors.get(self.status) + @property + def is_bay_compatible(self): + """ + Return True if this module's type is compatible with its installed bay, or if either has no type constraints. + Returns False when both the bay and module type have non-empty, disjoint bay type sets. + """ + if not (self.module_bay_id and self.module_type_id): + return True + # Use .all() so a prefetch cache (populated by the API list queryset) is honoured; .values_list() + # creates a fresh queryset that bypasses the cache and causes N+1 hits in list views. + bay_types = {t.pk for t in self.module_bay.module_bay_types.all()} + type_types = {t.pk for t in self.module_type.module_bay_types.all()} + if bay_types and type_types and not (bay_types & type_types): + return False + return True + def clean(self): super().clean() @@ -314,6 +413,15 @@ class Module(TrackingModelMixin, PrimaryModel): ) ) + if not self.is_bay_compatible: + raise ValidationError( + _('Module type {module_type} is not compatible with module bay {module_bay}: ' + 'their bay type sets have no common members.').format( + module_type=self.module_type, + module_bay=self.module_bay, + ) + ) + # Prevent module from being installed in a disabled bay if hasattr(self, 'module_bay') and self.module_bay and not self.module_bay.enabled: current_module_bay_id = Module.objects.filter(pk=self.pk).values_list('module_bay_id', flat=True).first() @@ -382,6 +490,7 @@ class Module(TrackingModelMixin, PrimaryModel): # Get the template for the module type. for template in getattr(self.module_type, templates).all(): template_instance = template.instantiate(device=self.device, module=self) + template_instance._source_template = template if adopt_components: existing_item = installed_components.get(template_instance.name) @@ -412,6 +521,13 @@ class Module(TrackingModelMixin, PrimaryModel): # in ModuleBayTemplate.instantiate() (bulk_create bypasses ModuleBay.save()), # and the BEFORE INSERT trigger derives path/sort_path from parent_id per row. component_model.objects.bulk_create(create_instances) + + # Copy M2M module_bay_types from template to new ModuleBay instances. + if component_model is ModuleBay: + for component in create_instances: + if src := getattr(component, '_source_template', None): + component.module_bay_types.set(src.module_bay_types.all()) + for component in create_instances: post_save.send( sender=component_model, diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index 3eebc6952..edea50e25 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -209,6 +209,18 @@ class ModuleBayIndex(SearchIndex): display_attrs = ('device', 'label', 'position', 'description') +@register_search +class ModuleBayTypeIndex(SearchIndex): + model = models.ModuleBayType + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ('comments', 5000), + ) + display_attrs = ('name', 'manufacturer', 'description') + + @register_search class ModuleTypeProfileIndex(SearchIndex): model = models.ModuleTypeProfile diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 8c1e9c704..25f4a1bd8 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -1015,12 +1015,16 @@ class ModuleBayTable(ModularDeviceComponentTable): template_code=MODULEBAY_STATUS, verbose_name=_('Module Status') ) + module_bay_types = columns.ManyToManyColumn( + verbose_name=_('Bay Types'), + linkify_item=True, + ) class Meta(ModularDeviceComponentTable.Meta): model = models.ModuleBay fields = ( - 'pk', 'id', 'name', 'device', 'enabled', 'parent', 'label', 'position', 'installed_module', 'module_status', - 'module_serial', 'module_asset_tag', 'description', 'tags', + 'pk', 'id', 'name', 'device', 'enabled', 'parent', 'label', 'position', 'module_bay_types', + 'installed_module', 'module_status', 'module_serial', 'module_asset_tag', 'description', 'tags', ) default_columns = ( 'pk', 'name', 'device', 'enabled', 'parent', 'label', 'installed_module', 'module_status', 'description', diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index e46f51f41..ec2decfba 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -292,13 +292,17 @@ class ModuleBayTemplateTable(ComponentTemplateTable): enabled = columns.BooleanColumn( verbose_name=_('Enabled'), ) + module_bay_types = columns.ManyToManyColumn( + verbose_name=_('Bay Types'), + linkify_item=True, + ) actions = columns.ActionsColumn( actions=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): model = models.ModuleBayTemplate - fields = ('pk', 'name', 'label', 'position', 'enabled', 'description', 'actions') + fields = ('pk', 'name', 'label', 'position', 'enabled', 'module_bay_types', 'description', 'actions') empty_text = "None" diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 9fa7a7d44..79c1bb3a3 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -1,18 +1,46 @@ import django_tables2 as tables from django.utils.translation import gettext_lazy as _ -from dcim.models import Module, ModuleType, ModuleTypeProfile +from dcim.models import Module, ModuleBayType, ModuleType, ModuleTypeProfile from netbox.tables import PrimaryModelTable, columns from .template_code import MODULETYPEPROFILE_ATTRIBUTES, WEIGHT __all__ = ( + 'ModuleBayTypeTable', 'ModuleTable', 'ModuleTypeProfileTable', 'ModuleTypeTable', ) +class ModuleBayTypeTable(PrimaryModelTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + manufacturer = tables.Column( + verbose_name=_('Manufacturer'), + linkify=True + ) + color = columns.ColorColumn( + verbose_name=_('Color'), + ) + tags = columns.TagColumn( + url_name='dcim:modulebaytype_list' + ) + + class Meta(PrimaryModelTable.Meta): + model = ModuleBayType + fields = ( + 'pk', 'id', 'name', 'slug', 'manufacturer', 'color', 'description', 'comments', 'tags', + 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'manufacturer', 'color', 'description', + ) + + class ModuleTypeProfileTable(PrimaryModelTable): name = tables.Column( verbose_name=_('Name'), @@ -47,6 +75,10 @@ class ModuleTypeTable(PrimaryModelTable): verbose_name=_('Manufacturer'), linkify=True ) + module_bay_types = columns.ManyToManyColumn( + verbose_name=_('Bay Types'), + linkify_item=True, + ) model = tables.Column( linkify=True, verbose_name=_('Module Type') @@ -72,6 +104,7 @@ class ModuleTypeTable(PrimaryModelTable): model = ModuleType fields = ( 'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'end_of_life', + 'module_bay_types', 'description', 'attributes', 'module_count', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( diff --git a/netbox/dcim/tests/query_counts.json b/netbox/dcim/tests/query_counts.json index f2f2d2f6b..acdc0458e 100644 --- a/netbox/dcim/tests/query_counts.json +++ b/netbox/dcim/tests/query_counts.json @@ -36,12 +36,14 @@ "macaddress:list_objects_with_permission": 24, "manufacturer:api_list_objects": 13, "manufacturer:list_objects_with_permission": 20, - "module:api_list_objects": 18, + "module:api_list_objects": 20, "module:list_objects_with_permission": 24, - "modulebay:api_list_objects": 15, + "modulebay:api_list_objects": 16, "modulebay:list_objects_with_permission": 21, - "modulebaytemplate:api_list_objects": 11, - "moduletype:api_list_objects": 14, + "modulebaytemplate:api_list_objects": 12, + "modulebaytype:api_list_objects": 14, + "modulebaytype:list_objects_with_permission": 21, + "moduletype:api_list_objects": 15, "moduletype:list_objects_with_permission": 22, "moduletypeprofile:api_list_objects": 13, "moduletypeprofile:list_objects_with_permission": 20, diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index bfbbf0eba..0c5dd84e1 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1090,6 +1090,47 @@ class ModuleTypeProfileTestCase(APIViewTestCases.APIViewTestCase): ModuleTypeProfile.objects.bulk_create(module_type_profiles) +class ModuleBayTypeTestCase(APIViewTestCases.APIViewTestCase): + model = ModuleBayType + brief_fields = ['color', 'description', 'display', 'id', 'manufacturer', 'name', 'slug', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + module_bay_types = ( + ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 1', slug='module-bay-type-1'), + ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 2', slug='module-bay-type-2'), + ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 3', slug='module-bay-type-3'), + ) + ModuleBayType.objects.bulk_create(module_bay_types) + + cls.create_data = [ + { + 'manufacturer': manufacturers[1].pk, + 'name': 'Module Bay Type 4', + 'slug': 'module-bay-type-4', + }, + { + 'manufacturer': manufacturers[1].pk, + 'name': 'Module Bay Type 5', + 'slug': 'module-bay-type-5', + }, + { + 'manufacturer': manufacturers[1].pk, + 'name': 'Module Bay Type 6', + 'slug': 'module-bay-type-6', + }, + ] + + class ConsolePortTemplateTestCase(APIViewTestCases.APIViewTestCase): model = ConsolePortTemplate brief_fields = ['description', 'display', 'id', 'name', 'url'] @@ -2239,6 +2280,54 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase): }, ] + def test_is_bay_compatible_flag(self): + """ + is_bay_compatible should be True when no bay types are set, and False when the + bay's types and the module type's types are both set but share no common members. + """ + self.add_permissions('dcim.view_module') + manufacturer = Manufacturer.objects.get(name='Generic') + device = create_test_device('Compat Test Device') + + bay_type_a = ModuleBayType.objects.create(manufacturer=manufacturer, name='Bay Type A', slug='bay-type-a') + bay_type_b = ModuleBayType.objects.create(manufacturer=manufacturer, name='Bay Type B', slug='bay-type-b') + + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Compat Module Type') + module_type.module_bay_types.set([bay_type_a]) + + compatible_bay = ModuleBay.objects.create(device=device, name='Compatible Bay') + compatible_bay.module_bay_types.set([bay_type_a]) + + incompatible_bay = ModuleBay.objects.create(device=device, name='Incompatible Bay') + incompatible_bay.module_bay_types.set([bay_type_b]) + + unconstrained_bay = ModuleBay.objects.create(device=device, name='Unconstrained Bay') + + compatible_module = Module.objects.create( + device=device, module_bay=compatible_bay, module_type=module_type + ) + incompatible_module = Module.objects.create( + device=device, module_bay=incompatible_bay, module_type=module_type + ) + unconstrained_module = Module.objects.create( + device=device, module_bay=unconstrained_bay, module_type=module_type + ) + + url = reverse('dcim-api:module-detail', kwargs={'pk': compatible_module.pk}) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, 200) + self.assertTrue(response.data['is_bay_compatible']) + + url = reverse('dcim-api:module-detail', kwargs={'pk': incompatible_module.pk}) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, 200) + self.assertFalse(response.data['is_bay_compatible']) + + url = reverse('dcim-api:module-detail', kwargs={'pk': unconstrained_module.pk}) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, 200) + self.assertTrue(response.data['is_bay_compatible']) + def test_replicate_components(self): """ Installing a module with replicate_components=True (the default) should create @@ -3268,6 +3357,52 @@ class ModuleBayTestCase(APIViewTestCases.APIViewTestCase): }, ] + def test_is_module_compatible_flag(self): + """ + is_module_compatible should be True when no bay types restrict the bay, and False + when the bay's types and the installed module type's types share no common members. + """ + self.add_permissions('dcim.view_modulebay', 'dcim.view_module', 'dcim.view_moduletype') + manufacturer = Manufacturer.objects.create( + name='Compat Manufacturer', slug='compat-manufacturer' + ) + device = create_test_device('Compat Bay Test Device') + + bay_type_a = ModuleBayType.objects.create( + manufacturer=manufacturer, name='Compat Bay Type A', slug='compat-bay-type-a' + ) + bay_type_b = ModuleBayType.objects.create( + manufacturer=manufacturer, name='Compat Bay Type B', slug='compat-bay-type-b' + ) + + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Compat MT') + module_type.module_bay_types.set([bay_type_a]) + + compatible_bay = ModuleBay.objects.create(device=device, name='Compat Bay C') + compatible_bay.module_bay_types.set([bay_type_a]) + Module.objects.create(device=device, module_bay=compatible_bay, module_type=module_type) + + incompatible_bay = ModuleBay.objects.create(device=device, name='Compat Bay I') + incompatible_bay.module_bay_types.set([bay_type_b]) + Module.objects.create(device=device, module_bay=incompatible_bay, module_type=module_type) + + empty_bay = ModuleBay.objects.create(device=device, name='Compat Bay E') + + url = reverse('dcim-api:modulebay-detail', kwargs={'pk': compatible_bay.pk}) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, 200) + self.assertTrue(response.data['is_module_compatible']) + + url = reverse('dcim-api:modulebay-detail', kwargs={'pk': incompatible_bay.pk}) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, 200) + self.assertFalse(response.data['is_module_compatible']) + + url = reverse('dcim-api:modulebay-detail', kwargs={'pk': empty_bay.pk}) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, 200) + self.assertTrue(response.data['is_module_compatible']) + class DeviceBayTestCase(APIViewTestCases.APIViewTestCase): model = DeviceBay diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 9adc9fc78..59b014944 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1886,6 +1886,49 @@ class ModuleTypeProfileTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) +class ModuleBayTypeTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ModuleBayType.objects.all() + filterset = ModuleBayTypeFilterSet + + @classmethod + def setUpTestData(cls): + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + module_bay_types = ( + ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 1', slug='module-bay-type-1'), + ModuleBayType(manufacturer=manufacturers[1], name='Module Bay Type 2', slug='module-bay-type-2'), + ModuleBayType(manufacturer=manufacturers[2], name='Module Bay Type 3', slug='module-bay-type-3'), + ) + ModuleBayType.objects.bulk_create(module_bay_types) + + def test_q(self): + params = {'q': 'Module Bay Type 1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_name(self): + params = {'name': ['Module Bay Type 1', 'Module Bay Type 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['module-bay-type-1', 'module-bay-type-2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_manufacturer(self): + manufacturers = Manufacturer.objects.filter(name__in=['Manufacturer 1', 'Manufacturer 2']) + params = {'manufacturer': [m.slug for m in manufacturers]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_manufacturer_id(self): + manufacturers = Manufacturer.objects.filter(name__in=['Manufacturer 1', 'Manufacturer 2']) + params = {'manufacturer_id': [m.pk for m in manufacturers]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class ConsolePortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): queryset = ConsolePortTemplate.objects.all() filterset = ConsolePortTemplateFilterSet diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 20ea7cc25..243fec599 100644 --- a/netbox/dcim/tests/test_models.py +++ b/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'])) +class ModuleBayTypeCompatibilityTestCase(TestCase): + """Tests for bay type compatibility: Module.is_bay_compatible, ModuleType.get_incompatible_modules, + ModuleBay.is_module_compatible, and Module.clean() validation.""" + + @classmethod + def setUpTestData(cls): + site = Site.objects.create(name='Compat Site', slug='compat-site') + manufacturer = Manufacturer.objects.create(name='Compat Mfr', slug='compat-mfr') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Compat Device Type', slug='compat-dt' + ) + device_role = DeviceRole.objects.create(name='Compat Role', slug='compat-role') + cls.device = Device.objects.create( + name='Compat Device', device_type=device_type, role=device_role, site=site + ) + + cls.bay_type_a = ModuleBayType.objects.create( + manufacturer=manufacturer, name='Bay Type A', slug='bay-type-a' + ) + cls.bay_type_b = ModuleBayType.objects.create( + manufacturer=manufacturer, name='Bay Type B', slug='bay-type-b' + ) + + cls.module_type_a = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type A') + cls.module_type_a.module_bay_types.set([cls.bay_type_a]) + + cls.module_type_b = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type B') + cls.module_type_b.module_bay_types.set([cls.bay_type_b]) + + cls.module_type_any = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type Any') + + def _make_bay(self, name, *bay_types): + bay = ModuleBay.objects.create(device=self.device, name=name) + if bay_types: + bay.module_bay_types.set(bay_types) + return bay + + def _install(self, bay, module_type): + return Module.objects.create(device=self.device, module_bay=bay, module_type=module_type) + + # --- Module.clean() validation --- + + def test_clean_blocks_incompatible_install(self): + """Module.clean() raises ValidationError when bay and module type have disjoint type sets.""" + bay = self._make_bay('Bay Compat 1', self.bay_type_b) + module = Module(device=self.device, module_bay=bay, module_type=self.module_type_a) + with self.assertRaises(ValidationError): + module.clean() + + def test_clean_allows_compatible_install(self): + """Module.clean() passes when bay and module type share at least one bay type.""" + bay = self._make_bay('Bay Compat 2', self.bay_type_a) + module = Module(device=self.device, module_bay=bay, module_type=self.module_type_a) + module.clean() # should not raise + + def test_clean_allows_unconstrained_module_type(self): + """Module.clean() passes when the module type has no bay type constraints.""" + bay = self._make_bay('Bay Compat 3', self.bay_type_a) + module = Module(device=self.device, module_bay=bay, module_type=self.module_type_any) + module.clean() # should not raise + + def test_clean_allows_unconstrained_bay(self): + """Module.clean() passes when the bay has no bay type constraints.""" + bay = self._make_bay('Bay Compat 4') + module = Module(device=self.device, module_bay=bay, module_type=self.module_type_a) + module.clean() # should not raise + + # --- Module.is_bay_compatible --- + + def test_is_bay_compatible_false_when_disjoint(self): + """Module.is_bay_compatible returns False when bay and module type sets are disjoint.""" + bay = self._make_bay('Bay Compat 5', self.bay_type_b) + # Bypass clean() to create an incompatible installation for testing the property + module = Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type_a) + module.refresh_from_db() + self.assertFalse(module.is_bay_compatible) + + def test_is_bay_compatible_true_when_overlapping(self): + """Module.is_bay_compatible returns True when bay and module type share a bay type.""" + bay = self._make_bay('Bay Compat 6', self.bay_type_a) + module = self._install(bay, self.module_type_a) + self.assertTrue(module.is_bay_compatible) + + def test_is_bay_compatible_true_when_module_type_unconstrained(self): + """Module.is_bay_compatible returns True when module type has no constraints.""" + bay = self._make_bay('Bay Compat 7', self.bay_type_a) + module = self._install(bay, self.module_type_any) + self.assertTrue(module.is_bay_compatible) + + def test_is_bay_compatible_true_when_bay_unconstrained(self): + """Module.is_bay_compatible returns True when bay has no constraints.""" + bay = self._make_bay('Bay Compat 8') + module = self._install(bay, self.module_type_a) + self.assertTrue(module.is_bay_compatible) + + # --- ModuleType.get_incompatible_modules --- + + def test_get_incompatible_modules_returns_incompatible(self): + """ModuleType.get_incompatible_modules includes modules in bays with disjoint type sets.""" + bay = self._make_bay('Bay Compat 9', self.bay_type_b) + module = Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type_a) + qs = self.module_type_a.get_incompatible_modules() + self.assertIn(module, qs) + + def test_get_incompatible_modules_excludes_compatible(self): + """ModuleType.get_incompatible_modules excludes modules in bays with matching type sets.""" + bay = self._make_bay('Bay Compat 10', self.bay_type_a) + module = self._install(bay, self.module_type_a) + qs = self.module_type_a.get_incompatible_modules() + self.assertNotIn(module, qs) + + def test_get_incompatible_modules_excludes_unconstrained_bay(self): + """ModuleType.get_incompatible_modules excludes modules in unconstrained bays.""" + bay = self._make_bay('Bay Compat 11') + module = self._install(bay, self.module_type_a) + qs = self.module_type_a.get_incompatible_modules() + self.assertNotIn(module, qs) + + def test_get_incompatible_modules_empty_when_type_unconstrained(self): + """ModuleType.get_incompatible_modules returns empty queryset when type has no constraints.""" + bay = self._make_bay('Bay Compat 12', self.bay_type_a) + self._install(bay, self.module_type_any) + qs = self.module_type_any.get_incompatible_modules() + self.assertFalse(qs.exists()) + + # --- ModuleBay.is_module_compatible --- + + def test_bay_is_module_compatible_false_when_disjoint(self): + """ModuleBay.is_module_compatible returns False when bay and installed module sets are disjoint.""" + bay = self._make_bay('Bay Compat 13', self.bay_type_b) + Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type_a) + bay.refresh_from_db() + self.assertFalse(bay.is_module_compatible) + + def test_bay_is_module_compatible_true_when_overlapping(self): + """ModuleBay.is_module_compatible returns True when sets overlap.""" + bay = self._make_bay('Bay Compat 14', self.bay_type_a) + self._install(bay, self.module_type_a) + bay.refresh_from_db() + self.assertTrue(bay.is_module_compatible) + + def test_bay_is_module_compatible_true_when_no_module(self): + """ModuleBay.is_module_compatible returns True when nothing is installed.""" + bay = self._make_bay('Bay Compat 15', self.bay_type_a) + self.assertTrue(bay.is_module_compatible) + + def test_bay_is_module_compatible_true_when_bay_unconstrained(self): + """ModuleBay.is_module_compatible returns True when bay has no constraints.""" + bay = self._make_bay('Bay Compat 16') + Module.objects.create(device=self.device, module_bay=bay, module_type=self.module_type_a) + bay.refresh_from_db() + self.assertTrue(bay.is_module_compatible) + + class CableTestCase(TestCase): @classmethod diff --git a/netbox/dcim/tests/test_tables.py b/netbox/dcim/tests/test_tables.py index b21ab59c3..254865435 100644 --- a/netbox/dcim/tests/test_tables.py +++ b/netbox/dcim/tests/test_tables.py @@ -67,6 +67,10 @@ class ModuleTypeProfileTableTestCase(TableTestCases.StandardTableTestCase): table = ModuleTypeProfileTable +class ModuleBayTypeTableTestCase(TableTestCases.StandardTableTestCase): + table = ModuleBayTypeTable + + class ModuleTypeTableTestCase(TableTestCases.StandardTableTestCase): table = ModuleTypeTable diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index fc46b6190..bae390700 100644 --- a/netbox/dcim/tests/test_views.py +++ b/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 # diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 228875875..595776463 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -4,6 +4,30 @@ from django.utils.translation import gettext_lazy as _ from netbox.ui import actions, attrs, panels +class BayTypeIncompatibilityPanel(panels.Panel): + """ + Renders a warning banner when a Module is incompatibly installed (its type's bay type set and + the bay's bay type set are both non-empty and share no common members). + Silently omitted when the installation is compatible or unconstrained. + """ + template_name = 'dcim/panels/bay_type_incompatibility.html' + + def should_render(self, context): + from dcim.models import Module, ModuleBay + obj = context.get('object') + if isinstance(obj, Module): + return not obj.is_bay_compatible + if isinstance(obj, ModuleBay): + return not obj.is_module_compatible + return False + + def get_context(self, context): + from dcim.models import Module + ctx = super().get_context(context) + ctx['is_module_view'] = isinstance(context.get('object'), Module) + return ctx + + class SitePanel(panels.ObjectAttributesPanel): region = attrs.NestedObjectAttr('region', linkify=True) group = attrs.NestedObjectAttr('group', linkify=True) @@ -173,6 +197,14 @@ class ModulePanel(panels.ObjectAttributesPanel): asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True) +class ModuleBayTypePanel(panels.ObjectAttributesPanel): + manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True) + name = attrs.TextAttr('name') + color = attrs.ColorAttr('color') + description = attrs.TextAttr('description') + module_types = attrs.RelatedObjectListAttr('module_types', label=_('Compatible Module Types'), linkify=True) + + class ModuleTypeProfilePanel(panels.ObjectAttributesPanel): name = attrs.TextAttr('name') description = attrs.TextAttr('description') @@ -187,6 +219,9 @@ class ModuleTypePanel(panels.ObjectAttributesPanel): airflow = attrs.ChoiceAttr('airflow') weight = attrs.WeightAttr('weight') end_of_life = attrs.DateTimeAttr('end_of_life', spec='date') + module_bay_types = attrs.RelatedObjectListAttr( + 'module_bay_types', label=_('Bay Type Compatibility'), linkify=True + ) class PlatformPanel(panels.NestedGroupObjectPanel): @@ -267,6 +302,7 @@ class ModuleBayPanel(panels.ObjectAttributesPanel): label = attrs.TextAttr('label') position = attrs.TextAttr('position') description = attrs.TextAttr('description') + module_bay_types = attrs.RelatedObjectListAttr('module_bay_types', label=_('Bay Type Compatibility'), linkify=True) class InstalledModulePanel(panels.ObjectAttributesPanel): diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 86cf79924..c92f6cb94 100644 --- a/netbox/dcim/urls.py +++ b/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'))), + path('module-bay-types/', include(get_model_urls('dcim', 'modulebaytype', detail=False))), + path('module-bay-types//', 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'))), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d9d48fd58..a3d4e2c32 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -11,6 +11,7 @@ from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ +from django.utils.translation import ngettext from django.views.generic import View from circuits.models import Circuit, CircuitTermination @@ -1690,6 +1691,74 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView): table = tables.DeviceTypeTable +# +# Module bay types +# + +@register_model_view(ModuleBayType, 'list', path='', detail=False) +class ModuleBayTypeListView(generic.ObjectListView): + queryset = ModuleBayType.objects.all() + filterset = filtersets.ModuleBayTypeFilterSet + filterset_form = forms.ModuleBayTypeFilterForm + table = tables.ModuleBayTypeTable + + +@register_model_view(ModuleBayType) +class ModuleBayTypeView(generic.ObjectView): + template_name = 'generic/object.html' + queryset = ModuleBayType.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.ModuleBayTypePanel(), + TagsPanel(), + CommentsPanel(), + ], + right_panels=[ + CustomFieldsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='dcim.ModuleBay', + title=_('Module Bays'), + filters={'module_bay_type_id': lambda ctx: ctx['object'].pk}, + ), + ], + ) + + +@register_model_view(ModuleBayType, 'add', detail=False) +@register_model_view(ModuleBayType, 'edit') +class ModuleBayTypeEditView(generic.ObjectEditView): + queryset = ModuleBayType.objects.all() + form = forms.ModuleBayTypeForm + + +@register_model_view(ModuleBayType, 'delete') +class ModuleBayTypeDeleteView(generic.ObjectDeleteView): + queryset = ModuleBayType.objects.all() + + +@register_model_view(ModuleBayType, 'bulk_import', detail=False) +class ModuleBayTypeBulkImportView(generic.BulkImportView): + queryset = ModuleBayType.objects.all() + model_form = forms.ModuleBayTypeImportForm + + +@register_model_view(ModuleBayType, 'bulk_edit', path='edit', detail=False) +class ModuleBayTypeBulkEditView(generic.BulkEditView): + queryset = ModuleBayType.objects.all() + filterset = filtersets.ModuleBayTypeFilterSet + table = tables.ModuleBayTypeTable + form = forms.ModuleBayTypeBulkEditForm + + +@register_model_view(ModuleBayType, 'bulk_delete', path='delete', detail=False) +class ModuleBayTypeBulkDeleteView(generic.BulkDeleteView): + queryset = ModuleBayType.objects.all() + filterset = filtersets.ModuleBayTypeFilterSet + table = tables.ModuleBayTypeTable + + # # Module type profiles # @@ -1833,6 +1902,33 @@ class ModuleTypeEditView(generic.ObjectEditView): queryset = ModuleType.objects.all() form = forms.ModuleTypeForm + def post(self, request, *args, **kwargs): + response = super().post(request, *args, **kwargs) + # A successful save always redirects (302) or returns an HTMX redirect header. + # Quick-add creates new objects (no pk in kwargs), so it is excluded by the pk guard below. + saved = ( + getattr(response, 'status_code', None) == 302 or + (hasattr(response, 'headers') and 'HX-Location' in response.headers) + ) + if saved and kwargs.get('pk'): + try: + module_type = ModuleType.objects.prefetch_related('module_bay_types').get(pk=kwargs['pk']) + count = module_type.get_incompatible_modules().count() + if count: + messages.warning( + request, + ngettext( + '%(count)d installed module of this type is now incompatible with its module bay ' + 'due to conflicting bay type constraints.', + '%(count)d installed modules of this type are now incompatible with their module bays ' + 'due to conflicting bay type constraints.', + count, + ) % {'count': count} + ) + except ModuleType.DoesNotExist: + pass + return response + @register_model_view(ModuleType, 'delete') class ModuleTypeDeleteView(generic.ObjectDeleteView): @@ -2000,6 +2096,35 @@ class ModuleTypeBulkEditView(generic.BulkEditView): table = tables.ModuleTypeTable form = forms.ModuleTypeBulkEditForm + def post_save_operations(self, form, obj): + super().post_save_operations(form, obj) + add = form.cleaned_data.get('add_module_bay_types') + remove = form.cleaned_data.get('remove_module_bay_types') + if add: + obj.module_bay_types.add(*add) + if remove: + obj.module_bay_types.remove(*remove) + if add or remove: + # Counts current incompatibilities, not just newly-introduced ones; may over-warn + # if pre-existing incompatibilities exist, but safe to under-warn on. + self._incompatible_count += obj.get_incompatible_modules().count() + + def post(self, request, **kwargs): + self._incompatible_count = 0 + response = super().post(request, **kwargs) + if self._incompatible_count and getattr(response, 'status_code', None) == 302: + messages.warning( + request, + ngettext( + '%(count)d installed module is now incompatible with its module bay ' + 'due to conflicting bay type constraints.', + '%(count)d installed modules are now incompatible with their module bays ' + 'due to conflicting bay type constraints.', + self._incompatible_count, + ) % {'count': self._incompatible_count} + ) + return response + @register_model_view(ModuleType, 'bulk_rename', path='rename', detail=False) class ModuleTypeBulkRenameView(generic.BulkRenameView): @@ -3000,12 +3125,16 @@ class ModuleListView(generic.ObjectListView): @register_model_view(Module) class ModuleView(GetRelatedModelsMixin, generic.ObjectView): - queryset = Module.objects.all() + queryset = Module.objects.prefetch_related( + 'module_bay__module_bay_types', + 'module_type__module_bay_types', + ) layout = layout.SimpleLayout( breadcrumbs=[ Breadcrumb('module_type', url=filtered_list_url('dcim:module_list', 'module_type_id')), ], left_panels=[ + panels.BayTypeIncompatibilityPanel(), panels.ModulePanel(), TagsPanel(), CommentsPanel(), @@ -3851,12 +3980,16 @@ class ModuleBayListView(generic.ObjectListView): @register_model_view(ModuleBay) class ModuleBayView(generic.ObjectView): template_name = 'generic/object.html' - queryset = ModuleBay.objects.all() + queryset = ModuleBay.objects.prefetch_related( + 'module_bay_types', + 'installed_module__module_type__module_bay_types', + ) layout = layout.SimpleLayout( breadcrumbs=[ Breadcrumb('device', url=object_view_url('dcim:device_modulebays')), ], left_panels=[ + panels.BayTypeIncompatibilityPanel(), panels.ModuleBayPanel(), TagsPanel(), ], @@ -3879,6 +4012,29 @@ class ModuleBayEditView(generic.ObjectEditView): queryset = ModuleBay.objects.all() form = forms.ModuleBayForm + def post(self, request, *args, **kwargs): + response = super().post(request, *args, **kwargs) + # A successful save always redirects (302) or returns an HTMX redirect header. + # Quick-add creates new objects (no pk in kwargs), so it is excluded by the pk guard below. + saved = ( + getattr(response, 'status_code', None) == 302 or + (hasattr(response, 'headers') and 'HX-Location' in response.headers) + ) + if saved and kwargs.get('pk'): + try: + bay = ModuleBay.objects.prefetch_related( + 'module_bay_types', 'installed_module__module_type__module_bay_types' + ).get(pk=kwargs['pk']) + if not bay.is_module_compatible: + messages.warning( + request, + _('The module currently installed in this bay is incompatible with the new bay type ' + 'constraints. Consider removing or replacing it.') + ) + except ModuleBay.DoesNotExist: + pass + return response + @register_model_view(ModuleBay, 'delete') class ModuleBayDeleteView(generic.ObjectDeleteView): @@ -3898,6 +4054,35 @@ class ModuleBayBulkEditView(generic.BulkEditView): table = tables.ModuleBayTable form = forms.ModuleBayBulkEditForm + def post_save_operations(self, form, obj): + super().post_save_operations(form, obj) + add = form.cleaned_data.get('add_module_bay_types') + remove = form.cleaned_data.get('remove_module_bay_types') + if add: + obj.module_bay_types.add(*add) + if remove: + obj.module_bay_types.remove(*remove) + if add or remove: + # Counts current incompatibilities, not just newly-introduced ones; may over-warn + # if pre-existing incompatibilities exist, but safe to under-warn on. + self._incompatible_count += int(not obj.is_module_compatible) + + def post(self, request, **kwargs): + self._incompatible_count = 0 + response = super().post(request, **kwargs) + if self._incompatible_count and getattr(response, 'status_code', None) == 302: + messages.warning( + request, + ngettext( + '%(count)d module bay now has an incompatible module installed ' + 'due to conflicting bay type constraints.', + '%(count)d module bays now have incompatible modules installed ' + 'due to conflicting bay type constraints.', + self._incompatible_count, + ) % {'count': self._incompatible_count} + ) + return response + @register_model_view(ModuleBay, 'bulk_rename', path='rename', detail=False) class ModuleBayBulkRenameView(generic.BulkRenameView): diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 7b34c2f1d..15fa25873 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -95,6 +95,7 @@ DEVICES_MENU = Menu( get_model_item('dcim', 'devicetype', _('Device Types')), get_model_item('dcim', 'moduletype', _('Module Types')), get_model_item('dcim', 'moduletypeprofile', _('Module Type Profiles')), + get_model_item('dcim', 'modulebaytype', _('Module Bay Types')), get_model_item('dcim', 'manufacturer', _('Manufacturers')), ), ), diff --git a/netbox/templates/dcim/panels/bay_type_incompatibility.html b/netbox/templates/dcim/panels/bay_type_incompatibility.html new file mode 100644 index 000000000..c0a4c0748 --- /dev/null +++ b/netbox/templates/dcim/panels/bay_type_incompatibility.html @@ -0,0 +1,47 @@ +{% load i18n %} +
+

+ {% trans "Bay Type Incompatibility" %} +

+
+ {% if is_module_view %} + {# Module detail view #} +

+ {% blocktrans with module_type=object.module_type module_bay=object.module_bay %} + Module type {{ module_type }} is not compatible with module bay + {{ module_bay }}: their bay type sets share no common members. + {% endblocktrans %} +

+

+ {% trans "Bay types on bay:" %} + {% for bt in object.module_bay.module_bay_types.all %} + {{ bt }}{% if not forloop.last %}, {% endif %} + {% endfor %} +  —  + {% trans "Bay types on module type:" %} + {% for bt in object.module_type.module_bay_types.all %} + {{ bt }}{% if not forloop.last %}, {% endif %} + {% endfor %} +

+ {% else %} + {# ModuleBay detail view #} +

+ {% blocktrans with module_type=object.installed_module.module_type bay=object %} + Module type {{ module_type }} is not compatible with this bay + ({{ bay }}): their bay type sets share no common members. + {% endblocktrans %} +

+

+ {% trans "Bay types on bay:" %} + {% for bt in object.module_bay_types.all %} + {{ bt }}{% if not forloop.last %}, {% endif %} + {% endfor %} +  —  + {% trans "Bay types on module type:" %} + {% for bt in object.installed_module.module_type.module_bay_types.all %} + {{ bt }}{% if not forloop.last %}, {% endif %} + {% endfor %} +

+ {% endif %} +
+
\ No newline at end of file