Closes #19731: Add ModuleBayType to restrict which module types can be installed into a module bay (#22648)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Closes #19731
This commit is contained in:
parent
d88b6a65dd
commit
d13c98b9ea
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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')]]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -67,6 +67,10 @@ class ModuleTypeProfileTableTestCase(TableTestCases.StandardTableTestCase):
|
|||
table = ModuleTypeProfileTable
|
||||
|
||||
|
||||
class ModuleBayTypeTableTestCase(TableTestCases.StandardTableTestCase):
|
||||
table = ModuleBayTypeTable
|
||||
|
||||
|
||||
class ModuleTypeTableTestCase(TableTestCases.StandardTableTestCase):
|
||||
table = ModuleTypeTable
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ urlpatterns = [
|
|||
path('device-types/', include(get_model_urls('dcim', 'devicetype', detail=False))),
|
||||
path('device-types/<int:pk>/', include(get_model_urls('dcim', 'devicetype'))),
|
||||
|
||||
path('module-bay-types/', include(get_model_urls('dcim', 'modulebaytype', detail=False))),
|
||||
path('module-bay-types/<int:pk>/', include(get_model_urls('dcim', 'modulebaytype'))),
|
||||
|
||||
path('module-type-profiles/', include(get_model_urls('dcim', 'moduletypeprofile', detail=False))),
|
||||
path('module-type-profiles/<int:pk>/', include(get_model_urls('dcim', 'moduletypeprofile'))),
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
—
|
||||
{% 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 %}
|
||||
—
|
||||
{% 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>
|
||||
Loading…
Reference in New Issue