diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 1fdde78d7..e050a22db 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -20,6 +20,8 @@ __all__ = [ 'NestedInterfaceTemplateSerializer', 'NestedInventoryItemSerializer', 'NestedManufacturerSerializer', + 'NestedModuleBaySerializer', + 'NestedModuleBayTemplateSerializer', 'NestedPlatformSerializer', 'NestedPowerFeedSerializer', 'NestedPowerOutletSerializer', @@ -195,6 +197,14 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class NestedModuleBayTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail') + + class Meta: + model = models.ModuleBayTemplate + fields = ['id', 'url', 'display', 'name'] + + class NestedDeviceBayTemplateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail') @@ -298,6 +308,15 @@ class NestedFrontPortSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] +class NestedModuleBaySerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') + # module = NestedModuleSerializer(read_only=True) + + class Meta: + model = models.DeviceBay + fields = ['id', 'url', 'display', 'name'] + + class NestedDeviceBaySerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') device = NestedDeviceSerializer(read_only=True) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 113c71745..1d1294b7a 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -409,6 +409,15 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer): ] +class ModuleBayTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail') + device_type = NestedDeviceTypeSerializer() + + class Meta: + model = ModuleBayTemplate + fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] + + class DeviceBayTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail') device_type = NestedDeviceTypeSerializer() @@ -707,6 +716,19 @@ class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): ] +class ModuleBaySerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') + device = NestedDeviceSerializer() + # installed_module = NestedModuleSerializer(required=False, allow_null=True) + + class Meta: + model = ModuleBay + fields = [ + 'id', 'url', 'display', 'device', 'name', 'label', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', + ] + + class DeviceBaySerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') device = NestedDeviceSerializer() diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 491f4e7f2..bf68106f5 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -28,6 +28,7 @@ router.register('power-outlet-templates', views.PowerOutletTemplateViewSet) router.register('interface-templates', views.InterfaceTemplateViewSet) router.register('front-port-templates', views.FrontPortTemplateViewSet) router.register('rear-port-templates', views.RearPortTemplateViewSet) +router.register('module-bay-templates', views.ModuleBayTemplateViewSet) router.register('device-bay-templates', views.DeviceBayTemplateViewSet) # Devices @@ -43,6 +44,7 @@ router.register('power-outlets', views.PowerOutletViewSet) router.register('interfaces', views.InterfaceViewSet) router.register('front-ports', views.FrontPortViewSet) router.register('rear-ports', views.RearPortViewSet) +router.register('module-bays', views.ModuleBayViewSet) router.register('device-bays', views.DeviceBayViewSet) router.register('inventory-items', views.InventoryItemViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index f359f0f24..25dfda360 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -329,6 +329,12 @@ class RearPortTemplateViewSet(ModelViewSet): filterset_class = filtersets.RearPortTemplateFilterSet +class ModuleBayTemplateViewSet(ModelViewSet): + queryset = ModuleBayTemplate.objects.prefetch_related('device_type__manufacturer') + serializer_class = serializers.ModuleBayTemplateSerializer + filterset_class = filtersets.ModuleBayTemplateFilterSet + + class DeviceBayTemplateViewSet(ModelViewSet): queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.DeviceBayTemplateSerializer @@ -569,15 +575,22 @@ class RearPortViewSet(PassThroughPortMixin, ModelViewSet): brief_prefetch_fields = ['device'] +class ModuleBayViewSet(ModelViewSet): + queryset = ModuleBay.objects.prefetch_related('tags') + serializer_class = serializers.ModuleBaySerializer + filterset_class = filtersets.ModuleBayFilterSet + brief_prefetch_fields = ['device'] + + class DeviceBayViewSet(ModelViewSet): - queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags') + queryset = DeviceBay.objects.prefetch_related('installed_device', 'tags') serializer_class = serializers.DeviceBaySerializer filterset_class = filtersets.DeviceBayFilterSet brief_prefetch_fields = ['device'] class InventoryItemViewSet(ModelViewSet): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags') + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags') serializer_class = serializers.InventoryItemSerializer filterset_class = filtersets.InventoryItemFilterSet brief_prefetch_fields = ['device'] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 8b1369be9..d4c5f8e5a 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -41,6 +41,8 @@ __all__ = ( 'InventoryItemFilterSet', 'LocationFilterSet', 'ManufacturerFilterSet', + 'ModuleBayFilterSet', + 'ModuleBayTemplateFilterSet', 'PathEndpointFilterSet', 'PlatformFilterSet', 'PowerConnectionFilterSet', @@ -447,6 +449,10 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): method='_pass_through_ports', label='Has pass-through ports', ) + module_bays = django_filters.BooleanFilter( + method='_module_bays', + label='Has module bays', + ) device_bays = django_filters.BooleanFilter( method='_device_bays', label='Has device bays', @@ -490,6 +496,9 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): rearporttemplates__isnull=value ) + def _module_bays(self, queryset, name, value): + return queryset.exclude(modulebaytemplates__isnull=value) + def _device_bays(self, queryset, name, value): return queryset.exclude(devicebaytemplates__isnull=value) @@ -576,6 +585,13 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentF fields = ['id', 'name', 'type', 'color', 'positions'] +class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): + + class Meta: + model = ModuleBayTemplate + fields = ['id', 'name'] + + class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): class Meta: @@ -760,6 +776,10 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex method='_pass_through_ports', label='Has pass-through ports', ) + module_bays = django_filters.BooleanFilter( + method='_module_bays', + label='Has module bays', + ) device_bays = django_filters.BooleanFilter( method='_device_bays', label='Has device bays', @@ -811,6 +831,9 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex rearports__isnull=value ) + def _module_bays(self, queryset, name, value): + return queryset.exclude(modulebays__isnull=value) + def _device_bays(self, queryset, name, value): return queryset.exclude(devicebays__isnull=value) @@ -1104,6 +1127,13 @@ class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTe fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description'] +class ModuleBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): + + class Meta: + model = ModuleBay + fields = ['id', 'name', 'label', 'description'] + + class DeviceBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): class Meta: diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 16e860c38..8eae46111 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -13,6 +13,7 @@ __all__ = ( # 'FrontPortBulkCreateForm', 'InterfaceBulkCreateForm', 'InventoryItemBulkCreateForm', + 'ModuleBayBulkCreateForm', 'PowerOutletBulkCreateForm', 'PowerPortBulkCreateForm', 'RearPortBulkCreateForm', @@ -95,6 +96,11 @@ class RearPortBulkCreateForm( field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags') +class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): + model = ModuleBay + field_order = ('name_pattern', 'label_pattern', 'description', 'tags') + + class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): model = DeviceBay field_order = ('name_pattern', 'label_pattern', 'description', 'tags') diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index a40396e98..02492c630 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -7,7 +7,6 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm -from ipam.constants import BGP_ASN_MIN, BGP_ASN_MAX from ipam.models import VLAN, ASN from tenancy.models import Tenant from utilities.forms import ( @@ -33,6 +32,8 @@ __all__ = ( 'InventoryItemBulkEditForm', 'LocationBulkEditForm', 'ManufacturerBulkEditForm', + 'ModuleBayBulkEditForm', + 'ModuleBayTemplateBulkEditForm', 'PlatformBulkEditForm', 'PowerFeedBulkEditForm', 'PowerOutletBulkEditForm', @@ -823,6 +824,23 @@ class RearPortTemplateBulkEditForm(BulkEditForm): nullable_fields = ('description',) +class ModuleBayTemplateBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ModuleBayTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + description = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ('label', 'description') + + class DeviceBayTemplateBulkEditForm(BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=DeviceBayTemplate.objects.all(), @@ -1076,6 +1094,20 @@ class RearPortBulkEditForm( nullable_fields = ['label', 'description'] +class ModuleBayBulkEditForm( + form_from_model(DeviceBay, ['label', 'description']), + AddRemoveTagsForm, + CustomFieldModelBulkEditForm +): + pk = forms.ModelMultipleChoiceField( + queryset=ModuleBay.objects.all(), + widget=forms.MultipleHiddenInput() + ) + + class Meta: + nullable_fields = ['label', 'description'] + + class DeviceBayBulkEditForm( form_from_model(DeviceBay, ['label', 'description']), AddRemoveTagsForm, diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 081f8d466..6092b3d41 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -26,6 +26,7 @@ __all__ = ( 'InventoryItemCSVForm', 'LocationCSVForm', 'ManufacturerCSVForm', + 'ModuleBayCSVForm', 'PlatformCSVForm', 'PowerFeedCSVForm', 'PowerOutletCSVForm', @@ -678,6 +679,17 @@ class RearPortCSVForm(CustomFieldModelCSVForm): } +class ModuleBayCSVForm(CustomFieldModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + + class Meta: + model = ModuleBay + fields = ('device', 'name', 'label', 'description') + + class DeviceBayCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index a1d996b2c..e134adace 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -29,6 +29,7 @@ __all__ = ( 'InventoryItemFilterForm', 'LocationFilterForm', 'ManufacturerFilterForm', + 'ModuleBayFilterForm', 'PlatformFilterForm', 'PowerConnectionFilterForm', 'PowerFeedFilterForm', @@ -970,6 +971,16 @@ class RearPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) +class ModuleBayFilterForm(DeviceComponentFilterForm): + model = ModuleBay + field_groups = [ + ['q', 'tag'], + ['name', 'label'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], + ] + tag = TagFilterField(model) + + class DeviceBayFilterForm(DeviceComponentFilterForm): model = DeviceBay field_groups = [ diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index d16cf3dd1..2fcd23211 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -39,6 +39,8 @@ __all__ = ( 'InventoryItemForm', 'LocationForm', 'ManufacturerForm', + 'ModuleBayForm', + 'ModuleBayTemplateForm', 'PlatformForm', 'PopulateDeviceBayForm', 'PowerFeedForm', @@ -984,6 +986,17 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): } +class ModuleBayTemplateForm(BootstrapMixin, forms.ModelForm): + class Meta: + model = ModuleBayTemplate + fields = [ + 'device_type', 'name', 'label', 'description', + ] + widgets = { + 'device_type': forms.HiddenInput(), + } + + class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = DeviceBayTemplate @@ -1222,6 +1235,22 @@ class RearPortForm(CustomFieldModelForm): } +class ModuleBayForm(CustomFieldModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = ModuleBay + fields = [ + 'device', 'name', 'label', 'description', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + } + + class DeviceBayForm(CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 92b92ef3e..bf9060225 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -25,6 +25,8 @@ __all__ = ( 'InterfaceCreateForm', 'InterfaceTemplateCreateForm', 'InventoryItemCreateForm', + 'ModuleBayCreateForm', + 'ModuleBayTemplateCreateForm', 'PowerOutletCreateForm', 'PowerOutletTemplateCreateForm', 'PowerPortCreateForm', @@ -327,6 +329,10 @@ class RearPortTemplateCreateForm(ComponentTemplateCreateForm): ) +class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm): + field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description') + + class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm): field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description') @@ -619,6 +625,11 @@ class RearPortCreateForm(ComponentCreateForm): ) +class ModuleBayCreateForm(ComponentCreateForm): + model = ModuleBay + field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags') + + class DeviceBayCreateForm(ComponentCreateForm): model = DeviceBay field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags') diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 03f040a00..49924b623 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -11,6 +11,7 @@ __all__ = ( 'DeviceTypeImportForm', 'FrontPortTemplateImportForm', 'InterfaceTemplateImportForm', + 'ModuleBayTemplateImportForm', 'PowerOutletTemplateImportForm', 'PowerPortTemplateImportForm', 'RearPortTemplateImportForm', @@ -139,6 +140,15 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm): ] +class ModuleBayTemplateImportForm(ComponentTemplateImportForm): + + class Meta: + model = ModuleBayTemplate + fields = [ + 'device_type', 'name', 'label', 'description', + ] + + class DeviceBayTemplateImportForm(ComponentTemplateImportForm): class Meta: diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 13e0c20ec..60b7526bd 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -56,6 +56,12 @@ class DCIMQuery(graphene.ObjectType): manufacturer = ObjectField(ManufacturerType) manufacturer_list = ObjectListField(ManufacturerType) + module_bay = ObjectField(ModuleBayType) + module_bay_list = ObjectListField(ModuleBayType) + + module_bay_template = ObjectField(ModuleBayTemplateType) + module_bay_template_list = ObjectListField(ModuleBayTemplateType) + platform = ObjectField(PlatformType) platform_list = ObjectListField(PlatformType) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 8ce10979e..355c14dc3 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -27,6 +27,8 @@ __all__ = ( 'InventoryItemType', 'LocationType', 'ManufacturerType', + 'ModuleBayType', + 'ModuleBayTemplateType', 'PlatformType', 'PowerFeedType', 'PowerOutletType', @@ -254,6 +256,22 @@ class ManufacturerType(OrganizationalObjectType): filterset_class = filtersets.ManufacturerFilterSet +class ModuleBayType(ComponentObjectType): + + class Meta: + model = models.ModuleBay + fields = '__all__' + filterset_class = filtersets.ModuleBayFilterSet + + +class ModuleBayTemplateType(ComponentTemplateObjectType): + + class Meta: + model = models.ModuleBayTemplate + fields = '__all__' + filterset_class = filtersets.ModuleBayTemplateFilterSet + + class PlatformType(OrganizationalObjectType): class Meta: diff --git a/netbox/dcim/migrations/0145_modules.py b/netbox/dcim/migrations/0145_modules.py new file mode 100644 index 000000000..c469e059c --- /dev/null +++ b/netbox/dcim/migrations/0145_modules.py @@ -0,0 +1,53 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.fields +import utilities.ordering + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0066_customfield_name_validation'), + ('dcim', '0144_site_remove_deprecated_fields'), + ] + + operations = [ + migrations.CreateModel( + name='ModuleBayTemplate', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), + ('label', models.CharField(blank=True, max_length=64)), + ('description', models.CharField(blank=True, max_length=200)), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebaytemplates', to='dcim.devicetype')), + ], + options={ + 'ordering': ('device_type', '_name'), + 'unique_together': {('device_type', 'name')}, + }, + ), + migrations.CreateModel( + name='ModuleBay', + fields=[ + ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), + ('label', models.CharField(blank=True, max_length=64)), + ('description', models.CharField(blank=True, max_length=200)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebays', to='dcim.device')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('device', '_name'), + 'unique_together': {('device', 'name')}, + }, + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 58a3e1de5..86e49c42e 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -27,6 +27,8 @@ __all__ = ( 'InventoryItem', 'Location', 'Manufacturer', + 'ModuleBay', + 'ModuleBayTemplate', 'Platform', 'PowerFeed', 'PowerOutlet', diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 42e453669..c8ab8f5f0 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -9,7 +9,7 @@ from netbox.models import ChangeLoggedModel from utilities.fields import ColorField, NaturalOrderingField from utilities.ordering import naturalize_interface from .device_components import ( - ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort, + ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, ModuleBay, PowerOutlet, PowerPort, RearPort, ) @@ -19,6 +19,7 @@ __all__ = ( 'DeviceBayTemplate', 'FrontPortTemplate', 'InterfaceTemplate', + 'ModuleBayTemplate', 'PowerOutletTemplate', 'PowerPortTemplate', 'RearPortTemplate', @@ -360,6 +361,23 @@ class RearPortTemplate(ComponentTemplateModel): ) +@extras_features('webhooks') +class ModuleBayTemplate(ComponentTemplateModel): + """ + A template for a ModuleBay to be created for a new parent Device. + """ + class Meta: + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') + + def instantiate(self, device): + return ModuleBay( + device=device, + name=self.name, + label=self.label + ) + + @extras_features('webhooks') class DeviceBayTemplate(ComponentTemplateModel): """ diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index e105bd804..08e069239 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -30,6 +30,7 @@ __all__ = ( 'FrontPort', 'Interface', 'InventoryItem', + 'ModuleBay', 'PathEndpoint', 'PowerOutlet', 'PowerPort', @@ -229,7 +230,7 @@ class PathEndpoint(models.Model): # -# Console ports +# Console components # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @@ -260,10 +261,6 @@ class ConsolePort(ComponentModel, LinkTermination, PathEndpoint): return reverse('dcim:consoleport', kwargs={'pk': self.pk}) -# -# Console server ports -# - @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint): """ @@ -293,7 +290,7 @@ class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint): # -# Power ports +# Power components # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @@ -389,10 +386,6 @@ class PowerPort(ComponentModel, LinkTermination, PathEndpoint): } -# -# Power outlets -# - @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerOutlet(ComponentModel, LinkTermination, PathEndpoint): """ @@ -866,9 +859,24 @@ class RearPort(ComponentModel, LinkTermination): # -# Device bays +# Bays # +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class ModuleBay(ComponentModel): + """ + An empty space within a Device which can house a child device + """ + clone_fields = ['device'] + + class Meta: + ordering = ('device', '_name') + unique_together = ('device', 'name') + + def get_absolute_url(self): + return reverse('dcim:modulebay', kwargs={'pk': self.pk}) + + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceBay(ComponentModel): """ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 24eeb7ac3..18c0fe9de 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -786,6 +786,9 @@ class Device(PrimaryModel, ConfigContextModel): FrontPort.objects.bulk_create( [x.instantiate(self) for x in self.device_type.frontporttemplates.all()] ) + ModuleBay.objects.bulk_create( + [x.instantiate(self) for x in self.device_type.modulebaytemplates.all()] + ) DeviceBay.objects.bulk_create( [x.instantiate(self) for x in self.device_type.devicebaytemplates.all()] ) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index f0e9c9bb0..df1d79aa4 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -2,8 +2,8 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import ( - ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform, - PowerOutlet, PowerPort, RearPort, VirtualChassis, + ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, ModuleBay, + Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis, ) from tenancy.tables import TenantColumn from utilities.tables import ( @@ -25,6 +25,7 @@ __all__ = ( 'DeviceImportTable', 'DeviceInterfaceTable', 'DeviceInventoryItemTable', + 'DeviceModuleBayTable', 'DevicePowerPortTable', 'DevicePowerOutletTable', 'DeviceRearPortTable', @@ -33,6 +34,7 @@ __all__ = ( 'FrontPortTable', 'InterfaceTable', 'InventoryItemTable', + 'ModuleBayTable', 'PlatformTable', 'PowerOutletTable', 'PowerPortTable', @@ -716,6 +718,35 @@ class DeviceDeviceBayTable(DeviceBayTable): ) +class ModuleBayTable(DeviceComponentTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_modulebays', + 'args': [Accessor('device_id')], + } + ) + tags = TagColumn( + url_name='dcim:modulebay_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = ModuleBay + fields = ('pk', 'id', 'name', 'device', 'label', 'description', 'tags') + default_columns = ('pk', 'name', 'device', 'label', 'description') + + +class DeviceModuleBayTable(ModuleBayTable): + actions = ButtonsColumn( + model=ModuleBay, + buttons=('edit', 'delete') + ) + + class Meta(DeviceComponentTable.Meta): + model = ModuleBay + fields = ('pk', 'id', 'name', 'label', 'description', 'tags', 'actions') + default_columns = ('pk', 'name', 'label', 'description', 'actions') + + class InventoryItemTable(DeviceComponentTable): device = tables.Column( linkify={ diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index f932b7994..6fc038542 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -2,7 +2,7 @@ import django_tables2 as tables from dcim.models import ( ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate, - Manufacturer, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, + Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from utilities.tables import ( BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, @@ -16,6 +16,7 @@ __all__ = ( 'FrontPortTemplateTable', 'InterfaceTemplateTable', 'ManufacturerTable', + 'ModuleBayTemplateTable', 'PowerOutletTemplateTable', 'PowerPortTemplateTable', 'RearPortTemplateTable', @@ -207,6 +208,19 @@ class RearPortTemplateTable(ComponentTemplateTable): empty_text = "None" +class ModuleBayTemplateTable(ComponentTemplateTable): + actions = ButtonsColumn( + model=ModuleBayTemplate, + buttons=('edit', 'delete'), + return_url_extra='%23tab_modulebays' + ) + + class Meta(ComponentTemplateTable.Meta): + model = ModuleBayTemplate + fields = ('pk', 'name', 'label', 'description', 'actions') + empty_text = "None" + + class DeviceBayTemplateTable(ComponentTemplateTable): actions = ButtonsColumn( model=DeviceBayTemplate, diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index bc6b18ead..2f68b0fbf 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -778,6 +778,46 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): ] +class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase): + model = ModuleBayTemplate + brief_fields = ['display', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, + model='Device Type 1', + slug='device-type-1', + subdevice_role=SubdeviceRoleChoices.ROLE_PARENT + ) + + module_bay_templates = ( + ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1'), + ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2'), + ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3'), + ) + ModuleBayTemplate.objects.bulk_create(module_bay_templates) + + cls.create_data = [ + { + 'device_type': devicetype.pk, + 'name': 'Module Bay Template 4', + }, + { + 'device_type': devicetype.pk, + 'name': 'Module Bay Template 5', + }, + { + 'device_type': devicetype.pk, + 'name': 'Module Bay Template 6', + }, + ] + + class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): model = DeviceBayTemplate brief_fields = ['display', 'id', 'name', 'url'] @@ -1369,6 +1409,45 @@ class RearPortTest(APIViewTestCases.APIViewTestCase): ] +class ModuleBayTest(APIViewTestCases.APIViewTestCase): + model = ModuleBay + brief_fields = ['display', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + site = Site.objects.create(name='Site 1', slug='site-1') + devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000') + + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + device = Device.objects.create(device_type=device_type, device_role=devicerole, name='Device 1', site=site) + + device_bays = ( + ModuleBay(device=device, name='Device Bay 1'), + ModuleBay(device=device, name='Device Bay 2'), + ModuleBay(device=device, name='Device Bay 3'), + ) + ModuleBay.objects.bulk_create(device_bays) + + cls.create_data = [ + { + 'device': device.pk, + 'name': 'Device Bay 4', + }, + { + 'device': device.pk, + 'name': 'Device Bay 5', + }, + { + 'device': device.pk, + 'name': 'Device Bay 6', + }, + ] + + class DeviceBayTest(APIViewTestCases.APIViewTestCase): model = DeviceBay brief_fields = ['device', 'display', 'id', 'name', 'url'] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index a187c8881..c35739320 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -678,6 +678,10 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): FrontPortTemplate(device_type=device_types[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]), FrontPortTemplate(device_type=device_types[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]), )) + ModuleBayTemplate.objects.bulk_create(( + ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1'), + ModuleBayTemplate(device_type=device_types[1], name='Module Bay 2'), + )) DeviceBayTemplate.objects.bulk_create(( DeviceBayTemplate(device_type=device_types[0], name='Device Bay 1'), DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2'), @@ -762,6 +766,12 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'device_bays': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_module_bays(self): + params = {'module_bays': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'module_bays': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + class ConsolePortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConsolePortTemplate.objects.all() @@ -1036,6 +1046,38 @@ class RearPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) +class ModuleBayTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ModuleBayTemplate.objects.all() + filterset = ModuleBayTemplateFilterSet + + @classmethod + def setUpTestData(cls): + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + + device_types = ( + DeviceType(manufacturer=manufacturer, model='Model 1', slug='model-1'), + DeviceType(manufacturer=manufacturer, model='Model 2', slug='model-2'), + DeviceType(manufacturer=manufacturer, model='Model 3', slug='model-3'), + ) + DeviceType.objects.bulk_create(device_types) + + ModuleBayTemplate.objects.bulk_create(( + ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1'), + ModuleBayTemplate(device_type=device_types[1], name='Module Bay 2'), + ModuleBayTemplate(device_type=device_types[2], name='Module Bay 3'), + )) + + def test_name(self): + params = {'name': ['Module Bay 1', 'Module Bay 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_devicetype_id(self): + device_types = DeviceType.objects.all()[:2] + params = {'devicetype_id': [device_types[0].pk, device_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class DeviceBayTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DeviceBayTemplate.objects.all() filterset = DeviceBayTemplateFilterSet @@ -1280,6 +1322,10 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): FrontPort(device=devices[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]), FrontPort(device=devices[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]), )) + ModuleBay.objects.bulk_create(( + ModuleBay(device=devices[0], name='Module Bay 1'), + ModuleBay(device=devices[1], name='Module Bay 2'), + )) DeviceBay.objects.bulk_create(( DeviceBay(device=devices[0], name='Device Bay 1'), DeviceBay(device=devices[1], name='Device Bay 2'), @@ -1465,6 +1511,12 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'pass_through_ports': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_module_bays(self): + params = {'module_bays': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'module_bays': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_device_bays(self): params = {'device_bays': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2508,6 +2560,109 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) +class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ModuleBay.objects.all() + filterset = ModuleBayFilterSet + + @classmethod + def setUpTestData(cls): + + regions = ( + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), + ) + for region in regions: + region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + + sites = Site.objects.bulk_create(( + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), + Site(name='Site X', slug='site-x'), + )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') + device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), + ) + for location in locations: + location.save() + + devices = ( + Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]), + Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]), + Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]), + ) + Device.objects.bulk_create(devices) + + module_bays = ( + ModuleBay(device=devices[0], name='Module Bay 1', label='A', description='First'), + ModuleBay(device=devices[1], name='Module Bay 2', label='B', description='Second'), + ModuleBay(device=devices[2], name='Module Bay 3', label='C', description='Third'), + ) + ModuleBay.objects.bulk_create(module_bays) + + def test_name(self): + params = {'name': ['Module Bay 1', 'Module Bay 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['First', 'Second']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_region(self): + regions = Region.objects.all()[:2] + params = {'region_id': [regions[0].pk, regions[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'region': [regions[0].slug, regions[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site(self): + sites = Site.objects.all()[:2] + params = {'site_id': [sites[0].pk, sites[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site': [sites[0].slug, sites[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_location(self): + locations = Location.objects.all()[:2] + params = {'location_id': [locations[0].pk, locations[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'location': [locations[0].slug, locations[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_device(self): + devices = Device.objects.all()[:2] + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device': [devices[0].name, devices[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DeviceBay.objects.all() filterset = DeviceBayFilterSet diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 1042057de..8566f969b 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -308,6 +308,11 @@ class DeviceTestCase(TestCase): rear_port_position=2 ).save() + ModuleBayTemplate( + device_type=self.device_type, + name='Module Bay 1' + ).save() + DeviceBayTemplate( device_type=self.device_type, name='Device Bay 1' @@ -371,6 +376,11 @@ class DeviceTestCase(TestCase): rear_port_position=2 ) + ModuleBay.objects.get( + device=d, + name='Module Bay 1' + ) + DeviceBay.objects.get( device=d, name='Device Bay 1' diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 4706cdc6a..7f93c10a2 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -554,6 +554,19 @@ class DeviceTypeTestCase( url = reverse('dcim:devicetype_frontports', kwargs={'pk': devicetype.pk}) self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_modulebays(self): + devicetype = DeviceType.objects.first() + module_bays = ( + ModuleBayTemplate(device_type=devicetype, name='Module Bay 1'), + ModuleBayTemplate(device_type=devicetype, name='Module Bay 2'), + ModuleBayTemplate(device_type=devicetype, name='Module Bay 3'), + ) + ModuleBayTemplate.objects.bulk_create(module_bays) + + url = reverse('dcim:devicetype_modulebays', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_devicetype_devicebays(self): devicetype = DeviceType.objects.first() @@ -638,6 +651,10 @@ front-ports: - name: Front Port 3 type: 8p8c rear_port: Rear Port 3 +module-bays: + - name: Module Bay 1 + - name: Module Bay 2 + - name: Module Bay 3 device-bays: - name: Device Bay 1 - name: Device Bay 2 @@ -658,6 +675,7 @@ device-bays: 'dcim.add_interfacetemplate', 'dcim.add_frontporttemplate', 'dcim.add_rearporttemplate', + 'dcim.add_modulebaytemplate', 'dcim.add_devicebaytemplate', ) @@ -710,6 +728,10 @@ device-bays: self.assertEqual(fp1.rear_port, rp1) self.assertEqual(fp1.rear_port_position, 1) + self.assertEqual(dt.modulebaytemplates.count(), 3) + db1 = ModuleBayTemplate.objects.first() + self.assertEqual(db1.name, 'Module Bay 1') + self.assertEqual(dt.devicebaytemplates.count(), 3) db1 = DeviceBayTemplate.objects.first() self.assertEqual(db1.name, 'Device Bay 1') @@ -1011,6 +1033,39 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase } +class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = ModuleBayTemplate + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetypes = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + ) + DeviceType.objects.bulk_create(devicetypes) + + ModuleBayTemplate.objects.bulk_create(( + ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 1'), + ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 2'), + ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 3'), + )) + + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'name': 'Module Bay Template X', + } + + cls.bulk_create_data = { + 'device_type': devicetypes[1].pk, + 'name_pattern': 'Module Bay Template [4-6]', + } + + cls.bulk_edit_data = { + 'description': 'Foo bar', + } + + class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = DeviceBayTemplate @@ -1307,6 +1362,19 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): url = reverse('dcim:device_frontports', kwargs={'pk': device.pk}) self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_device_modulebays(self): + device = Device.objects.first() + device_bays = ( + ModuleBay(device=device, name='Module Bay 1'), + ModuleBay(device=device, name='Module Bay 2'), + ModuleBay(device=device, name='Module Bay 3'), + ) + ModuleBay.objects.bulk_create(device_bays) + + url = reverse('dcim:device_modulebays', kwargs={'pk': device.pk}) + self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_device_devicebays(self): device = Device.objects.first() @@ -1807,6 +1875,47 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): self.assertHttpStatus(response, 200) +class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase): + model = ModuleBay + + @classmethod + def setUpTestData(cls): + device = create_test_device('Device 1') + + ModuleBay.objects.bulk_create([ + ModuleBay(device=device, name='Module Bay 1'), + ModuleBay(device=device, name='Module Bay 2'), + ModuleBay(device=device, name='Module Bay 3'), + ]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'device': device.pk, + 'name': 'Module Bay X', + 'description': 'A device bay', + 'tags': [t.pk for t in tags], + } + + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Module Bay [4-6]', + 'description': 'A module bay', + 'tags': [t.pk for t in tags], + } + + cls.bulk_edit_data = { + 'description': 'New description', + } + + cls.csv_data = ( + "device,name", + "Device 1,Module Bay 4", + "Device 1,Module Bay 5", + "Device 1,Module Bay 6", + ) + + class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): model = DeviceBay diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 11665f22a..dbde8e348 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -113,6 +113,7 @@ urlpatterns = [ path('device-types//interfaces/', views.DeviceTypeInterfacesView.as_view(), name='devicetype_interfaces'), path('device-types//front-ports/', views.DeviceTypeFrontPortsView.as_view(), name='devicetype_frontports'), path('device-types//rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'), + path('device-types//module-bays/', views.DeviceTypeModuleBaysView.as_view(), name='devicetype_modulebays'), path('device-types//device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'), path('device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), path('device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), @@ -183,6 +184,14 @@ urlpatterns = [ path('device-bay-templates//edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'), path('device-bay-templates//delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'), + # Device bay templates + path('module-bay-templates/add/', views.ModuleBayTemplateCreateView.as_view(), name='modulebaytemplate_add'), + path('module-bay-templates/edit/', views.ModuleBayTemplateBulkEditView.as_view(), name='modulebaytemplate_bulk_edit'), + path('module-bay-templates/rename/', views.ModuleBayTemplateBulkRenameView.as_view(), name='modulebaytemplate_bulk_rename'), + path('module-bay-templates/delete/', views.ModuleBayTemplateBulkDeleteView.as_view(), name='modulebaytemplate_bulk_delete'), + path('module-bay-templates//edit/', views.ModuleBayTemplateEditView.as_view(), name='modulebaytemplate_edit'), + path('module-bay-templates//delete/', views.ModuleBayTemplateDeleteView.as_view(), name='modulebaytemplate_delete'), + # Device roles path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'), @@ -222,6 +231,7 @@ urlpatterns = [ path('devices//interfaces/', views.DeviceInterfacesView.as_view(), name='device_interfaces'), path('devices//front-ports/', views.DeviceFrontPortsView.as_view(), name='device_frontports'), path('devices//rear-ports/', views.DeviceRearPortsView.as_view(), name='device_rearports'), + path('devices//module-bays/', views.DeviceModuleBaysView.as_view(), name='device_modulebays'), path('devices//device-bays/', views.DeviceDeviceBaysView.as_view(), name='device_devicebays'), path('devices//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), path('devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), @@ -343,6 +353,19 @@ urlpatterns = [ path('rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), + # Module bays + path('module-bays/', views.ModuleBayListView.as_view(), name='modulebay_list'), + path('module-bays/add/', views.ModuleBayCreateView.as_view(), name='modulebay_add'), + path('module-bays/import/', views.ModuleBayBulkImportView.as_view(), name='modulebay_import'), + path('module-bays/edit/', views.ModuleBayBulkEditView.as_view(), name='modulebay_bulk_edit'), + path('module-bays/rename/', views.ModuleBayBulkRenameView.as_view(), name='modulebay_bulk_rename'), + path('module-bays/delete/', views.ModuleBayBulkDeleteView.as_view(), name='modulebay_bulk_delete'), + path('module-bays//', views.ModuleBayView.as_view(), name='modulebay'), + path('module-bays//edit/', views.ModuleBayEditView.as_view(), name='modulebay_edit'), + path('module-bays//delete/', views.ModuleBayDeleteView.as_view(), name='modulebay_delete'), + path('module-bays//changelog/', ObjectChangeLogView.as_view(), name='modulebay_changelog', kwargs={'model': ModuleBay}), + path('devices/module-bays/add/', views.DeviceBulkAddModuleBayView.as_view(), name='device_bulk_add_modulebay'), + # Device bays path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'), path('device-bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4ec31e60c..fed6ea31d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -30,9 +30,9 @@ from .constants import NONCONNECTABLE_IFACE_TYPES from .models import ( Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, - PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - SiteGroup, VirtualChassis, + InventoryItem, Manufacturer, ModuleBay, ModuleBayTemplate, PathEndpoint, Platform, PowerFeed, PowerOutlet, + PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, + RearPortTemplate, Region, Site, SiteGroup, VirtualChassis, ) @@ -836,6 +836,12 @@ class DeviceTypeRearPortsView(DeviceTypeComponentsView): filterset = filtersets.RearPortTemplateFilterSet +class DeviceTypeModuleBaysView(DeviceTypeComponentsView): + child_model = ModuleBayTemplate + table = tables.ModuleBayTemplateTable + filterset = filtersets.ModuleBayTemplateFilterSet + + class DeviceTypeDeviceBaysView(DeviceTypeComponentsView): child_model = DeviceBayTemplate table = tables.DeviceBayTemplateTable @@ -861,6 +867,7 @@ class DeviceTypeImportView(generic.ObjectImportView): 'dcim.add_interfacetemplate', 'dcim.add_frontporttemplate', 'dcim.add_rearporttemplate', + 'dcim.add_modulebaytemplate', 'dcim.add_devicebaytemplate', ] queryset = DeviceType.objects.all() @@ -873,6 +880,7 @@ class DeviceTypeImportView(generic.ObjectImportView): ('interfaces', forms.InterfaceTemplateImportForm), ('rear-ports', forms.RearPortTemplateImportForm), ('front-ports', forms.FrontPortTemplateImportForm), + ('module-bays', forms.ModuleBayTemplateImportForm), ('device-bays', forms.DeviceBayTemplateImportForm), )) @@ -1132,6 +1140,40 @@ class RearPortTemplateBulkDeleteView(generic.BulkDeleteView): table = tables.RearPortTemplateTable +# +# Module bay templates +# + +class ModuleBayTemplateCreateView(generic.ComponentCreateView): + queryset = ModuleBayTemplate.objects.all() + form = forms.ModuleBayTemplateCreateForm + model_form = forms.ModuleBayTemplateForm + + +class ModuleBayTemplateEditView(generic.ObjectEditView): + queryset = ModuleBayTemplate.objects.all() + model_form = forms.ModuleBayTemplateForm + + +class ModuleBayTemplateDeleteView(generic.ObjectDeleteView): + queryset = ModuleBayTemplate.objects.all() + + +class ModuleBayTemplateBulkEditView(generic.BulkEditView): + queryset = ModuleBayTemplate.objects.all() + table = tables.ModuleBayTemplateTable + form = forms.ModuleBayTemplateBulkEditForm + + +class ModuleBayTemplateBulkRenameView(generic.BulkRenameView): + queryset = ModuleBayTemplate.objects.all() + + +class ModuleBayTemplateBulkDeleteView(generic.BulkDeleteView): + queryset = ModuleBayTemplate.objects.all() + table = tables.ModuleBayTemplateTable + + # # Device bay templates # @@ -1388,6 +1430,13 @@ class DeviceRearPortsView(DeviceComponentsView): template_name = 'dcim/device/rearports.html' +class DeviceModuleBaysView(DeviceComponentsView): + child_model = ModuleBay + table = tables.DeviceModuleBayTable + filterset = filtersets.ModuleBayFilterSet + template_name = 'dcim/device/modulebays.html' + + class DeviceDeviceBaysView(DeviceComponentsView): child_model = DeviceBay table = tables.DeviceDeviceBayTable @@ -1978,6 +2027,61 @@ class RearPortBulkDeleteView(generic.BulkDeleteView): table = tables.RearPortTable +# +# Module bays +# + +class ModuleBayListView(generic.ObjectListView): + queryset = ModuleBay.objects.all() + filterset = filtersets.ModuleBayFilterSet + filterset_form = forms.ModuleBayFilterForm + table = tables.ModuleBayTable + action_buttons = ('import', 'export') + + +class ModuleBayView(generic.ObjectView): + queryset = ModuleBay.objects.all() + + +class ModuleBayCreateView(generic.ComponentCreateView): + queryset = ModuleBay.objects.all() + form = forms.ModuleBayCreateForm + model_form = forms.ModuleBayForm + + +class ModuleBayEditView(generic.ObjectEditView): + queryset = ModuleBay.objects.all() + model_form = forms.ModuleBayForm + template_name = 'dcim/device_component_edit.html' + + +class ModuleBayDeleteView(generic.ObjectDeleteView): + queryset = ModuleBay.objects.all() + + +class ModuleBayBulkImportView(generic.BulkImportView): + queryset = ModuleBay.objects.all() + model_form = forms.ModuleBayCSVForm + table = tables.ModuleBayTable + + +class ModuleBayBulkEditView(generic.BulkEditView): + queryset = ModuleBay.objects.all() + filterset = filtersets.ModuleBayFilterSet + table = tables.ModuleBayTable + form = forms.ModuleBayBulkEditForm + + +class ModuleBayBulkRenameView(generic.BulkRenameView): + queryset = ModuleBay.objects.all() + + +class ModuleBayBulkDeleteView(generic.BulkDeleteView): + queryset = ModuleBay.objects.all() + filterset = filtersets.ModuleBayFilterSet + table = tables.ModuleBayTable + + # # Device bays # @@ -2234,6 +2338,17 @@ class DeviceBulkAddRearPortView(generic.BulkComponentCreateView): default_return_url = 'dcim:device_list' +class DeviceBulkAddModuleBayView(generic.BulkComponentCreateView): + parent_model = Device + parent_field = 'device' + form = forms.ModuleBayBulkCreateForm + queryset = ModuleBay.objects.all() + model_form = forms.ModuleBayForm + filterset = filtersets.DeviceFilterSet + table = tables.DeviceTable + default_return_url = 'dcim:device_list' + + class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView): parent_model = Device parent_field = 'device' diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 488fa163d..71be861f8 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -161,6 +161,7 @@ DEVICES_MENU = Menu( get_model_item('dcim', 'consoleserverport', 'Console Server Ports', actions=['import']), get_model_item('dcim', 'powerport', 'Power Ports', actions=['import']), get_model_item('dcim', 'poweroutlet', 'Power Outlets', actions=['import']), + get_model_item('dcim', 'modulebay', 'Module Bays', actions=['import']), get_model_item('dcim', 'devicebay', 'Device Bays', actions=['import']), get_model_item('dcim', 'inventoryitem', 'Inventory Items', actions=['import']), ), diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index 13d4bbcbc..80ccb69a2 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -69,6 +69,13 @@ {% endif %} + {% if perms.dcim.add_devicebay %} +
  • + + Module Bays + +
  • + {% endif %} {% if perms.dcim.add_devicebay %}
  • @@ -151,6 +158,14 @@ {% endif %} {% endwith %} + {% with modulebay_count=object.modulebays.count %} + {% if modulebay_count %} +
  • + {% endif %} + {% endwith %} + {% with devicebay_count=object.devicebays.count %} {% if devicebay_count %} {% endif %} + {% if perms.dcim.add_modulebay %} +
  • + +
  • + {% endif %} {% if perms.dcim.add_inventoryitem %}