From e529d7fd3b721c3cf5b2e5c2c61c4df45aa474d5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Dec 2021 09:35:57 -0500 Subject: [PATCH 1/9] Add ModuleBay and ModuleBayTemplate models --- netbox/dcim/api/nested_serializers.py | 19 +++ netbox/dcim/api/serializers.py | 22 +++ netbox/dcim/api/urls.py | 2 + netbox/dcim/api/views.py | 17 +- netbox/dcim/filtersets.py | 30 ++++ netbox/dcim/forms/bulk_create.py | 6 + netbox/dcim/forms/bulk_edit.py | 34 +++- netbox/dcim/forms/bulk_import.py | 12 ++ netbox/dcim/forms/filtersets.py | 11 ++ netbox/dcim/forms/models.py | 29 ++++ netbox/dcim/forms/object_create.py | 11 ++ netbox/dcim/forms/object_import.py | 10 ++ netbox/dcim/graphql/schema.py | 6 + netbox/dcim/graphql/types.py | 18 ++ netbox/dcim/migrations/0145_modules.py | 53 ++++++ netbox/dcim/models/__init__.py | 2 + .../dcim/models/device_component_templates.py | 20 ++- netbox/dcim/models/device_components.py | 30 ++-- netbox/dcim/models/devices.py | 3 + netbox/dcim/tables/devices.py | 35 +++- netbox/dcim/tables/devicetypes.py | 16 +- netbox/dcim/tests/test_api.py | 79 +++++++++ netbox/dcim/tests/test_filtersets.py | 155 ++++++++++++++++++ netbox/dcim/tests/test_models.py | 10 ++ netbox/dcim/tests/test_views.py | 109 ++++++++++++ netbox/dcim/urls.py | 23 +++ netbox/dcim/views.py | 121 +++++++++++++- netbox/netbox/navigation_menu.py | 1 + netbox/templates/dcim/device/base.html | 15 ++ netbox/templates/dcim/device/modulebays.html | 43 +++++ netbox/templates/dcim/device_list.html | 7 + netbox/templates/dcim/devicetype/base.html | 11 ++ netbox/templates/dcim/modulebay.html | 69 ++++++++ 33 files changed, 1008 insertions(+), 21 deletions(-) create mode 100644 netbox/dcim/migrations/0145_modules.py create mode 100644 netbox/templates/dcim/device/modulebays.html create mode 100644 netbox/templates/dcim/modulebay.html 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 %}
  • + + + {% endif %} +{% endblock %} + +{% block tab_items %} +
  • + + {% with interface_count=object.interfacetemplates.count %} + {% if interface_count %} + + {% endif %} + {% endwith %} + + {% with frontport_count=object.frontporttemplates.count %} + {% if frontport_count %} + + {% endif %} + {% endwith %} + + {% with rearport_count=object.rearporttemplates.count %} + {% if rearport_count %} + + {% endif %} + {% endwith %} + + {% with consoleport_count=object.consoleporttemplates.count %} + {% if consoleport_count %} + + {% endif %} + {% endwith %} + + {% with consoleserverport_count=object.consoleserverporttemplates.count %} + {% if consoleserverport_count %} + + {% endif %} + {% endwith %} + + {% with powerport_count=object.powerporttemplates.count %} + {% if powerport_count %} + + {% endif %} + {% endwith %} + + {% with poweroutlet_count=object.poweroutlettemplates.count %} + {% if poweroutlet_count %} + + {% endif %} + {% endwith %} +{% endblock %} diff --git a/netbox/templates/dcim/moduletype/component_templates.html b/netbox/templates/dcim/moduletype/component_templates.html new file mode 100644 index 000000000..9930588b8 --- /dev/null +++ b/netbox/templates/dcim/moduletype/component_templates.html @@ -0,0 +1,44 @@ +{% extends 'dcim/moduletype/base.html' %} +{% load render_table from django_tables2 %} +{% load helpers %} + +{% block content %} + {% if perms.dcim.change_moduletype %} +
    + {% csrf_token %} +
    +
    {{ title }}
    +
    + {% include 'htmx/table.html' %} +
    + +
    +
    + {% else %} +
    +
    {{ title }}
    +
    + {% include 'htmx/table.html' %} +
    +
    + {% endif %} +{% endblock content %} From 5bd223a4683bb1ea72cffeb5e7c2ed7b13f65d70 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Dec 2021 13:28:17 -0500 Subject: [PATCH 3/9] Fix YAML import for ModuleTypes --- netbox/dcim/forms/object_import.py | 42 ++++++++++----------- netbox/dcim/views.py | 8 ++++ netbox/netbox/views/generic/object_views.py | 11 +++++- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 9df029386..cc0c7dc41 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -50,23 +50,21 @@ class ModuleTypeImportForm(BootstrapMixin, forms.ModelForm): class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm): - def __init__(self, device_type, data=None, *args, **kwargs): - - # Must pass the parent DeviceType on form initialization - data.update({ - 'device_type': device_type.pk, - }) - - super().__init__(data, *args, **kwargs) - def clean_device_type(self): - - data = self.cleaned_data['device_type'] - # Limit fields referencing other components to the parent DeviceType - for field_name, field in self.fields.items(): - if isinstance(field, forms.ModelChoiceField) and field_name != 'device_type': - field.queryset = field.queryset.filter(device_type=data) + if data := self.cleaned_data['device_type']: + for field_name, field in self.fields.items(): + if isinstance(field, forms.ModelChoiceField) and field_name not in ['device_type', 'module_type']: + field.queryset = field.queryset.filter(device_type=data) + + return data + + def clean_module_type(self): + # Limit fields referencing other components to the parent ModuleType + if data := self.cleaned_data['module_type']: + for field_name, field in self.fields.items(): + if isinstance(field, forms.ModelChoiceField) and field_name not in ['device_type', 'module_type']: + field.queryset = field.queryset.filter(module_type=data) return data @@ -76,7 +74,7 @@ class ConsolePortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = ConsolePortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] @@ -85,7 +83,7 @@ class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = ConsoleServerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] @@ -94,7 +92,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = PowerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', ] @@ -108,7 +106,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm): class Meta: model = PowerOutletTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', ] @@ -120,7 +118,7 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', ] @@ -136,7 +134,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = FrontPortTemplate fields = [ - 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description', + 'device_type', 'module_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description', ] @@ -148,7 +146,7 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = RearPortTemplate fields = [ - 'device_type', 'name', 'type', 'positions', 'label', 'description', + 'device_type', 'module_type', 'name', 'type', 'positions', 'label', 'description', ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 15e7d2406..f673e64d5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -892,6 +892,10 @@ class DeviceTypeImportView(generic.ObjectImportView): ('device-bays', forms.DeviceBayTemplateImportForm), )) + def prep_related_object_data(self, parent, data): + data.update({'device_type': parent}) + return data + class DeviceTypeBulkEditView(generic.BulkEditView): queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( @@ -1009,6 +1013,10 @@ class ModuleTypeImportView(generic.ObjectImportView): ('front-ports', forms.FrontPortTemplateImportForm), )) + def prep_related_object_data(self, parent, data): + data.update({'module_type': parent}) + return data + class ModuleTypeBulkEditView(generic.BulkEditView): queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 588b51062..eda3658a6 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -319,6 +319,13 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') + def prep_related_object_data(self, parent, data): + """ + Hook to modify the data for related objects before it's passed to the related object form (for example, to + assign a parent object). + """ + return data + def _create_object(self, model_form): # Save the primary object @@ -333,8 +340,8 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): related_obj_pks = [] for i, rel_obj_data in enumerate(model_form.data.get(field_name, list())): - - f = related_object_form(obj, rel_obj_data) + rel_obj_data = self.prep_related_object_data(obj, rel_obj_data) + f = related_object_form(rel_obj_data) for subfield_name, field in f.fields.items(): if subfield_name not in rel_obj_data and hasattr(field, 'initial'): From 7777922bef32663f0817f11fbf1e4ec3676b10c3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Dec 2021 16:12:03 -0500 Subject: [PATCH 4/9] Add Module model --- netbox/dcim/api/nested_serializers.py | 19 ++- netbox/dcim/api/serializers.py | 14 ++ netbox/dcim/api/urls.py | 3 +- netbox/dcim/api/views.py | 10 +- netbox/dcim/filtersets.py | 37 +++++ netbox/dcim/forms/bulk_edit.py | 27 +++ netbox/dcim/forms/bulk_import.py | 30 ++++ netbox/dcim/forms/filtersets.py | 33 ++++ netbox/dcim/forms/models.py | 41 +++++ netbox/dcim/graphql/schema.py | 3 + netbox/dcim/graphql/types.py | 9 + netbox/dcim/migrations/0145_modules.py | 95 ++++++++--- netbox/dcim/models/__init__.py | 1 + .../dcim/models/device_component_templates.py | 46 +++--- netbox/dcim/models/device_components.py | 27 ++- netbox/dcim/models/devices.py | 98 ++++++++++- netbox/dcim/tables/__init__.py | 2 +- netbox/dcim/tables/devices.py | 17 +- netbox/dcim/tables/modules.py | 61 +++++++ netbox/dcim/tables/moduletypes.py | 34 ---- netbox/dcim/tables/template_code.py | 14 ++ netbox/dcim/tests/test_api.py | 63 ++++++- netbox/dcim/tests/test_filtersets.py | 75 ++++++++- netbox/dcim/tests/test_views.py | 69 ++++++++ netbox/dcim/urls.py | 16 +- netbox/dcim/views.py | 55 +++++-- netbox/netbox/navigation_menu.py | 1 + netbox/templates/dcim/device/base.html | 32 ++-- netbox/templates/dcim/devicetype/base.html | 32 ++-- netbox/templates/dcim/module.html | 154 ++++++++++++++++++ 30 files changed, 967 insertions(+), 151 deletions(-) create mode 100644 netbox/dcim/tables/modules.py delete mode 100644 netbox/dcim/tables/moduletypes.py create mode 100644 netbox/templates/dcim/module.html diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index a6e359feb..6ed7c63c6 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -22,6 +22,7 @@ __all__ = [ 'NestedManufacturerSerializer', 'NestedModuleBaySerializer', 'NestedModuleBayTemplateSerializer', + 'NestedModuleSerializer', 'NestedModuleTypeSerializer', 'NestedPlatformSerializer', 'NestedPowerFeedSerializer', @@ -260,6 +261,18 @@ class NestedDeviceSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class NestedModuleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + device = NestedDeviceSerializer(read_only=True) + # TODO: Solve circular dependency + # module_bay = NestedModuleBaySerializer(read_only=True) + module_type = NestedModuleTypeSerializer(read_only=True) + + class Meta: + model = models.Module + fields = ['id', 'url', 'display', 'device', 'module_bay', 'module_type'] + + class NestedConsoleServerPortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer(read_only=True) @@ -325,11 +338,11 @@ class NestedFrontPortSerializer(WritableNestedSerializer): class NestedModuleBaySerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') - # module = NestedModuleSerializer(read_only=True) + module = NestedModuleSerializer(read_only=True) class Meta: - model = models.DeviceBay - fields = ['id', 'url', 'display', 'name'] + model = models.ModuleBay + fields = ['id', 'url', 'display', 'module', 'name'] class NestedDeviceBaySerializer(WritableNestedSerializer): diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index c3d6b5cb4..b58355f32 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -517,6 +517,20 @@ class DeviceSerializer(PrimaryModelSerializer): return data +class ModuleSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + device = NestedDeviceSerializer() + module_bay = NestedModuleBaySerializer() + module_type = NestedModuleTypeSerializer() + + class Meta: + model = Module + fields = [ + 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + + class DeviceWithConfigContextSerializer(DeviceSerializer): config_context = serializers.SerializerMethodField() diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 7a866063f..71a768fd5 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -32,10 +32,11 @@ router.register('rear-port-templates', views.RearPortTemplateViewSet) router.register('module-bay-templates', views.ModuleBayTemplateViewSet) router.register('device-bay-templates', views.DeviceBayTemplateViewSet) -# Devices +# Device/modules router.register('device-roles', views.DeviceRoleViewSet) router.register('platforms', views.PlatformViewSet) router.register('devices', views.DeviceViewSet) +router.register('modules', views.ModuleViewSet) # Device components router.register('console-ports', views.ConsolePortViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e50f9b1b6..378e697c8 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -377,7 +377,7 @@ class PlatformViewSet(CustomFieldModelViewSet): # -# Devices +# Devices/modules # class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): @@ -526,6 +526,14 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): return Response(response) +class ModuleViewSet(CustomFieldModelViewSet): + queryset = Module.objects.prefetch_related( + 'device', 'module_bay', 'module_type__manufacturer', 'tags', + ) + serializer_class = serializers.ModuleSerializer + filterset_class = filtersets.ModuleFilterSet + + # # Device components # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index b0ff992a7..d91a9b574 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -43,6 +43,7 @@ __all__ = ( 'ManufacturerFilterSet', 'ModuleBayFilterSet', 'ModuleBayTemplateFilterSet', + 'ModuleFilterSet', 'ModuleTypeFilterSet', 'PathEndpointFilterSet', 'PlatformFilterSet', @@ -924,6 +925,42 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex return queryset.exclude(devicebays__isnull=value) +class ModuleFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + field_name='module_type__manufacturer', + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + field_name='module_type__manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label='Device (ID)', + ) + tag = TagFilter() + + class Meta: + model = Module + fields = ['id', 'serial', 'asset_tag'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(serial__icontains=value.strip()) | + Q(asset_tag__icontains=value.strip()) | + Q(comments__icontains=value) + ).distinct() + + class DeviceComponentFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 360fb81cb..378620180 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -32,6 +32,7 @@ __all__ = ( 'InventoryItemBulkEditForm', 'LocationBulkEditForm', 'ManufacturerBulkEditForm', + 'ModuleBulkEditForm', 'ModuleBayBulkEditForm', 'ModuleBayTemplateBulkEditForm', 'ModuleTypeBulkEditForm', @@ -473,6 +474,32 @@ class DeviceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ] +class ModuleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Module.objects.all(), + widget=forms.MultipleHiddenInput() + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + required=False, + query_params={ + 'manufacturer_id': '$manufacturer' + } + ) + serial = forms.CharField( + max_length=50, + required=False, + label='Serial Number' + ) + + class Meta: + nullable_fields = ['serial'] + + class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Cable.objects.all(), diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 6092b3d41..8f5ba25b7 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -26,6 +26,7 @@ __all__ = ( 'InventoryItemCSVForm', 'LocationCSVForm', 'ManufacturerCSVForm', + 'ModuleCSVForm', 'ModuleBayCSVForm', 'PlatformCSVForm', 'PowerFeedCSVForm', @@ -400,6 +401,35 @@ class DeviceCSVForm(BaseDeviceCSVForm): self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) +class ModuleCSVForm(CustomFieldModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + module_bay = CSVModelChoiceField( + queryset=ModuleBay.objects.all(), + to_field_name='name' + ) + module_type = CSVModelChoiceField( + queryset=ModuleType.objects.all(), + to_field_name='model' + ) + + class Meta: + model = Module + fields = ( + 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', + ) + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + # Limit module_bay queryset by assigned device + params = {f"device__{self.fields['device'].to_field_name}": data.get('device')} + self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params) + + class ChildDeviceCSVForm(BaseDeviceCSVForm): parent = CSVModelChoiceField( queryset=Device.objects.all(), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 5e8b333b9..29c09c7f7 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -29,6 +29,8 @@ __all__ = ( 'InventoryItemFilterForm', 'LocationFilterForm', 'ManufacturerFilterForm', + 'ModuleFilterForm', + 'ModuleFilterForm', 'ModuleBayFilterForm', 'ModuleTypeFilterForm', 'PlatformFilterForm', @@ -645,6 +647,37 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi tag = TagFilterField(model) +class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): + model = Module + field_groups = [ + ['q', 'tag'], + ['manufacturer_id', 'module_type_id'], + ['serial', 'asset_tag'], + ] + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer'), + fetch_trigger='open' + ) + module_type_id = DynamicModelMultipleChoiceField( + queryset=ModuleType.objects.all(), + required=False, + query_params={ + 'manufacturer_id': '$manufacturer_id' + }, + label=_('Type'), + fetch_trigger='open' + ) + serial = forms.CharField( + required=False + ) + asset_tag = forms.CharField( + required=False + ) + tag = TagFilterField(model) + + class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = VirtualChassis field_groups = [ diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index ae3cfeaef..672c54c68 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -39,6 +39,7 @@ __all__ = ( 'InventoryItemForm', 'LocationForm', 'ManufacturerForm', + 'ModuleForm', 'ModuleBayForm', 'ModuleBayTemplateForm', 'ModuleTypeForm', @@ -651,6 +652,46 @@ class DeviceForm(TenancyForm, CustomFieldModelForm): self.fields['position'].widget.choices = [(position, f'U{position}')] +class ModuleForm(CustomFieldModelForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + initial_params={ + 'modulebays': '$module_bay' + } + ) + module_bay = DynamicModelChoiceField( + queryset=ModuleBay.objects.all(), + query_params={ + 'device_id': '$device' + } + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + initial_params={ + 'device_types': '$device_type' + } + ) + module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + query_params={ + 'manufacturer_id': '$manufacturer' + } + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Module + fields = [ + 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags', 'comments', + ] + + class CableForm(TenancyForm, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index d50c64d33..7f660b192 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -56,6 +56,9 @@ class DCIMQuery(graphene.ObjectType): manufacturer = ObjectField(ManufacturerType) manufacturer_list = ObjectListField(ManufacturerType) + module = ObjectField(ModuleType) + module_list = ObjectListField(ModuleType) + module_bay = ObjectField(ModuleBayType) module_bay_list = ObjectListField(ModuleBayType) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index c1a8822d8..51e196076 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -27,6 +27,7 @@ __all__ = ( 'InventoryItemType', 'LocationType', 'ManufacturerType', + 'ModuleType', 'ModuleBayType', 'ModuleBayTemplateType', 'ModuleTypeType', @@ -257,6 +258,14 @@ class ManufacturerType(OrganizationalObjectType): filterset_class = filtersets.ManufacturerFilterSet +class ModuleType(ComponentObjectType): + + class Meta: + model = models.Module + fields = '__all__' + filterset_class = filtersets.ModuleFilterSet + + class ModuleBayType(ComponentObjectType): class Meta: diff --git a/netbox/dcim/migrations/0145_modules.py b/netbox/dcim/migrations/0145_modules.py index b9cb7bcc5..c9a332846 100644 --- a/netbox/dcim/migrations/0145_modules.py +++ b/netbox/dcim/migrations/0145_modules.py @@ -95,36 +95,110 @@ class Migration(migrations.Migration): 'unique_together': {('manufacturer', 'model')}, }, ), + 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')}, + }, + ), + migrations.CreateModel( + name='Module', + 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)), + ('local_context_data', models.JSONField(blank=True, null=True)), + ('serial', models.CharField(blank=True, max_length=50)), + ('asset_tag', models.CharField(blank=True, max_length=50, null=True, unique=True)), + ('comments', models.TextField(blank=True)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='dcim.device')), + ('module_bay', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='installed_module', to='dcim.modulebay')), + ('module_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='dcim.moduletype')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('module_bay',), + }, + ), + migrations.AddField( + model_name='consoleport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleports', to='dcim.module'), + ), migrations.AddField( model_name='consoleporttemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleporttemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='consoleserverport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverports', to='dcim.module'), + ), migrations.AddField( model_name='consoleserverporttemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverporttemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='frontport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='frontports', to='dcim.module'), + ), migrations.AddField( model_name='frontporttemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='frontporttemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='interface', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.module'), + ), migrations.AddField( model_name='interfacetemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfacetemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='poweroutlet', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlets', to='dcim.module'), + ), migrations.AddField( model_name='poweroutlettemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlettemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='powerport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='powerports', to='dcim.module'), + ), migrations.AddField( model_name='powerporttemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='powerporttemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='rearport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='rearports', to='dcim.module'), + ), migrations.AddField( model_name='rearporttemplate', name='module_type', @@ -140,7 +214,7 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='frontporttemplate', - unique_together={('device_type', 'name'), ('module_type', 'name'), ('rear_port', 'rear_port_position')}, + unique_together={('device_type', 'name'), ('rear_port', 'rear_port_position'), ('module_type', 'name')}, ), migrations.AlterUniqueTogether( name='interfacetemplate', @@ -175,23 +249,4 @@ class Migration(migrations.Migration): '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 a030dc3a8..8d4b1dce6 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -27,6 +27,7 @@ __all__ = ( 'InventoryItem', 'Location', 'Manufacturer', + 'Module', 'ModuleBay', 'ModuleBayTemplate', 'ModuleType', diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index d522d543a..a22118de0 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -142,12 +142,12 @@ class ConsolePortTemplate(ModularComponentTemplateModel): ('module_type', 'name'), ) - def instantiate(self, device): + def instantiate(self, **kwargs): return ConsolePort( - device=device, name=self.name, label=self.label, - type=self.type + type=self.type, + **kwargs ) @@ -169,12 +169,12 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): ('module_type', 'name'), ) - def instantiate(self, device): + def instantiate(self, **kwargs): return ConsoleServerPort( - device=device, name=self.name, label=self.label, - type=self.type + type=self.type, + **kwargs ) @@ -208,14 +208,14 @@ class PowerPortTemplate(ModularComponentTemplateModel): ('module_type', 'name'), ) - def instantiate(self, device): + def instantiate(self, **kwargs): return PowerPort( - device=device, name=self.name, label=self.label, type=self.type, maximum_draw=self.maximum_draw, - allocated_draw=self.allocated_draw + allocated_draw=self.allocated_draw, + **kwargs ) def clean(self): @@ -273,18 +273,18 @@ class PowerOutletTemplate(ModularComponentTemplateModel): f"Parent power port ({self.power_port}) must belong to the same module type" ) - def instantiate(self, device): + def instantiate(self, **kwargs): if self.power_port: - power_port = PowerPort.objects.get(device=device, name=self.power_port.name) + power_port = PowerPort.objects.get(name=self.power_port.name, **kwargs) else: power_port = None return PowerOutlet( - device=device, name=self.name, label=self.label, type=self.type, power_port=power_port, - feed_leg=self.feed_leg + feed_leg=self.feed_leg, + **kwargs ) @@ -316,13 +316,13 @@ class InterfaceTemplate(ModularComponentTemplateModel): ('module_type', 'name'), ) - def instantiate(self, device): + def instantiate(self, **kwargs): return Interface( - device=device, name=self.name, label=self.label, type=self.type, - mgmt_only=self.mgmt_only + mgmt_only=self.mgmt_only, + **kwargs ) @@ -381,19 +381,19 @@ class FrontPortTemplate(ModularComponentTemplateModel): except RearPortTemplate.DoesNotExist: pass - def instantiate(self, device): + def instantiate(self, **kwargs): if self.rear_port: - rear_port = RearPort.objects.get(device=device, name=self.rear_port.name) + rear_port = RearPort.objects.get(name=self.rear_port.name, **kwargs) else: rear_port = None return FrontPort( - device=device, name=self.name, label=self.label, type=self.type, color=self.color, rear_port=rear_port, - rear_port_position=self.rear_port_position + rear_port_position=self.rear_port_position, + **kwargs ) @@ -424,14 +424,14 @@ class RearPortTemplate(ModularComponentTemplateModel): ('module_type', 'name'), ) - def instantiate(self, device): + def instantiate(self, **kwargs): return RearPort( - device=device, name=self.name, label=self.label, type=self.type, color=self.color, - positions=self.positions + positions=self.positions, + **kwargs ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 08e069239..fc80b29c9 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -87,6 +87,19 @@ class ComponentModel(PrimaryModel): return self.device +class ModularComponentModel(ComponentModel): + module = models.ForeignKey( + to='dcim.Module', + on_delete=models.CASCADE, + related_name='%(class)ss', + blank=True, + null=True + ) + + class Meta: + abstract = True + + class LinkTermination(models.Model): """ An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples @@ -234,7 +247,7 @@ class PathEndpoint(models.Model): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ConsolePort(ComponentModel, LinkTermination, PathEndpoint): +class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ @@ -262,7 +275,7 @@ class ConsolePort(ComponentModel, LinkTermination, PathEndpoint): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint): +class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ @@ -294,7 +307,7 @@ class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerPort(ComponentModel, LinkTermination, PathEndpoint): +class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ @@ -387,7 +400,7 @@ class PowerPort(ComponentModel, LinkTermination, PathEndpoint): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerOutlet(ComponentModel, LinkTermination, PathEndpoint): +class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ @@ -502,7 +515,7 @@ class BaseInterface(models.Model): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): +class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. """ @@ -765,7 +778,7 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class FrontPort(ComponentModel, LinkTermination): +class FrontPort(ModularComponentModel, LinkTermination): """ A pass-through port on the front of a Device. """ @@ -819,7 +832,7 @@ class FrontPort(ComponentModel, LinkTermination): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class RearPort(ComponentModel, LinkTermination): +class RearPort(ModularComponentModel, LinkTermination): """ A pass-through port on the rear of a Device. """ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index ab06b7dc5..8d0a7ae19 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -26,6 +26,7 @@ __all__ = ( 'DeviceRole', 'DeviceType', 'Manufacturer', + 'Module', 'ModuleType', 'Platform', 'VirtualChassis', @@ -906,31 +907,31 @@ class Device(PrimaryModel, ConfigContextModel): # If this is a new Device, instantiate all of the related components per the DeviceType definition if is_new: ConsolePort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.consoleporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.consoleporttemplates.all()] ) ConsoleServerPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.consoleserverporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.consoleserverporttemplates.all()] ) PowerPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.powerporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.powerporttemplates.all()] ) PowerOutlet.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.poweroutlettemplates.all()] + [x.instantiate(device=self) for x in self.device_type.poweroutlettemplates.all()] ) Interface.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.interfacetemplates.all()] + [x.instantiate(device=self) for x in self.device_type.interfacetemplates.all()] ) RearPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.rearporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.rearporttemplates.all()] ) FrontPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.frontporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.frontporttemplates.all()] ) ModuleBay.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.modulebaytemplates.all()] + [x.instantiate(device=self) for x in self.device_type.modulebaytemplates.all()] ) DeviceBay.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.devicebaytemplates.all()] + [x.instantiate(device=self) for x in self.device_type.devicebaytemplates.all()] ) # Update Site and Rack assignment for any child Devices @@ -1008,6 +1009,85 @@ class Device(PrimaryModel, ConfigContextModel): return DeviceStatusChoices.colors.get(self.status, 'secondary') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Module(PrimaryModel, ConfigContextModel): + """ + A Module represents a field-installable component within a Device which may itself hold multiple device components + (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. + """ + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='modules' + ) + module_bay = models.OneToOneField( + to='dcim.ModuleBay', + on_delete=models.CASCADE, + related_name='installed_module' + ) + module_type = models.ForeignKey( + to='dcim.ModuleType', + on_delete=models.PROTECT, + related_name='instances' + ) + serial = models.CharField( + max_length=50, + blank=True, + verbose_name='Serial number' + ) + asset_tag = models.CharField( + max_length=50, + blank=True, + null=True, + unique=True, + verbose_name='Asset tag', + help_text='A unique tag used to identify this device' + ) + comments = models.TextField( + blank=True + ) + + clone_fields = ('device', 'module_type') + + class Meta: + ordering = ('module_bay',) + + def __str__(self): + return str(self.module_type) + + def get_absolute_url(self): + return reverse('dcim:module', args=[self.pk]) + + def save(self, *args, **kwargs): + is_new = not bool(self.pk) + + super().save(*args, **kwargs) + + # If this is a new Module, instantiate all its related components per the ModuleType definition + if is_new: + ConsolePort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleporttemplates.all()] + ) + ConsoleServerPort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleserverporttemplates.all()] + ) + PowerPort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.powerporttemplates.all()] + ) + PowerOutlet.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.poweroutlettemplates.all()] + ) + Interface.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.interfacetemplates.all()] + ) + RearPort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.rearporttemplates.all()] + ) + FrontPort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.frontporttemplates.all()] + ) + + # # Virtual chassis # diff --git a/netbox/dcim/tables/__init__.py b/netbox/dcim/tables/__init__.py index 688b8771c..993ae0518 100644 --- a/netbox/dcim/tables/__init__.py +++ b/netbox/dcim/tables/__init__.py @@ -6,7 +6,7 @@ from dcim.models import ConsolePort, Interface, PowerPort from .cables import * from .devices import * from .devicetypes import * -from .moduletypes import * +from .modules import * from .power import * from .racks import * from .sites import * diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index df1d79aa4..f8616b642 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -725,26 +725,31 @@ class ModuleBayTable(DeviceComponentTable): 'args': [Accessor('device_id')], } ) + installed_module = tables.Column( + linkify=True, + verbose_name='Installed module' + ) 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') + fields = ('pk', 'id', 'name', 'device', 'label', 'installed_module', 'description', 'tags') + default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'description') class DeviceModuleBayTable(ModuleBayTable): actions = ButtonsColumn( - model=ModuleBay, - buttons=('edit', 'delete') + model=DeviceBay, + buttons=('edit', 'delete'), + prepend_template=MODULEBAY_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = ModuleBay - fields = ('pk', 'id', 'name', 'label', 'description', 'tags', 'actions') - default_columns = ('pk', 'name', 'label', 'description', 'actions') + fields = ('pk', 'id', 'name', 'label', 'description', 'installed_module', 'tags', 'actions') + default_columns = ('pk', 'name', 'label', 'description', 'installed_module', 'actions') class InventoryItemTable(DeviceComponentTable): diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py new file mode 100644 index 000000000..6d620433a --- /dev/null +++ b/netbox/dcim/tables/modules.py @@ -0,0 +1,61 @@ +import django_tables2 as tables + +from dcim.models import Module, ModuleType +from utilities.tables import BaseTable, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn + +__all__ = ( + 'ModuleTable', + 'ModuleTypeTable', +) + + +class ModuleTypeTable(BaseTable): + pk = ToggleColumn() + model = tables.Column( + linkify=True, + verbose_name='Module Type' + ) + instance_count = LinkedCountColumn( + viewname='dcim:module_list', + url_params={'module_type_id': 'pk'}, + verbose_name='Instances' + ) + comments = MarkdownColumn() + tags = TagColumn( + url_name='dcim:moduletype_list' + ) + + class Meta(BaseTable.Meta): + model = ModuleType + fields = ( + 'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags', + ) + default_columns = ( + 'pk', 'model', 'manufacturer', 'part_number', + ) + + +class ModuleTable(BaseTable): + pk = ToggleColumn() + device = tables.Column( + linkify=True + ) + module_bay = tables.Column( + linkify=True + ) + module_type = tables.Column( + linkify=True + ) + comments = MarkdownColumn() + tags = TagColumn( + url_name='dcim:module_list' + ) + + class Meta(BaseTable.Meta): + model = Module + fields = ( + 'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags', + ) + default_columns = ( + 'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', + ) diff --git a/netbox/dcim/tables/moduletypes.py b/netbox/dcim/tables/moduletypes.py deleted file mode 100644 index 23bf2e965..000000000 --- a/netbox/dcim/tables/moduletypes.py +++ /dev/null @@ -1,34 +0,0 @@ -import django_tables2 as tables - -from dcim.models import ModuleType -from utilities.tables import BaseTable, MarkdownColumn, TagColumn, ToggleColumn - -__all__ = ( - 'ModuleTypeTable', -) - - -class ModuleTypeTable(BaseTable): - pk = ToggleColumn() - model = tables.Column( - linkify=True, - verbose_name='Device Type' - ) - # instance_count = LinkedCountColumn( - # viewname='dcim:module_list', - # url_params={'module_type_id': 'pk'}, - # verbose_name='Instances' - # ) - comments = MarkdownColumn() - tags = TagColumn( - url_name='dcim:moduletype_list' - ) - - class Meta(BaseTable.Meta): - model = ModuleType - fields = ( - 'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags', - ) - default_columns = ( - 'pk', 'model', 'manufacturer', 'part_number', - ) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index ccca32be8..6b44c4b3f 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -321,3 +321,17 @@ DEVICEBAY_BUTTONS = """ {% endif %} {% endif %} """ + +MODULEBAY_BUTTONS = """ +{% if perms.dcim.add_module %} + {% if record.installed_module %} + + + + {% else %} + + + + {% endif %} +{% endif %} +""" diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 597b6d50b..3b6410c8c 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -7,7 +7,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import ASN, RIR, VLAN -from utilities.testing import APITestCase, APIViewTestCases +from utilities.testing import APITestCase, APIViewTestCases, create_test_device from virtualization.models import Cluster, ClusterType from wireless.models import WirelessLAN @@ -1105,6 +1105,67 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) +class ModuleTest(APIViewTestCases.APIViewTestCase): + model = Module + brief_fields = ['device', 'display', 'id', 'module_bay', 'module_type', 'url'] + bulk_update_data = { + 'serial': '1234ABCD', + } + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + device = create_test_device('Test Device 1') + + module_types = ( + ModuleType(manufacturer=manufacturer, model='Module Type 1'), + ModuleType(manufacturer=manufacturer, model='Module Type 2'), + ModuleType(manufacturer=manufacturer, model='Module Type 3'), + ) + ModuleType.objects.bulk_create(module_types) + + module_bays = ( + ModuleBay(device=device, name='Module Bay 1'), + ModuleBay(device=device, name='Module Bay 2'), + ModuleBay(device=device, name='Module Bay 3'), + ModuleBay(device=device, name='Module Bay 4'), + ModuleBay(device=device, name='Module Bay 5'), + ModuleBay(device=device, name='Module Bay 6'), + ) + ModuleBay.objects.bulk_create(module_bays) + + modules = ( + Module(device=device, module_bay=module_bays[0], module_type=module_types[0]), + Module(device=device, module_bay=module_bays[1], module_type=module_types[1]), + Module(device=device, module_bay=module_bays[2], module_type=module_types[2]), + ) + Module.objects.bulk_create(modules) + + cls.create_data = [ + { + 'device': device.pk, + 'module_bay': module_bays[3].pk, + 'module_type': module_types[0].pk, + 'serial': 'ABC123', + 'asset_tag': 'Foo1', + }, + { + 'device': device.pk, + 'module_bay': module_bays[4].pk, + 'module_type': module_types[1].pk, + 'serial': 'DEF456', + 'asset_tag': 'Foo2', + }, + { + 'device': device.pk, + 'module_bay': module_bays[5].pk, + 'module_type': module_types[2].pk, + 'serial': 'GHI789', + 'asset_tag': 'Foo3', + }, + ] + + class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 4fd1286da..8f04fb4d9 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -7,7 +7,7 @@ from dcim.models import * from ipam.models import ASN, IPAddress, RIR from tenancy.models import Tenant, TenantGroup from utilities.choices import ColorChoices -from utilities.testing import ChangeLoggedFilterSetTests +from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices, WirelessRoleChoices @@ -1648,6 +1648,79 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) +class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = Module.objects.all() + filterset = ModuleFilterSet + + @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) + + devices = ( + create_test_device('Test Device 1'), + create_test_device('Test Device 2'), + create_test_device('Test Device 3'), + ) + + module_types = ( + ModuleType(manufacturer=manufacturers[0], model='Module Type 1'), + ModuleType(manufacturer=manufacturers[1], model='Module Type 2'), + ModuleType(manufacturer=manufacturers[2], model='Module Type 3'), + ) + ModuleType.objects.bulk_create(module_types) + + module_bays = ( + ModuleBay(device=devices[0], name='Module Bay 1'), + ModuleBay(device=devices[0], name='Module Bay 2'), + ModuleBay(device=devices[0], name='Module Bay 3'), + ModuleBay(device=devices[1], name='Module Bay 1'), + ModuleBay(device=devices[1], name='Module Bay 2'), + ModuleBay(device=devices[1], name='Module Bay 3'), + ModuleBay(device=devices[2], name='Module Bay 1'), + ModuleBay(device=devices[2], name='Module Bay 2'), + ModuleBay(device=devices[2], name='Module Bay 3'), + ) + ModuleBay.objects.bulk_create(module_bays) + + modules = ( + Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], serial='A', asset_tag='A'), + Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], serial='B', asset_tag='B'), + Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], serial='C', asset_tag='C'), + Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], serial='D', asset_tag='D'), + Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], serial='E', asset_tag='E'), + Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], serial='F', asset_tag='F'), + Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], serial='G', asset_tag='G'), + Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], serial='H', asset_tag='H'), + Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], serial='I', asset_tag='I'), + ) + Module.objects.bulk_create(modules) + + def test_manufacturer(self): + manufacturers = Manufacturer.objects.all()[:2] + params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + + def test_device(self): + device_types = Device.objects.all()[:2] + params = {'device_id': [device_types[0].pk, device_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + + def test_serial(self): + params = {'asset_tag': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_asset_tag(self): + params = {'asset_tag': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConsolePort.objects.all() filterset = ConsolePortFilterSet diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 6094fe739..12216a8ac 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1697,6 +1697,75 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): self.assertHttpStatus(self.client.get(url), 200) +class ModuleTestCase( + # Module does not support bulk renaming (no name field) or + # bulk creation (need to specify module bays) + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.GetObjectChangelogViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, +): + model = Module + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + devices = ( + create_test_device('Device 1'), + create_test_device('Device 2'), + ) + + module_types = ( + ModuleType(manufacturer=manufacturer, model='Module Type 1'), + ModuleType(manufacturer=manufacturer, model='Module Type 2'), + ModuleType(manufacturer=manufacturer, model='Module Type 3'), + ModuleType(manufacturer=manufacturer, model='Module Type 4'), + ) + ModuleType.objects.bulk_create(module_types) + + module_bays = ( + ModuleBay(device=devices[0], name='Module Bay 1'), + ModuleBay(device=devices[0], name='Module Bay 2'), + ModuleBay(device=devices[0], name='Module Bay 3'), + ModuleBay(device=devices[1], name='Module Bay 1'), + ModuleBay(device=devices[1], name='Module Bay 2'), + ModuleBay(device=devices[1], name='Module Bay 3'), + ) + ModuleBay.objects.bulk_create(module_bays) + + modules = ( + Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0]), + Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1]), + Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2]), + ) + Module.objects.bulk_create(modules) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'device': devices[1].pk, + 'module_bay': module_bays[3].pk, + 'module_type': module_types[0].pk, + 'serial': 'A', + 'tags': [t.pk for t in tags], + } + + cls.bulk_edit_data = { + 'module_type': module_types[3].pk, + } + + cls.csv_data = ( + "device,module_bay,module_type,serial,asset_tag", + "Device 2,Module Bay 1,Module Type 1,A,A", + "Device 2,Module Bay 2,Module Type 2,B,B", + "Device 2,Module Bay 3,Module Type 3,C,C", + ) + + class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsolePort diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index e1c1e200f..8ec30c0cc 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -254,12 +254,24 @@ urlpatterns = [ 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'), - path('devices//changelog/', views.DeviceChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), - path('devices//journal/', views.DeviceJournalView.as_view(), name='device_journal', kwargs={'model': Device}), + path('devices//changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), + path('devices//journal/', ObjectJournalView.as_view(), name='device_journal', kwargs={'model': Device}), path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'), path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'), + # Modules + path('modules/', views.ModuleListView.as_view(), name='module_list'), + path('modules/add/', views.ModuleEditView.as_view(), name='module_add'), + path('modules/import/', views.ModuleBulkImportView.as_view(), name='module_import'), + path('modules/edit/', views.ModuleBulkEditView.as_view(), name='module_bulk_edit'), + path('modules/delete/', views.ModuleBulkDeleteView.as_view(), name='module_bulk_delete'), + path('modules//', views.ModuleView.as_view(), name='module'), + path('modules//edit/', views.ModuleEditView.as_view(), name='module_edit'), + path('modules//delete/', views.ModuleDeleteView.as_view(), name='module_delete'), + path('modules//changelog/', ObjectChangeLogView.as_view(), name='module_changelog', kwargs={'model': Module}), + path('modules//journal/', ObjectJournalView.as_view(), name='module_journal', kwargs={'model': Module}), + # Console ports path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'), path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f673e64d5..3bc264554 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -13,7 +13,7 @@ from django.utils.safestring import mark_safe from django.views.generic import View from circuits.models import Circuit -from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView +from extras.views import ObjectConfigContextView from ipam.models import ASN, IPAddress, Prefix, Service, VLAN from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic @@ -30,7 +30,7 @@ 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, ModuleBay, ModuleBayTemplate, ModuleType, PathEndpoint, Platform, PowerFeed, + InventoryItem, Manufacturer, Module, ModuleBay, ModuleBayTemplate, ModuleType, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, SiteGroup, VirtualChassis, ) @@ -1629,14 +1629,6 @@ class DeviceConfigContextView(ObjectConfigContextView): base_template = 'dcim/device/base.html' -class DeviceChangeLogView(ObjectChangeLogView): - base_template = 'dcim/device/base.html' - - -class DeviceJournalView(ObjectJournalView): - base_template = 'dcim/device/base.html' - - class DeviceEditView(generic.ObjectEditView): queryset = Device.objects.all() model_form = forms.DeviceForm @@ -1685,6 +1677,49 @@ class DeviceBulkDeleteView(generic.BulkDeleteView): table = tables.DeviceTable +# +# Devices +# + +class ModuleListView(generic.ObjectListView): + queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer') + filterset = filtersets.ModuleFilterSet + filterset_form = forms.ModuleFilterForm + table = tables.ModuleTable + + +class ModuleView(generic.ObjectView): + queryset = Module.objects.all() + + +class ModuleEditView(generic.ObjectEditView): + queryset = Module.objects.all() + model_form = forms.ModuleForm + + +class ModuleDeleteView(generic.ObjectDeleteView): + queryset = Module.objects.all() + + +class ModuleBulkImportView(generic.BulkImportView): + queryset = Module.objects.all() + model_form = forms.ModuleCSVForm + table = tables.ModuleTable + + +class ModuleBulkEditView(generic.BulkEditView): + queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer') + filterset = filtersets.ModuleFilterSet + table = tables.ModuleTable + form = forms.ModuleBulkEditForm + + +class ModuleBulkDeleteView(generic.BulkDeleteView): + queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer') + filterset = filtersets.ModuleFilterSet + table = tables.ModuleTable + + # # Console ports # diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index a2bec4710..52359dcc6 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -139,6 +139,7 @@ DEVICES_MENU = Menu( label='Devices', items=( get_model_item('dcim', 'device', 'Devices'), + get_model_item('dcim', 'module', 'Modules'), get_model_item('dcim', 'devicerole', 'Device Roles'), get_model_item('dcim', 'platform', 'Platforms'), get_model_item('dcim', 'virtualchassis', 'Virtual Chassis'), diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index 80ccb69a2..d9ff0657c 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -102,6 +102,22 @@ + {% with devicebay_count=object.devicebays.count %} + {% if devicebay_count %} + + {% endif %} + {% endwith %} + + {% with modulebay_count=object.modulebays.count %} + {% if modulebay_count %} + + {% endif %} + {% endwith %} + {% with interface_count=object.interfaces_count %} {% if interface_count %} - {% endif %} - {% endwith %} - - {% with devicebay_count=object.devicebays.count %} - {% if devicebay_count %} - - {% endif %} - {% endwith %} - {% with inventoryitem_count=object.inventoryitems.count %} {% if inventoryitem_count %} + {% with devicebay_count=object.devicebaytemplates.count %} + {% if devicebay_count %} + + {% endif %} + {% endwith %} + + {% with modulebay_count=object.modulebaytemplates.count %} + {% if modulebay_count %} + + {% endif %} + {% endwith %} + {% with interface_count=object.interfacetemplates.count %} {% if interface_count %} {% endif %} {% endwith %} - - {% with modulebay_count=object.modulebaytemplates.count %} - {% if modulebay_count %} - - {% endif %} - {% endwith %} - - {% with devicebay_count=object.devicebaytemplates.count %} - {% if devicebay_count %} - - {% endif %} - {% endwith %} {% endblock %} diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html new file mode 100644 index 000000000..8410b9556 --- /dev/null +++ b/netbox/templates/dcim/module.html @@ -0,0 +1,154 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load tz %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
    +
    +
    +
    Module
    +
    + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ object.device }} +
    Device Type + {{ object.device.device_type }} +
    Module Type + {{ object.module_type }} +
    Serial Number{{ object.serial|placeholder }}
    Asset Tag{{ object.asset_tag|placeholder }}
    +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
    +
    +
    +
    Components
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Interfaces + {% with component_count=object.interfaces.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
    Console Ports + {% with component_count=object.consoleports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
    Console Server Ports + {% with component_count=object.consoleserverports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
    Power Ports + {% with component_count=object.powerports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
    Power Outlets + {% with component_count=object.poweroutlets.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
    Front Ports + {% with component_count=object.frontports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
    Rear Ports + {% with component_count=object.rearports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
    +
    +
    + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} From e0d7511eaa963eb0298ece41d1c431e092b20d65 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Dec 2021 16:27:03 -0500 Subject: [PATCH 5/9] Misc cleanup --- netbox/dcim/views.py | 2 +- netbox/templates/dcim/modulebay.html | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3bc264554..7ce67ab97 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2200,7 +2200,7 @@ class RearPortBulkDeleteView(generic.BulkDeleteView): # class ModuleBayListView(generic.ObjectListView): - queryset = ModuleBay.objects.all() + queryset = ModuleBay.objects.select_related('installed_module__module_type') filterset = filtersets.ModuleBayFilterSet filterset_form = forms.ModuleBayFilterForm table = tables.ModuleBayTable diff --git a/netbox/templates/dcim/modulebay.html b/netbox/templates/dcim/modulebay.html index 3dfcc68b9..0f903483a 100644 --- a/netbox/templates/dcim/modulebay.html +++ b/netbox/templates/dcim/modulebay.html @@ -12,7 +12,7 @@ Device - {{ object.device }} + {{ object.device }} @@ -30,26 +30,28 @@ - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %}
    + {% include 'inc/panels/custom_fields.html' %}
    Installed Module
    - {% if object.module %} - {% with module=object.module %} + {% if object.installed_module %} + {% with module=object.installed_module %} - + - +
    ModuleManufacturer - {{ module }} + {{ module.module_type.manufacturer }}
    Module Type{{ module.module_type }} + {{ module.module_type }} +
    {% endwith %} From 7dc4e00b4d0f27e089f4b24ef780b7501510ccf3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Dec 2021 20:15:49 -0500 Subject: [PATCH 6/9] Add module, module_bay columns to device component tables --- netbox/dcim/tables/devices.py | 92 ++++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 39 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index f8616b642..08f229d33 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -257,6 +257,19 @@ class DeviceComponentTable(BaseTable): order_by = ('device', 'name') +class ModularDeviceComponentTable(DeviceComponentTable): + module_bay = tables.Column( + accessor=Accessor('module__module_bay'), + linkify={ + 'viewname': 'dcim:device_modulebays', + 'args': [Accessor('device_id')], + } + ) + module = tables.Column( + linkify=True + ) + + class CableTerminationTable(BaseTable): cable = tables.Column( linkify=True @@ -284,7 +297,7 @@ class PathEndpointTable(CableTerminationTable): ) -class ConsolePortTable(DeviceComponentTable, PathEndpointTable): +class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_consoleports', @@ -298,8 +311,8 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'link_peer', 'connection', 'tags', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -319,8 +332,8 @@ class DeviceConsolePortTable(ConsolePortTable): class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'link_peer', 'connection', 'tags', 'actions' + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', + 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions' ) default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { @@ -328,7 +341,7 @@ class DeviceConsolePortTable(ConsolePortTable): } -class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): +class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_consoleserverports', @@ -342,8 +355,8 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', - 'cable_color', 'link_peer', 'connection', 'tags', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -364,8 +377,8 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'link_peer', 'connection', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', + 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { @@ -373,7 +386,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): } -class PowerPortTable(DeviceComponentTable, PathEndpointTable): +class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_powerports', @@ -387,8 +400,8 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = PowerPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', - 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected', + 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') @@ -409,8 +422,8 @@ class DevicePowerPortTable(PowerPortTable): class Meta(DeviceComponentTable.Meta): model = PowerPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', - 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw', + 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', @@ -421,7 +434,7 @@ class DevicePowerPortTable(PowerPortTable): } -class PowerOutletTable(DeviceComponentTable, PathEndpointTable): +class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_poweroutlets', @@ -438,8 +451,8 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = PowerOutlet fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', - 'cable', 'cable_color', 'link_peer', 'connection', 'tags', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', + 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -459,8 +472,8 @@ class DevicePowerOutletTable(PowerOutletTable): class Meta(DeviceComponentTable.Meta): model = PowerOutlet fields = ( - 'pk', 'id', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', - 'cable_color', 'link_peer', 'connection', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', @@ -491,7 +504,7 @@ class BaseInterfaceTable(BaseTable): ) -class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable): +class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_interfaces', @@ -514,10 +527,10 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'id', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', - 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', - 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', + 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', + 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', + 'link_peer', 'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -549,10 +562,11 @@ class DeviceInterfaceTable(InterfaceTable): class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'id', 'name', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode', - 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', - 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', - 'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', + 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', + 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', + 'tagged_vlans', 'actions', ) order_by = ('name',) default_columns = ( @@ -566,7 +580,7 @@ class DeviceInterfaceTable(InterfaceTable): } -class FrontPortTable(DeviceComponentTable, CableTerminationTable): +class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_frontports', @@ -587,8 +601,8 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable): class Meta(DeviceComponentTable.Meta): model = FrontPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port', + 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', ) default_columns = ( 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', @@ -611,8 +625,8 @@ class DeviceFrontPortTable(FrontPortTable): class Meta(DeviceComponentTable.Meta): model = FrontPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', - 'cable_color', 'link_peer', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position', + 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer', @@ -623,7 +637,7 @@ class DeviceFrontPortTable(FrontPortTable): } -class RearPortTable(DeviceComponentTable, CableTerminationTable): +class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_rearports', @@ -638,8 +652,8 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable): class Meta(DeviceComponentTable.Meta): model = RearPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', - 'cable_color', 'link_peer', 'tags', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description') @@ -660,8 +674,8 @@ class DeviceRearPortTable(RearPortTable): class Meta(DeviceComponentTable.Meta): model = RearPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', - 'link_peer', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected', + 'cable', 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'actions', From ed6a160372fbfd46602accd6b57278625656640d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Dec 2021 20:31:15 -0500 Subject: [PATCH 7/9] Add modules to device component serializers --- netbox/dcim/api/nested_serializers.py | 24 ++++++++- netbox/dcim/api/serializers.py | 70 +++++++++++++++++++-------- netbox/dcim/api/views.py | 26 +++++++--- 3 files changed, 90 insertions(+), 30 deletions(-) diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 6ed7c63c6..9440e5d4b 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -4,6 +4,7 @@ from dcim import models from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer __all__ = [ + 'ComponentNestedModuleSerializer', 'NestedCableSerializer', 'NestedConsolePortSerializer', 'NestedConsolePortTemplateSerializer', @@ -261,11 +262,30 @@ class NestedDeviceSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class ModuleNestedModuleBaySerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') + + class Meta: + model = models.ModuleBay + fields = ['id', 'url', 'display', 'name'] + + +class ComponentNestedModuleSerializer(WritableNestedSerializer): + """ + Used by device component serializers. + """ + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + module_bay = ModuleNestedModuleBaySerializer(read_only=True) + + class Meta: + model = models.Module + fields = ['id', 'url', 'display', 'device', 'module_bay'] + + class NestedModuleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') device = NestedDeviceSerializer(read_only=True) - # TODO: Solve circular dependency - # module_bay = NestedModuleBaySerializer(read_only=True) + module_bay = ModuleNestedModuleBaySerializer(read_only=True) module_type = NestedModuleTypeSerializer(read_only=True) class Meta: diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index b58355f32..c81b26d1f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -558,6 +558,10 @@ class DeviceNAPALMSerializer(serializers.Serializer): class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField( choices=ConsolePortTypeChoices, allow_blank=True, @@ -573,8 +577,8 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSeriali class Meta: model = ConsoleServerPort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', - 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -582,6 +586,10 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSeriali class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField( choices=ConsolePortTypeChoices, allow_blank=True, @@ -597,8 +605,8 @@ class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C class Meta: model = ConsolePort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', - 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -606,6 +614,10 @@ class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField( choices=PowerOutletTypeChoices, allow_blank=True, @@ -627,15 +639,20 @@ class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C class Meta: model = PowerOutlet fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', - 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', + 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', + 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', ] class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField( choices=PowerPortTypeChoices, allow_blank=True, @@ -646,15 +663,20 @@ class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con class Meta: model = PowerPort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', - 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', + 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', + 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', ] class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField(choices=InterfaceTypeChoices) parent = NestedInterfaceSerializer(required=False, allow_null=True) bridge = NestedInterfaceSerializer(required=False, allow_null=True) @@ -683,12 +705,12 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con class Meta: model = Interface fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', - 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', - 'link_peer', 'link_peer_type', 'wireless_lans', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', - 'count_fhrp_groups', '_occupied', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', + 'mtu', 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', + 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', + 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'connected_endpoint', + 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', ] def validate(self, data): @@ -708,13 +730,17 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con class RearPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField(choices=PortTypeChoices) cable = NestedCableSerializer(read_only=True) class Meta: model = RearPort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'positions', 'description', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -734,6 +760,10 @@ class FrontPortRearPortSerializer(WritableNestedSerializer): class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField(choices=PortTypeChoices) rear_port = FrontPortRearPortSerializer() cable = NestedCableSerializer(read_only=True) @@ -741,9 +771,9 @@ class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): class Meta: model = FrontPort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', - 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', - 'created', 'last_updated', '_occupied', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', + 'rear_port_position', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', + 'custom_fields', 'created', 'last_updated', '_occupied', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 378e697c8..9d1be93d5 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -539,7 +539,9 @@ class ModuleViewSet(CustomFieldModelViewSet): # class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): - queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') + queryset = ConsolePort.objects.prefetch_related( + 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + ) serializer_class = serializers.ConsolePortSerializer filterset_class = filtersets.ConsolePortFilterSet brief_prefetch_fields = ['device'] @@ -547,7 +549,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): queryset = ConsoleServerPort.objects.prefetch_related( - 'device', '_path__destination', 'cable', '_link_peer', 'tags' + 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' ) serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filtersets.ConsoleServerPortFilterSet @@ -555,14 +557,18 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): class PowerPortViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') + queryset = PowerPort.objects.prefetch_related( + 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + ) serializer_class = serializers.PowerPortSerializer filterset_class = filtersets.PowerPortFilterSet brief_prefetch_fields = ['device'] class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') + queryset = PowerOutlet.objects.prefetch_related( + 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + ) serializer_class = serializers.PowerOutletSerializer filterset_class = filtersets.PowerOutletFilterSet brief_prefetch_fields = ['device'] @@ -570,8 +576,8 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): class InterfaceViewSet(PathEndpointMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', 'wireless_lans', - 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'fhrp_group_assignments', 'tags' + 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', + 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'fhrp_group_assignments', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet @@ -579,14 +585,18 @@ class InterfaceViewSet(PathEndpointMixin, ModelViewSet): class FrontPortViewSet(PassThroughPortMixin, ModelViewSet): - queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags') + queryset = FrontPort.objects.prefetch_related( + 'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable', 'tags' + ) serializer_class = serializers.FrontPortSerializer filterset_class = filtersets.FrontPortFilterSet brief_prefetch_fields = ['device'] class RearPortViewSet(PassThroughPortMixin, ModelViewSet): - queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags') + queryset = RearPort.objects.prefetch_related( + 'device__device_type__manufacturer', 'module__module_bay', 'cable', 'tags' + ) serializer_class = serializers.RearPortSerializer filterset_class = filtersets.RearPortFilterSet brief_prefetch_fields = ['device'] From eaa1165611999bb5a44980622f9335c0da3cda93 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Dec 2021 09:51:55 -0500 Subject: [PATCH 8/9] Add position field for module bays --- netbox/dcim/api/serializers.py | 9 ++++-- netbox/dcim/forms/bulk_edit.py | 4 +-- netbox/dcim/forms/bulk_import.py | 2 +- netbox/dcim/forms/filtersets.py | 5 +++- netbox/dcim/forms/models.py | 4 +-- netbox/dcim/forms/object_create.py | 24 +++++++++------- netbox/dcim/forms/object_import.py | 2 +- netbox/dcim/migrations/0145_modules.py | 2 ++ .../dcim/models/device_component_templates.py | 28 +++++++++++++------ netbox/dcim/models/device_components.py | 6 ++++ netbox/dcim/tables/devices.py | 2 +- netbox/dcim/tables/devicetypes.py | 2 +- netbox/templates/dcim/modulebay.html | 4 +++ netbox/templates/dcim/moduletype/base.html | 2 +- 14 files changed, 65 insertions(+), 31 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index c81b26d1f..cf6c89333 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -432,7 +432,10 @@ class ModuleBayTemplateSerializer(ValidatedModelSerializer): class Meta: model = ModuleBayTemplate - fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created', + 'last_updated', + ] class DeviceBayTemplateSerializer(ValidatedModelSerializer): @@ -785,8 +788,8 @@ class ModuleBaySerializer(PrimaryModelSerializer): class Meta: model = ModuleBay fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'description', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'device', 'name', 'label', 'position', 'description', 'tags', 'custom_fields', + 'created', 'last_updated', ] diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 378620180..d40ac6fca 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -886,7 +886,7 @@ class ModuleBayTemplateBulkEditForm(BulkEditForm): ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ('label', 'position', 'description') class DeviceBayTemplateBulkEditForm(BulkEditForm): @@ -1153,7 +1153,7 @@ class ModuleBayBulkEditForm( ) class Meta: - nullable_fields = ['label', 'description'] + nullable_fields = ['label', 'position', 'description'] class DeviceBayBulkEditForm( diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 8f5ba25b7..23d589abf 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -717,7 +717,7 @@ class ModuleBayCSVForm(CustomFieldModelCSVForm): class Meta: model = ModuleBay - fields = ('device', 'name', 'label', 'description') + fields = ('device', 'name', 'label', 'position', 'description') class DeviceBayCSVForm(CustomFieldModelCSVForm): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 29c09c7f7..819cb91cc 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1073,10 +1073,13 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): model = ModuleBay field_groups = [ ['q', 'tag'], - ['name', 'label'], + ['name', 'label', 'position'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] tag = TagFilterField(model) + position = forms.CharField( + required=False + ) class DeviceBayFilterForm(DeviceComponentFilterForm): diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 672c54c68..2d32093c4 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1059,7 +1059,7 @@ class ModuleBayTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ModuleBayTemplate fields = [ - 'device_type', 'name', 'label', 'description', + 'device_type', 'name', 'label', 'position', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1313,7 +1313,7 @@ class ModuleBayForm(CustomFieldModelForm): class Meta: model = ModuleBay fields = [ - 'device', 'name', 'label', 'description', 'tags', + 'device', 'name', 'label', 'position', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 1619f5424..681a17a5a 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -163,6 +163,12 @@ class ComponentTemplateCreateForm(ComponentForm): 'manufacturer_id': '$manufacturer' } ) + description = forms.CharField( + required=False + ) + + +class ModularComponentTemplateCreateForm(ComponentTemplateCreateForm): module_type = DynamicModelChoiceField( queryset=ModuleType.objects.all(), required=False, @@ -170,12 +176,9 @@ class ComponentTemplateCreateForm(ComponentForm): 'manufacturer_id': '$manufacturer' } ) - description = forms.CharField( - required=False - ) -class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm): +class ConsolePortTemplateCreateForm(ModularComponentTemplateCreateForm): type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), widget=StaticSelect() @@ -185,7 +188,7 @@ class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm): ) -class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm): +class ConsoleServerPortTemplateCreateForm(ModularComponentTemplateCreateForm): type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), widget=StaticSelect() @@ -195,7 +198,7 @@ class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm): ) -class PowerPortTemplateCreateForm(ComponentTemplateCreateForm): +class PowerPortTemplateCreateForm(ModularComponentTemplateCreateForm): type = forms.ChoiceField( choices=add_blank_choice(PowerPortTypeChoices), required=False @@ -216,7 +219,7 @@ class PowerPortTemplateCreateForm(ComponentTemplateCreateForm): ) -class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm): +class PowerOutletTemplateCreateForm(ModularComponentTemplateCreateForm): type = forms.ChoiceField( choices=add_blank_choice(PowerOutletTypeChoices), required=False @@ -240,7 +243,7 @@ class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm): ) -class InterfaceTemplateCreateForm(ComponentTemplateCreateForm): +class InterfaceTemplateCreateForm(ModularComponentTemplateCreateForm): type = forms.ChoiceField( choices=InterfaceTypeChoices, widget=StaticSelect() @@ -255,7 +258,7 @@ class InterfaceTemplateCreateForm(ComponentTemplateCreateForm): ) -class FrontPortTemplateCreateForm(ComponentTemplateCreateForm): +class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm): type = forms.ChoiceField( choices=PortTypeChoices, widget=StaticSelect() @@ -320,7 +323,7 @@ class FrontPortTemplateCreateForm(ComponentTemplateCreateForm): } -class RearPortTemplateCreateForm(ComponentTemplateCreateForm): +class RearPortTemplateCreateForm(ModularComponentTemplateCreateForm): type = forms.ChoiceField( choices=PortTypeChoices, widget=StaticSelect(), @@ -341,6 +344,7 @@ class RearPortTemplateCreateForm(ComponentTemplateCreateForm): class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm): + # TODO: Support patterned position assignment field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description') diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index cc0c7dc41..36c6ae8bc 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -155,7 +155,7 @@ class ModuleBayTemplateImportForm(ComponentTemplateImportForm): class Meta: model = ModuleBayTemplate fields = [ - 'device_type', 'name', 'label', 'description', + 'device_type', 'name', 'label', 'position', 'description', ] diff --git a/netbox/dcim/migrations/0145_modules.py b/netbox/dcim/migrations/0145_modules.py index c9a332846..1f99c7c04 100644 --- a/netbox/dcim/migrations/0145_modules.py +++ b/netbox/dcim/migrations/0145_modules.py @@ -105,6 +105,7 @@ class Migration(migrations.Migration): ('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)), + ('position', models.CharField(blank=True, max_length=30)), ('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')), @@ -241,6 +242,7 @@ class Migration(migrations.Migration): ('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)), + ('position', models.CharField(blank=True, max_length=30)), ('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')), ], diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index a22118de0..71fed25c5 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -123,6 +123,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel): "A component template must be associated with either a device type or a module type." ) + def resolve_name(self, module): + if module: + return self.name.replace('{module}', module.module_bay.position) + return self.name + @extras_features('webhooks') class ConsolePortTemplate(ModularComponentTemplateModel): @@ -144,7 +149,7 @@ class ConsolePortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return ConsolePort( - name=self.name, + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, **kwargs @@ -171,7 +176,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return ConsoleServerPort( - name=self.name, + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, **kwargs @@ -210,7 +215,7 @@ class PowerPortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return PowerPort( - name=self.name, + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, maximum_draw=self.maximum_draw, @@ -279,7 +284,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel): else: power_port = None return PowerOutlet( - name=self.name, + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, power_port=power_port, @@ -318,7 +323,7 @@ class InterfaceTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return Interface( - name=self.name, + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, mgmt_only=self.mgmt_only, @@ -387,7 +392,7 @@ class FrontPortTemplate(ModularComponentTemplateModel): else: rear_port = None return FrontPort( - name=self.name, + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, color=self.color, @@ -426,7 +431,7 @@ class RearPortTemplate(ModularComponentTemplateModel): def instantiate(self, **kwargs): return RearPort( - name=self.name, + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, color=self.color, @@ -440,6 +445,12 @@ class ModuleBayTemplate(ComponentTemplateModel): """ A template for a ModuleBay to be created for a new parent Device. """ + position = models.CharField( + max_length=30, + blank=True, + help_text='Identifier to reference when renaming installed components' + ) + class Meta: ordering = ('device_type', '_name') unique_together = ('device_type', 'name') @@ -448,7 +459,8 @@ class ModuleBayTemplate(ComponentTemplateModel): return ModuleBay( device=device, name=self.name, - label=self.label + label=self.label, + position=self.position ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index fc80b29c9..ccfe538d7 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -880,6 +880,12 @@ class ModuleBay(ComponentModel): """ An empty space within a Device which can house a child device """ + position = models.CharField( + max_length=30, + blank=True, + help_text='Identifier to reference when renaming installed components' + ) + clone_fields = ['device'] class Meta: diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 08f229d33..7805e60c1 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -749,7 +749,7 @@ class ModuleBayTable(DeviceComponentTable): class Meta(DeviceComponentTable.Meta): model = ModuleBay - fields = ('pk', 'id', 'name', 'device', 'label', 'installed_module', 'description', 'tags') + fields = ('pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'description', 'tags') default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'description') diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 6fc038542..38a33c652 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -217,7 +217,7 @@ class ModuleBayTemplateTable(ComponentTemplateTable): class Meta(ComponentTemplateTable.Meta): model = ModuleBayTemplate - fields = ('pk', 'name', 'label', 'description', 'actions') + fields = ('pk', 'name', 'label', 'position', 'description', 'actions') empty_text = "None" diff --git a/netbox/templates/dcim/modulebay.html b/netbox/templates/dcim/modulebay.html index 0f903483a..9f04ba225 100644 --- a/netbox/templates/dcim/modulebay.html +++ b/netbox/templates/dcim/modulebay.html @@ -23,6 +23,10 @@ Label {{ object.label|placeholder }} + + Position + {{ object.position|placeholder }} + Description {{ object.description|placeholder }} diff --git a/netbox/templates/dcim/moduletype/base.html b/netbox/templates/dcim/moduletype/base.html index 563e23a7b..2d0bca7d8 100644 --- a/netbox/templates/dcim/moduletype/base.html +++ b/netbox/templates/dcim/moduletype/base.html @@ -45,7 +45,7 @@ {% block tab_items %} From e35aa4bd1e479690a3ca23ec40d7ee897eaeee8a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Dec 2021 10:31:18 -0500 Subject: [PATCH 9/9] Add documentation for modules --- docs/core-functionality/device-types.md | 1 + docs/core-functionality/devices.md | 1 + docs/core-functionality/modules.md | 4 ++++ docs/models/dcim/devicebay.md | 2 +- docs/models/dcim/devicebaytemplate.md | 2 +- docs/models/dcim/module.md | 5 +++++ docs/models/dcim/modulebay.md | 3 +++ docs/models/dcim/modulebaytemplate.md | 3 +++ docs/models/dcim/moduletype.md | 23 +++++++++++++++++++++++ mkdocs.yml | 1 + 10 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 docs/core-functionality/modules.md create mode 100644 docs/models/dcim/module.md create mode 100644 docs/models/dcim/modulebay.md create mode 100644 docs/models/dcim/modulebaytemplate.md create mode 100644 docs/models/dcim/moduletype.md diff --git a/docs/core-functionality/device-types.md b/docs/core-functionality/device-types.md index 037d3cfd0..ec5cbacdb 100644 --- a/docs/core-functionality/device-types.md +++ b/docs/core-functionality/device-types.md @@ -37,4 +37,5 @@ Once component templates have been created, every new device that you create as {!models/dcim/interfacetemplate.md!} {!models/dcim/frontporttemplate.md!} {!models/dcim/rearporttemplate.md!} +{!models/dcim/modulebaytemplate.md!} {!models/dcim/devicebaytemplate.md!} diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 982ee3071..35c978210 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -17,6 +17,7 @@ Device components represent discrete objects within a device which are used to t {!models/dcim/interface.md!} {!models/dcim/frontport.md!} {!models/dcim/rearport.md!} +{!models/dcim/modulebay.md!} {!models/dcim/devicebay.md!} {!models/dcim/inventoryitem.md!} diff --git a/docs/core-functionality/modules.md b/docs/core-functionality/modules.md new file mode 100644 index 000000000..4d32fe18c --- /dev/null +++ b/docs/core-functionality/modules.md @@ -0,0 +1,4 @@ +# Modules + +{!models/dcim/moduletype.md!} +{!models/dcim/module.md!} diff --git a/docs/models/dcim/devicebay.md b/docs/models/dcim/devicebay.md index 2aea14a7a..e79c426dc 100644 --- a/docs/models/dcim/devicebay.md +++ b/docs/models/dcim/devicebay.md @@ -5,4 +5,4 @@ Device bays represent a space or slot within a parent device in which a child de Child devices are first-class Devices in their own right: That is, they are fully independent managed entities which don't share any control plane with the parent. Just like normal devices, child devices have their own platform (OS), role, tags, and components. LAG interfaces may not group interfaces belonging to different child devices. !!! note - Device bays are **not** suitable for modeling line cards (such as those commonly found in chassis-based routers and switches), as these components depend on the control plane of the parent device to operate. Instead, line cards and similarly non-autonomous hardware should be modeled as inventory items within a device, with any associated interfaces or other components assigned directly to the device. + Device bays are **not** suitable for modeling line cards (such as those commonly found in chassis-based routers and switches), as these components depend on the control plane of the parent device to operate. Instead, these should be modeled as modules installed within module bays. diff --git a/docs/models/dcim/devicebaytemplate.md b/docs/models/dcim/devicebaytemplate.md index ebf7bd63c..a4c50067a 100644 --- a/docs/models/dcim/devicebaytemplate.md +++ b/docs/models/dcim/devicebaytemplate.md @@ -1,3 +1,3 @@ ## Device Bay Templates -A template for a device bay that will be created on all instantiations of the parent device type. +A template for a device bay that will be created on all instantiations of the parent device type. Device bays hold child devices, such as blade servers. diff --git a/docs/models/dcim/module.md b/docs/models/dcim/module.md new file mode 100644 index 000000000..bc9753ecc --- /dev/null +++ b/docs/models/dcim/module.md @@ -0,0 +1,5 @@ +# Modules + +A module is a field-replaceable hardware component installed within a device which houses its own child components. The most common example is a chassis-based router or switch. + +Similar to devices, modules are instantiated from module types, and any components associated with the module type are automatically instantiated on the new model. Each module must be installed within a module bay on a device, and each module bay may have only one module installed in it. A module may optionally be assigned a serial number and asset tag. diff --git a/docs/models/dcim/modulebay.md b/docs/models/dcim/modulebay.md new file mode 100644 index 000000000..6c6f94598 --- /dev/null +++ b/docs/models/dcim/modulebay.md @@ -0,0 +1,3 @@ +## Module Bays + +Module bays represent a space or slot within a device in which a field-replaceable module may be installed. A common example is that of a chassis-based switch such as the Cisco Nexus 9000 or Juniper EX9200. Modules in turn hold additional components that become available to the parent device. diff --git a/docs/models/dcim/modulebaytemplate.md b/docs/models/dcim/modulebaytemplate.md new file mode 100644 index 000000000..463789305 --- /dev/null +++ b/docs/models/dcim/modulebaytemplate.md @@ -0,0 +1,3 @@ +## Module Bay Templates + +A template for a module bay that will be created on all instantiations of the parent device type. Module bays hold installed modules that do not have an independent management plane, such as line cards. diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md new file mode 100644 index 000000000..c1c8c5079 --- /dev/null +++ b/docs/models/dcim/moduletype.md @@ -0,0 +1,23 @@ +# Module Types + +A module type represent a specific make and model of hardware component which is installable within a device and has its own child components. For example, consider a chassis-based switch or router with a number of field-replaceable line cards. Each line card has its own model number and includes a certain set of components such as interfaces. Each module type may have a manufacturer, model number, and part number assigned to it. + +Similar to device types, each module type can have any of the following component templates associated with it: + +* Interfaces +* Console ports +* Console server ports +* Power ports +* Power Outlets +* Front pass-through ports +* Rear pass-through ports + +Note that device bays and module bays may _not_ be added to modules. + +## Automatic Component Renaming + +When adding component templates to a module type, the string `{module}` can be used to reference the `position` field of the module bay into which an instance of the module type is being installed. + +For example, you can create a module type with interface templates named `Gi{module}/0/[1-48]`. When a new module of this type is "installed" to a module bay with a position of "3", NetBox will automatically name these interfaces `Gi3/0/[1-48]`. + +Automatic renaming is supported for all modular component types (those listed above). diff --git a/mkdocs.yml b/mkdocs.yml index 3fb838ffd..f89bdaea7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,7 @@ nav: - Sites and Racks: 'core-functionality/sites-and-racks.md' - Devices and Cabling: 'core-functionality/devices.md' - Device Types: 'core-functionality/device-types.md' + - Modules: 'core-functionality/modules.md' - Virtualization: 'core-functionality/virtualization.md' - Service Mapping: 'core-functionality/services.md' - Circuits: 'core-functionality/circuits.md'