From 7c60e3c0ffeb81f3ccc7ff23b41732e1f22fbfac Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 17 Dec 2021 12:18:37 -0500 Subject: [PATCH] Add Module model --- netbox/dcim/api/nested_serializers.py | 17 +- netbox/dcim/api/serializers.py | 19 +- netbox/dcim/api/urls.py | 3 +- netbox/dcim/api/views.py | 11 +- netbox/dcim/filtersets.py | 100 +++++- netbox/dcim/forms/bulk_edit.py | 23 +- netbox/dcim/forms/filtersets.py | 67 +++- netbox/dcim/forms/models.py | 54 ++- netbox/dcim/forms/object_create.py | 55 +-- netbox/dcim/forms/object_import.py | 12 + netbox/dcim/graphql/schema.py | 3 + netbox/dcim/graphql/types.py | 9 + netbox/dcim/migrations/0145_modules.py | 144 ++++++++ netbox/dcim/models/__init__.py | 1 + .../dcim/models/device_component_templates.py | 124 +++++-- netbox/dcim/models/devices.py | 140 ++++++++ netbox/dcim/tables/__init__.py | 1 + netbox/dcim/tables/moduletypes.py | 34 ++ netbox/dcim/tests/test_api.py | 39 +++ netbox/dcim/tests/test_filtersets.py | 104 ++++++ netbox/dcim/tests/test_views.py | 320 +++++++++++++++++- netbox/dcim/urls.py | 19 ++ netbox/dcim/views.py | 131 ++++++- netbox/netbox/navigation_menu.py | 1 + netbox/templates/dcim/moduletype.html | 48 +++ netbox/templates/dcim/moduletype/base.html | 108 ++++++ .../dcim/moduletype/component_templates.html | 44 +++ 27 files changed, 1542 insertions(+), 89 deletions(-) create mode 100644 netbox/dcim/tables/moduletypes.py create mode 100644 netbox/templates/dcim/moduletype.html create mode 100644 netbox/templates/dcim/moduletype/base.html create mode 100644 netbox/templates/dcim/moduletype/component_templates.html diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index e050a22db..a6e359feb 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -22,6 +22,7 @@ __all__ = [ 'NestedManufacturerSerializer', 'NestedModuleBaySerializer', 'NestedModuleBayTemplateSerializer', + 'NestedModuleTypeSerializer', 'NestedPlatformSerializer', 'NestedPowerFeedSerializer', 'NestedPowerOutletSerializer', @@ -119,7 +120,7 @@ class NestedRackReservationSerializer(WritableNestedSerializer): # -# Device types +# Device/module types # class NestedManufacturerSerializer(WritableNestedSerializer): @@ -141,6 +142,20 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'manufacturer', 'model', 'slug', 'device_count'] +class NestedModuleTypeSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') + manufacturer = NestedManufacturerSerializer(read_only=True) + # module_count = serializers.IntegerField(read_only=True) + + class Meta: + model = models.ModuleType + fields = ['id', 'url', 'display', 'manufacturer', 'model'] + + +# +# Component templates +# + class NestedConsolePortTemplateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1d1294b7a..c3d6b5cb4 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -261,7 +261,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): # -# Device types +# Device/module types # class ManufacturerSerializer(PrimaryModelSerializer): @@ -294,6 +294,23 @@ class DeviceTypeSerializer(PrimaryModelSerializer): ] +class ModuleTypeSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') + manufacturer = NestedManufacturerSerializer() + # module_count = serializers.IntegerField(read_only=True) + + class Meta: + model = ModuleType + fields = [ + 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', + ] + + +# +# Component templates +# + class ConsolePortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') device_type = NestedDeviceTypeSerializer() diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index bf68106f5..7a866063f 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -16,9 +16,10 @@ router.register('rack-roles', views.RackRoleViewSet) router.register('racks', views.RackViewSet) router.register('rack-reservations', views.RackReservationViewSet) -# Device types +# Device/module types router.register('manufacturers', views.ManufacturerViewSet) router.register('device-types', views.DeviceTypeViewSet) +router.register('module-types', views.ModuleTypeViewSet) # Device type components router.register('console-port-templates', views.ConsolePortTemplateViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 25dfda360..e50f9b1b6 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -271,7 +271,7 @@ class ManufacturerViewSet(CustomFieldModelViewSet): # -# Device types +# Device/module types # class DeviceTypeViewSet(CustomFieldModelViewSet): @@ -283,6 +283,15 @@ class DeviceTypeViewSet(CustomFieldModelViewSet): brief_prefetch_fields = ['manufacturer'] +class ModuleTypeViewSet(CustomFieldModelViewSet): + queryset = ModuleType.objects.prefetch_related('manufacturer', 'tags').annotate( + # module_count=count_related(Module, 'module_type') + ) + serializer_class = serializers.ModuleTypeSerializer + filterset_class = filtersets.ModuleTypeFilterSet + brief_prefetch_fields = ['manufacturer'] + + # # Device type components # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index d4c5f8e5a..b0ff992a7 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -43,6 +43,7 @@ __all__ = ( 'ManufacturerFilterSet', 'ModuleBayFilterSet', 'ModuleBayTemplateFilterSet', + 'ModuleTypeFilterSet', 'PathEndpointFilterSet', 'PlatformFilterSet', 'PowerConnectionFilterSet', @@ -503,6 +504,83 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): return queryset.exclude(devicebaytemplates__isnull=value) +class ModuleTypeFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + field_name='manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + console_ports = django_filters.BooleanFilter( + method='_console_ports', + label='Has console ports', + ) + console_server_ports = django_filters.BooleanFilter( + method='_console_server_ports', + label='Has console server ports', + ) + power_ports = django_filters.BooleanFilter( + method='_power_ports', + label='Has power ports', + ) + power_outlets = django_filters.BooleanFilter( + method='_power_outlets', + label='Has power outlets', + ) + interfaces = django_filters.BooleanFilter( + method='_interfaces', + label='Has interfaces', + ) + pass_through_ports = django_filters.BooleanFilter( + method='_pass_through_ports', + label='Has pass-through ports', + ) + tag = TagFilter() + + class Meta: + model = ModuleType + fields = ['id', 'model', 'part_number'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(manufacturer__name__icontains=value) | + Q(model__icontains=value) | + Q(part_number__icontains=value) | + Q(comments__icontains=value) + ) + + def _console_ports(self, queryset, name, value): + return queryset.exclude(consoleporttemplates__isnull=value) + + def _console_server_ports(self, queryset, name, value): + return queryset.exclude(consoleserverporttemplates__isnull=value) + + def _power_ports(self, queryset, name, value): + return queryset.exclude(powerporttemplates__isnull=value) + + def _power_outlets(self, queryset, name, value): + return queryset.exclude(poweroutlettemplates__isnull=value) + + def _interfaces(self, queryset, name, value): + return queryset.exclude(interfacetemplates__isnull=value) + + def _pass_through_ports(self, queryset, name, value): + return queryset.exclude( + frontporttemplates__isnull=value, + rearporttemplates__isnull=value + ) + + class DeviceTypeComponentFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( method='search', @@ -520,28 +598,36 @@ class DeviceTypeComponentFilterSet(django_filters.FilterSet): return queryset.filter(name__icontains=value) -class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet): + moduletype_id = django_filters.ModelMultipleChoiceFilter( + queryset=ModuleType.objects.all(), + field_name='module_type_id', + label='Module type (ID)', + ) + + +class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = ConsolePortTemplate fields = ['id', 'name', 'type'] -class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = ConsoleServerPortTemplate fields = ['id', 'name', 'type'] -class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = PowerPortTemplate fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw'] -class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): feed_leg = django_filters.MultipleChoiceFilter( choices=PowerOutletFeedLegChoices, null_value=None @@ -552,7 +638,7 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompone fields = ['id', 'name', 'type', 'feed_leg'] -class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=InterfaceTypeChoices, null_value=None @@ -563,7 +649,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent fields = ['id', 'name', 'type', 'mgmt_only'] -class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, null_value=None @@ -574,7 +660,7 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent fields = ['id', 'name', 'type', 'color'] -class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, null_value=None diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 02492c630..360fb81cb 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -34,6 +34,7 @@ __all__ = ( 'ManufacturerBulkEditForm', 'ModuleBayBulkEditForm', 'ModuleBayTemplateBulkEditForm', + 'ModuleTypeBulkEditForm', 'PlatformBulkEditForm', 'PowerFeedBulkEditForm', 'PowerOutletBulkEditForm', @@ -327,6 +328,9 @@ class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): queryset=Manufacturer.objects.all(), required=False ) + part_number = forms.CharField( + required=False + ) u_height = forms.IntegerField( min_value=1, required=False @@ -343,7 +347,24 @@ class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ) class Meta: - nullable_fields = ['airflow'] + nullable_fields = ['part_number', 'airflow'] + + +class ModuleTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ModuleType.objects.all(), + widget=forms.MultipleHiddenInput() + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + part_number = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ['part_number'] class DeviceRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index e134adace..5e8b333b9 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -30,6 +30,7 @@ __all__ = ( 'LocationFilterForm', 'ManufacturerFilterForm', 'ModuleBayFilterForm', + 'ModuleTypeFilterForm', 'PlatformFilterForm', 'PowerConnectionFilterForm', 'PowerFeedFilterForm', @@ -337,7 +338,7 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm): model = DeviceType field_groups = [ ['q', 'tag'], - ['manufacturer_id', 'subdevice_role', 'airflow'], + ['manufacturer_id', 'part_number', 'subdevice_role', 'airflow'], ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'], ] manufacturer_id = DynamicModelMultipleChoiceField( @@ -346,6 +347,9 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm): label=_('Manufacturer'), fetch_trigger='open' ) + part_number = forms.CharField( + required=False + ) subdevice_role = forms.MultipleChoiceField( choices=add_blank_choice(SubdeviceRoleChoices), required=False, @@ -401,6 +405,67 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) +class ModuleTypeFilterForm(CustomFieldModelFilterForm): + model = ModuleType + field_groups = [ + ['q', 'tag'], + ['manufacturer_id', 'part_number'], + ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'], + ] + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer'), + fetch_trigger='open' + ) + part_number = forms.CharField( + required=False + ) + console_ports = forms.NullBooleanField( + required=False, + label='Has console ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + console_server_ports = forms.NullBooleanField( + required=False, + label='Has console server ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_ports = forms.NullBooleanField( + required=False, + label='Has power ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_outlets = forms.NullBooleanField( + required=False, + label='Has power outlets', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + interfaces = forms.NullBooleanField( + required=False, + label='Has interfaces', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + pass_through_ports = forms.NullBooleanField( + required=False, + label='Has pass-through ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + tag = TagFilterField(model) + + class DeviceRoleFilterForm(CustomFieldModelFilterForm): model = DeviceRole tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 2fcd23211..ae3cfeaef 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -41,6 +41,7 @@ __all__ = ( 'ManufacturerForm', 'ModuleBayForm', 'ModuleBayTemplateForm', + 'ModuleTypeForm', 'PlatformForm', 'PopulateDeviceBayForm', 'PowerFeedForm', @@ -414,6 +415,23 @@ class DeviceTypeForm(CustomFieldModelForm): } +class ModuleTypeForm(CustomFieldModelForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all() + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = ModuleType + fields = [ + 'manufacturer', 'model', 'part_number', 'comments', 'tags', + ] + + class DeviceRoleForm(CustomFieldModelForm): slug = SlugField() tags = DynamicModelMultipleChoiceField( @@ -892,10 +910,11 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), } @@ -903,10 +922,11 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsoleServerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), } @@ -914,10 +934,11 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): 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', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), } @@ -925,19 +946,21 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): 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', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Limit power_port choices to current DeviceType - if hasattr(self.instance, 'device_type'): + # Limit power_port choices to current DeviceType/ModuleType + if self.instance.pk: self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( - device_type=self.instance.device_type + device_type=self.instance.device_type, + module_type=self.instance.module_type ) @@ -945,10 +968,11 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), 'type': StaticSelect(), } @@ -957,20 +981,23 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = FrontPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', + 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), 'rear_port': StaticSelect(), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Limit rear_port choices to current DeviceType - if hasattr(self.instance, 'device_type'): + # Limit rear_port choices to current DeviceType/ModuleType + if self.instance.pk: self.fields['rear_port'].queryset = RearPortTemplate.objects.filter( - device_type=self.instance.device_type + device_type=self.instance.device_type, + module_type=self.instance.module_type ) @@ -978,10 +1005,11 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = RearPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'color', 'positions', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), 'type': StaticSelect(), } diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index bf9060225..1619f5424 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -152,11 +152,20 @@ class ComponentTemplateCreateForm(ComponentForm): queryset=Manufacturer.objects.all(), required=False, initial_params={ - 'device_types': 'device_type' + 'device_types': 'device_type', + 'module_types': 'module_type', } ) device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), + required=False, + query_params={ + 'manufacturer_id': '$manufacturer' + } + ) + module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + required=False, query_params={ 'manufacturer_id': '$manufacturer' } @@ -171,7 +180,9 @@ class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm): choices=add_blank_choice(ConsolePortTypeChoices), widget=StaticSelect() ) - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description') + field_order = ( + 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'description', + ) class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm): @@ -179,7 +190,9 @@ class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm): choices=add_blank_choice(ConsolePortTypeChoices), widget=StaticSelect() ) - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description') + field_order = ( + 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'description', + ) class PowerPortTemplateCreateForm(ComponentTemplateCreateForm): @@ -198,8 +211,8 @@ class PowerPortTemplateCreateForm(ComponentTemplateCreateForm): help_text="Allocated power draw (watts)" ) field_order = ( - 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', - 'description', + 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', + 'allocated_draw', 'description', ) @@ -208,9 +221,13 @@ class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm): choices=add_blank_choice(PowerOutletTypeChoices), required=False ) - power_port = forms.ModelChoiceField( + power_port = DynamicModelChoiceField( queryset=PowerPortTemplate.objects.all(), - required=False + required=False, + query_params={ + 'devicetype_id': '$device_type', + 'moduletype_id': '$module_type', + } ) feed_leg = forms.ChoiceField( choices=add_blank_choice(PowerOutletFeedLegChoices), @@ -218,21 +235,10 @@ class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm): widget=StaticSelect() ) field_order = ( - 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', + 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', 'description', ) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit power_port choices to current DeviceType - device_type = DeviceType.objects.get( - pk=self.initial.get('device_type') or self.data.get('device_type') - ) - self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( - device_type=device_type - ) - class InterfaceTemplateCreateForm(ComponentTemplateCreateForm): type = forms.ChoiceField( @@ -243,7 +249,10 @@ class InterfaceTemplateCreateForm(ComponentTemplateCreateForm): required=False, label='Management only' ) - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'mgmt_only', 'description') + field_order = ( + 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'mgmt_only', + 'description', + ) class FrontPortTemplateCreateForm(ComponentTemplateCreateForm): @@ -260,7 +269,8 @@ class FrontPortTemplateCreateForm(ComponentTemplateCreateForm): help_text='Select one rear port assignment for each front port being created.', ) field_order = ( - 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'description', + 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', + 'description', ) def __init__(self, *args, **kwargs): @@ -325,7 +335,8 @@ class RearPortTemplateCreateForm(ComponentTemplateCreateForm): help_text='The number of front ports which may be mapped to each rear port' ) field_order = ( - 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'description', + 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', + 'description', ) diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 49924b623..9df029386 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -12,6 +12,7 @@ __all__ = ( 'FrontPortTemplateImportForm', 'InterfaceTemplateImportForm', 'ModuleBayTemplateImportForm', + 'ModuleTypeImportForm', 'PowerOutletTemplateImportForm', 'PowerPortTemplateImportForm', 'RearPortTemplateImportForm', @@ -32,6 +33,17 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm): ] +class ModuleTypeImportForm(BootstrapMixin, forms.ModelForm): + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name' + ) + + class Meta: + model = ModuleType + fields = ['manufacturer', 'model', 'part_number', 'comments'] + + # # Component template import forms # diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 60b7526bd..d50c64d33 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -62,6 +62,9 @@ class DCIMQuery(graphene.ObjectType): module_bay_template = ObjectField(ModuleBayTemplateType) module_bay_template_list = ObjectListField(ModuleBayTemplateType) + module_type = ObjectField(ModuleTypeType) + module_type_list = ObjectListField(ModuleTypeType) + platform = ObjectField(PlatformType) platform_list = ObjectListField(PlatformType) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 355c14dc3..c1a8822d8 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -29,6 +29,7 @@ __all__ = ( 'ManufacturerType', 'ModuleBayType', 'ModuleBayTemplateType', + 'ModuleTypeType', 'PlatformType', 'PowerFeedType', 'PowerOutletType', @@ -272,6 +273,14 @@ class ModuleBayTemplateType(ComponentTemplateObjectType): filterset_class = filtersets.ModuleBayTemplateFilterSet +class ModuleTypeType(PrimaryObjectType): + + class Meta: + model = models.ModuleType + fields = '__all__' + filterset_class = filtersets.ModuleTypeFilterSet + + class PlatformType(OrganizationalObjectType): class Meta: diff --git a/netbox/dcim/migrations/0145_modules.py b/netbox/dcim/migrations/0145_modules.py index c469e059c..b9cb7bcc5 100644 --- a/netbox/dcim/migrations/0145_modules.py +++ b/netbox/dcim/migrations/0145_modules.py @@ -14,6 +14,150 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AlterModelOptions( + name='consoleporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='consoleserverporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='frontporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='interfacetemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='poweroutlettemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='powerporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='rearporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleporttemplates', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverporttemplates', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='frontporttemplates', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfacetemplates', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlettemplates', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='powerporttemplates', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='rearporttemplates', to='dcim.devicetype'), + ), + migrations.CreateModel( + name='ModuleType', + 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)), + ('model', models.CharField(max_length=100)), + ('part_number', models.CharField(blank=True, max_length=50)), + ('comments', models.TextField(blank=True)), + ('manufacturer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='module_types', to='dcim.manufacturer')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('manufacturer', 'model'), + 'unique_together': {('manufacturer', 'model')}, + }, + ), + 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='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='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='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='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='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='rearporttemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='rearporttemplates', to='dcim.moduletype'), + ), + migrations.AlterUniqueTogether( + name='consoleporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='consoleserverporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='frontporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name'), ('rear_port', 'rear_port_position')}, + ), + migrations.AlterUniqueTogether( + name='interfacetemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='poweroutlettemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='powerporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='rearporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), migrations.CreateModel( name='ModuleBayTemplate', fields=[ diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 86e49c42e..a030dc3a8 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -29,6 +29,7 @@ __all__ = ( 'Manufacturer', 'ModuleBay', 'ModuleBayTemplate', + 'ModuleType', 'Platform', 'PowerFeed', 'PowerOutlet', diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index c8ab8f5f0..d522d543a 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -64,7 +64,7 @@ class ComponentTemplateModel(ChangeLoggedModel): """ raise NotImplementedError() - def to_objectchange(self, action): + def to_objectchange(self, action, related_object=None): # Annotate the parent DeviceType try: device_type = self.device_type @@ -74,8 +74,58 @@ class ComponentTemplateModel(ChangeLoggedModel): return super().to_objectchange(action, related_object=device_type) +class ModularComponentTemplateModel(ComponentTemplateModel): + """ + A ComponentTemplateModel which supports optional assignment to a ModuleType. + """ + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='%(class)ss', + blank=True, + null=True + ) + module_type = models.ForeignKey( + to='dcim.ModuleType', + on_delete=models.CASCADE, + related_name='%(class)ss', + blank=True, + null=True + ) + + class Meta: + abstract = True + + def to_objectchange(self, action, related_object=None): + # Annotate the parent DeviceType or ModuleType + try: + if getattr(self, 'device_type'): + return super().to_objectchange(action, related_object=self.device_type) + except ObjectDoesNotExist: + pass + try: + if getattr(self, 'module_type'): + return super().to_objectchange(action, related_object=self.module_type) + except ObjectDoesNotExist: + pass + return super().to_objectchange(action) + + def clean(self): + super().clean() + + # A component template must belong to a DeviceType *or* to a ModuleType + if self.device_type and self.module_type: + raise ValidationError( + "A component template cannot be associated with both a device type and a module type." + ) + if not self.device_type and not self.module_type: + raise ValidationError( + "A component template must be associated with either a device type or a module type." + ) + + @extras_features('webhooks') -class ConsolePortTemplate(ComponentTemplateModel): +class ConsolePortTemplate(ModularComponentTemplateModel): """ A template for a ConsolePort to be created for a new Device. """ @@ -86,8 +136,11 @@ class ConsolePortTemplate(ComponentTemplateModel): ) class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) def instantiate(self, device): return ConsolePort( @@ -99,7 +152,7 @@ class ConsolePortTemplate(ComponentTemplateModel): @extras_features('webhooks') -class ConsoleServerPortTemplate(ComponentTemplateModel): +class ConsoleServerPortTemplate(ModularComponentTemplateModel): """ A template for a ConsoleServerPort to be created for a new Device. """ @@ -110,8 +163,11 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): ) class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) def instantiate(self, device): return ConsoleServerPort( @@ -123,7 +179,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): @extras_features('webhooks') -class PowerPortTemplate(ComponentTemplateModel): +class PowerPortTemplate(ModularComponentTemplateModel): """ A template for a PowerPort to be created for a new Device. """ @@ -146,8 +202,11 @@ class PowerPortTemplate(ComponentTemplateModel): ) class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) def instantiate(self, device): return PowerPort( @@ -170,7 +229,7 @@ class PowerPortTemplate(ComponentTemplateModel): @extras_features('webhooks') -class PowerOutletTemplate(ComponentTemplateModel): +class PowerOutletTemplate(ModularComponentTemplateModel): """ A template for a PowerOutlet to be created for a new Device. """ @@ -194,17 +253,25 @@ class PowerOutletTemplate(ComponentTemplateModel): ) class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) def clean(self): super().clean() # Validate power port assignment - if self.power_port and self.power_port.device_type != self.device_type: - raise ValidationError( - "Parent power port ({}) must belong to the same device type".format(self.power_port) - ) + if self.power_port: + if self.device_type and self.power_port.device_type != self.device_type: + raise ValidationError( + f"Parent power port ({self.power_port}) must belong to the same device type" + ) + if self.module_type and self.power_port.module_type != self.module_type: + raise ValidationError( + f"Parent power port ({self.power_port}) must belong to the same module type" + ) def instantiate(self, device): if self.power_port: @@ -222,7 +289,7 @@ class PowerOutletTemplate(ComponentTemplateModel): @extras_features('webhooks') -class InterfaceTemplate(ComponentTemplateModel): +class InterfaceTemplate(ModularComponentTemplateModel): """ A template for a physical data interface on a new Device. """ @@ -243,8 +310,11 @@ class InterfaceTemplate(ComponentTemplateModel): ) class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) def instantiate(self, device): return Interface( @@ -257,7 +327,7 @@ class InterfaceTemplate(ComponentTemplateModel): @extras_features('webhooks') -class FrontPortTemplate(ComponentTemplateModel): +class FrontPortTemplate(ModularComponentTemplateModel): """ Template for a pass-through port on the front of a new Device. """ @@ -282,9 +352,10 @@ class FrontPortTemplate(ComponentTemplateModel): ) class Meta: - ordering = ('device_type', '_name') + ordering = ('device_type', 'module_type', '_name') unique_together = ( ('device_type', 'name'), + ('module_type', 'name'), ('rear_port', 'rear_port_position'), ) @@ -327,7 +398,7 @@ class FrontPortTemplate(ComponentTemplateModel): @extras_features('webhooks') -class RearPortTemplate(ComponentTemplateModel): +class RearPortTemplate(ModularComponentTemplateModel): """ Template for a pass-through port on the rear of a new Device. """ @@ -347,8 +418,11 @@ class RearPortTemplate(ComponentTemplateModel): ) class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) def instantiate(self, device): return RearPort( diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 18c0fe9de..ab06b7dc5 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -26,6 +26,7 @@ __all__ = ( 'DeviceRole', 'DeviceType', 'Manufacturer', + 'ModuleType', 'Platform', 'VirtualChassis', ) @@ -253,6 +254,15 @@ class DeviceType(PrimaryModel): } for c in self.rearporttemplates.all() ] + if self.modulebaytemplates.exists(): + data['module-bays'] = [ + { + 'name': c.name, + 'label': c.label, + 'description': c.description, + } + for c in self.modulebaytemplates.all() + ] if self.devicebaytemplates.exists(): data['device-bays'] = [ { @@ -342,6 +352,136 @@ class DeviceType(PrimaryModel): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class ModuleType(PrimaryModel): + """ + A ModuleType represents a hardware element that can be installed within a device and which houses additional + components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a + DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It + cannot, however house device bays or module bays. + """ + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='module_types' + ) + model = models.CharField( + max_length=100 + ) + part_number = models.CharField( + max_length=50, + blank=True, + help_text='Discrete part number (optional)' + ) + comments = models.TextField( + blank=True + ) + + clone_fields = ('manufacturer',) + + class Meta: + ordering = ('manufacturer', 'model') + unique_together = ( + ('manufacturer', 'model'), + ) + + def __str__(self): + return self.model + + def get_absolute_url(self): + return reverse('dcim:moduletype', args=[self.pk]) + + def to_yaml(self): + data = OrderedDict(( + ('manufacturer', self.manufacturer.name), + ('model', self.model), + ('part_number', self.part_number), + ('comments', self.comments), + )) + + # Component templates + if self.consoleporttemplates.exists(): + data['console-ports'] = [ + { + 'name': c.name, + 'type': c.type, + 'label': c.label, + 'description': c.description, + } + for c in self.consoleporttemplates.all() + ] + if self.consoleserverporttemplates.exists(): + data['console-server-ports'] = [ + { + 'name': c.name, + 'type': c.type, + 'label': c.label, + 'description': c.description, + } + for c in self.consoleserverporttemplates.all() + ] + if self.powerporttemplates.exists(): + data['power-ports'] = [ + { + 'name': c.name, + 'type': c.type, + 'maximum_draw': c.maximum_draw, + 'allocated_draw': c.allocated_draw, + 'label': c.label, + 'description': c.description, + } + for c in self.powerporttemplates.all() + ] + if self.poweroutlettemplates.exists(): + data['power-outlets'] = [ + { + 'name': c.name, + 'type': c.type, + 'power_port': c.power_port.name if c.power_port else None, + 'feed_leg': c.feed_leg, + 'label': c.label, + 'description': c.description, + } + for c in self.poweroutlettemplates.all() + ] + if self.interfacetemplates.exists(): + data['interfaces'] = [ + { + 'name': c.name, + 'type': c.type, + 'mgmt_only': c.mgmt_only, + 'label': c.label, + 'description': c.description, + } + for c in self.interfacetemplates.all() + ] + if self.frontporttemplates.exists(): + data['front-ports'] = [ + { + 'name': c.name, + 'type': c.type, + 'rear_port': c.rear_port.name, + 'rear_port_position': c.rear_port_position, + 'label': c.label, + 'description': c.description, + } + for c in self.frontporttemplates.all() + ] + if self.rearporttemplates.exists(): + data['rear-ports'] = [ + { + 'name': c.name, + 'type': c.type, + 'positions': c.positions, + 'label': c.label, + 'description': c.description, + } + for c in self.rearporttemplates.all() + ] + + return yaml.dump(dict(data), sort_keys=False) + + # # Devices # diff --git a/netbox/dcim/tables/__init__.py b/netbox/dcim/tables/__init__.py index 825e60d57..688b8771c 100644 --- a/netbox/dcim/tables/__init__.py +++ b/netbox/dcim/tables/__init__.py @@ -6,6 +6,7 @@ from dcim.models import ConsolePort, Interface, PowerPort from .cables import * from .devices import * from .devicetypes import * +from .moduletypes import * from .power import * from .racks import * from .sites import * diff --git a/netbox/dcim/tables/moduletypes.py b/netbox/dcim/tables/moduletypes.py new file mode 100644 index 000000000..23bf2e965 --- /dev/null +++ b/netbox/dcim/tables/moduletypes.py @@ -0,0 +1,34 @@ +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/tests/test_api.py b/netbox/dcim/tests/test_api.py index 2f68b0fbf..597b6d50b 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -470,6 +470,45 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase): ] +class ModuleTypeTest(APIViewTestCases.APIViewTestCase): + model = ModuleType + brief_fields = ['display', 'id', 'manufacturer', 'model', 'url'] + bulk_update_data = { + 'part_number': 'ABC123', + } + + @classmethod + def setUpTestData(cls): + + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + module_types = ( + ModuleType(manufacturer=manufacturers[0], model='Module Type 1'), + ModuleType(manufacturer=manufacturers[0], model='Module Type 2'), + ModuleType(manufacturer=manufacturers[0], model='Module Type 3'), + ) + ModuleType.objects.bulk_create(module_types) + + cls.create_data = [ + { + 'manufacturer': manufacturers[1].pk, + 'model': 'Module Type 4', + }, + { + 'manufacturer': manufacturers[1].pk, + 'model': 'Module Type 5', + }, + { + 'manufacturer': manufacturers[1].pk, + 'model': 'Module Type 6', + }, + ] + + class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): model = ConsolePortTemplate brief_fields = ['display', 'id', 'name', 'url'] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index c35739320..4fd1286da 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -773,6 +773,110 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) +class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ModuleType.objects.all() + filterset = ModuleTypeFilterSet + + @classmethod + def setUpTestData(cls): + + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + module_types = ( + ModuleType(manufacturer=manufacturers[0], model='Model 1', part_number='Part Number 1'), + ModuleType(manufacturer=manufacturers[1], model='Model 2', part_number='Part Number 2'), + ModuleType(manufacturer=manufacturers[2], model='Model 3', part_number='Part Number 3'), + ) + ModuleType.objects.bulk_create(module_types) + + # Add component templates for filtering + ConsolePortTemplate.objects.bulk_create(( + ConsolePortTemplate(module_type=module_types[0], name='Console Port 1'), + ConsolePortTemplate(module_type=module_types[1], name='Console Port 2'), + )) + ConsoleServerPortTemplate.objects.bulk_create(( + ConsoleServerPortTemplate(module_type=module_types[0], name='Console Server Port 1'), + ConsoleServerPortTemplate(module_type=module_types[1], name='Console Server Port 2'), + )) + PowerPortTemplate.objects.bulk_create(( + PowerPortTemplate(module_type=module_types[0], name='Power Port 1'), + PowerPortTemplate(module_type=module_types[1], name='Power Port 2'), + )) + PowerOutletTemplate.objects.bulk_create(( + PowerOutletTemplate(module_type=module_types[0], name='Power Outlet 1'), + PowerOutletTemplate(module_type=module_types[1], name='Power Outlet 2'), + )) + InterfaceTemplate.objects.bulk_create(( + InterfaceTemplate(module_type=module_types[0], name='Interface 1'), + InterfaceTemplate(module_type=module_types[1], name='Interface 2'), + )) + rear_ports = ( + RearPortTemplate(module_type=module_types[0], name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C), + RearPortTemplate(module_type=module_types[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C), + ) + RearPortTemplate.objects.bulk_create(rear_ports) + FrontPortTemplate.objects.bulk_create(( + FrontPortTemplate(module_type=module_types[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]), + FrontPortTemplate(module_type=module_types[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]), + )) + + def test_model(self): + params = {'model': ['Model 1', 'Model 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_part_number(self): + params = {'part_number': ['Part Number 1', 'Part Number 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + 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(), 2) + params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_console_ports(self): + params = {'console_ports': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'console_ports': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_console_server_ports(self): + params = {'console_server_ports': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'console_server_ports': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_power_ports(self): + params = {'power_ports': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'power_ports': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_power_outlets(self): + params = {'power_outlets': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'power_outlets': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_interfaces(self): + params = {'interfaces': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'interfaces': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_pass_through_ports(self): + params = {'pass_through_ports': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'pass_through_ports': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + class ConsolePortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConsolePortTemplate.objects.all() filterset = ConsolePortTemplateFilterSet diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 7f93c10a2..6094fe739 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -591,7 +591,7 @@ model: TEST-1000 slug: test-1000 u_height: 2 subdevice_role: parent -comments: test comment +comments: Test comment console-ports: - name: Console Port 1 type: de-9 @@ -686,53 +686,53 @@ device-bays: response = self.client.post(reverse('dcim:devicetype_import'), data=form_data, follow=True) self.assertHttpStatus(response, 200) - dt = DeviceType.objects.get(model='TEST-1000') - self.assertEqual(dt.comments, 'test comment') + device_type = DeviceType.objects.get(model='TEST-1000') + self.assertEqual(device_type.comments, 'Test comment') # Verify all of the components were created - self.assertEqual(dt.consoleporttemplates.count(), 3) + self.assertEqual(device_type.consoleporttemplates.count(), 3) cp1 = ConsolePortTemplate.objects.first() self.assertEqual(cp1.name, 'Console Port 1') self.assertEqual(cp1.type, ConsolePortTypeChoices.TYPE_DE9) - self.assertEqual(dt.consoleserverporttemplates.count(), 3) + self.assertEqual(device_type.consoleserverporttemplates.count(), 3) csp1 = ConsoleServerPortTemplate.objects.first() self.assertEqual(csp1.name, 'Console Server Port 1') self.assertEqual(csp1.type, ConsolePortTypeChoices.TYPE_RJ45) - self.assertEqual(dt.powerporttemplates.count(), 3) + self.assertEqual(device_type.powerporttemplates.count(), 3) pp1 = PowerPortTemplate.objects.first() self.assertEqual(pp1.name, 'Power Port 1') self.assertEqual(pp1.type, PowerPortTypeChoices.TYPE_IEC_C14) - self.assertEqual(dt.poweroutlettemplates.count(), 3) + self.assertEqual(device_type.poweroutlettemplates.count(), 3) po1 = PowerOutletTemplate.objects.first() self.assertEqual(po1.name, 'Power Outlet 1') self.assertEqual(po1.type, PowerOutletTypeChoices.TYPE_IEC_C13) self.assertEqual(po1.power_port, pp1) self.assertEqual(po1.feed_leg, PowerOutletFeedLegChoices.FEED_LEG_A) - self.assertEqual(dt.interfacetemplates.count(), 3) + self.assertEqual(device_type.interfacetemplates.count(), 3) iface1 = InterfaceTemplate.objects.first() self.assertEqual(iface1.name, 'Interface 1') self.assertEqual(iface1.type, InterfaceTypeChoices.TYPE_1GE_FIXED) self.assertTrue(iface1.mgmt_only) - self.assertEqual(dt.rearporttemplates.count(), 3) + self.assertEqual(device_type.rearporttemplates.count(), 3) rp1 = RearPortTemplate.objects.first() self.assertEqual(rp1.name, 'Rear Port 1') - self.assertEqual(dt.frontporttemplates.count(), 3) + self.assertEqual(device_type.frontporttemplates.count(), 3) fp1 = FrontPortTemplate.objects.first() self.assertEqual(fp1.name, 'Front Port 1') self.assertEqual(fp1.rear_port, rp1) self.assertEqual(fp1.rear_port_position, 1) - self.assertEqual(dt.modulebaytemplates.count(), 3) + self.assertEqual(device_type.modulebaytemplates.count(), 3) db1 = ModuleBayTemplate.objects.first() self.assertEqual(db1.name, 'Module Bay 1') - self.assertEqual(dt.devicebaytemplates.count(), 3) + self.assertEqual(device_type.devicebaytemplates.count(), 3) db1 = DeviceBayTemplate.objects.first() self.assertEqual(db1.name, 'Device Bay 1') @@ -741,7 +741,7 @@ device-bays: self.add_permissions('dcim.view_devicetype') # Test default YAML export - response = self.client.get('{}?export'.format(url)) + response = self.client.get(f'{url}?export') self.assertEqual(response.status_code, 200) data = list(yaml.load_all(response.content, Loader=yaml.SafeLoader)) self.assertEqual(len(data), 3) @@ -754,6 +754,300 @@ device-bays: self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8') +# TODO: Change base class to PrimaryObjectViewTestCase +# Blocked by absence of bulk import view for ModuleTypes +class ModuleTypeTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.GetObjectChangelogViewTestCase, + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): + model = ModuleType + + @classmethod + def setUpTestData(cls): + + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2') + ) + Manufacturer.objects.bulk_create(manufacturers) + + ModuleType.objects.bulk_create([ + ModuleType(model='Module Type 1', manufacturer=manufacturers[0]), + ModuleType(model='Module Type 2', manufacturer=manufacturers[0]), + ModuleType(model='Module Type 3', manufacturer=manufacturers[0]), + ]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'manufacturer': manufacturers[1].pk, + 'model': 'Device Type X', + 'part_number': '123ABC', + 'comments': 'Some comments', + 'tags': [t.pk for t in tags], + } + + cls.bulk_edit_data = { + 'manufacturer': manufacturers[1].pk, + 'part_number': '456DEF', + } + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_moduletype_consoleports(self): + moduletype = ModuleType.objects.first() + console_ports = ( + ConsolePortTemplate(module_type=moduletype, name='Console Port 1'), + ConsolePortTemplate(module_type=moduletype, name='Console Port 2'), + ConsolePortTemplate(module_type=moduletype, name='Console Port 3'), + ) + ConsolePortTemplate.objects.bulk_create(console_ports) + + url = reverse('dcim:moduletype_consoleports', kwargs={'pk': moduletype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_moduletype_consoleserverports(self): + moduletype = ModuleType.objects.first() + console_server_ports = ( + ConsoleServerPortTemplate(module_type=moduletype, name='Console Server Port 1'), + ConsoleServerPortTemplate(module_type=moduletype, name='Console Server Port 2'), + ConsoleServerPortTemplate(module_type=moduletype, name='Console Server Port 3'), + ) + ConsoleServerPortTemplate.objects.bulk_create(console_server_ports) + + url = reverse('dcim:moduletype_consoleserverports', kwargs={'pk': moduletype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_moduletype_powerports(self): + moduletype = ModuleType.objects.first() + power_ports = ( + PowerPortTemplate(module_type=moduletype, name='Power Port 1'), + PowerPortTemplate(module_type=moduletype, name='Power Port 2'), + PowerPortTemplate(module_type=moduletype, name='Power Port 3'), + ) + PowerPortTemplate.objects.bulk_create(power_ports) + + url = reverse('dcim:moduletype_powerports', kwargs={'pk': moduletype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_moduletype_poweroutlets(self): + moduletype = ModuleType.objects.first() + power_outlets = ( + PowerOutletTemplate(module_type=moduletype, name='Power Outlet 1'), + PowerOutletTemplate(module_type=moduletype, name='Power Outlet 2'), + PowerOutletTemplate(module_type=moduletype, name='Power Outlet 3'), + ) + PowerOutletTemplate.objects.bulk_create(power_outlets) + + url = reverse('dcim:moduletype_poweroutlets', kwargs={'pk': moduletype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_moduletype_interfaces(self): + moduletype = ModuleType.objects.first() + interfaces = ( + InterfaceTemplate(module_type=moduletype, name='Interface 1'), + InterfaceTemplate(module_type=moduletype, name='Interface 2'), + InterfaceTemplate(module_type=moduletype, name='Interface 3'), + ) + InterfaceTemplate.objects.bulk_create(interfaces) + + url = reverse('dcim:moduletype_interfaces', kwargs={'pk': moduletype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_moduletype_rearports(self): + moduletype = ModuleType.objects.first() + rear_ports = ( + RearPortTemplate(module_type=moduletype, name='Rear Port 1'), + RearPortTemplate(module_type=moduletype, name='Rear Port 2'), + RearPortTemplate(module_type=moduletype, name='Rear Port 3'), + ) + RearPortTemplate.objects.bulk_create(rear_ports) + + url = reverse('dcim:moduletype_rearports', kwargs={'pk': moduletype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_moduletype_frontports(self): + moduletype = ModuleType.objects.first() + rear_ports = ( + RearPortTemplate(module_type=moduletype, name='Rear Port 1'), + RearPortTemplate(module_type=moduletype, name='Rear Port 2'), + RearPortTemplate(module_type=moduletype, name='Rear Port 3'), + ) + RearPortTemplate.objects.bulk_create(rear_ports) + front_ports = ( + FrontPortTemplate(module_type=moduletype, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1), + FrontPortTemplate(module_type=moduletype, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1), + FrontPortTemplate(module_type=moduletype, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1), + ) + FrontPortTemplate.objects.bulk_create(front_ports) + + url = reverse('dcim:moduletype_frontports', kwargs={'pk': moduletype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_import_objects(self): + """ + Custom import test for YAML-based imports (versus CSV) + """ + IMPORT_DATA = """ +manufacturer: Generic +model: TEST-1000 +comments: Test comment +console-ports: + - name: Console Port 1 + type: de-9 + - name: Console Port 2 + type: de-9 + - name: Console Port 3 + type: de-9 +console-server-ports: + - name: Console Server Port 1 + type: rj-45 + - name: Console Server Port 2 + type: rj-45 + - name: Console Server Port 3 + type: rj-45 +power-ports: + - name: Power Port 1 + type: iec-60320-c14 + - name: Power Port 2 + type: iec-60320-c14 + - name: Power Port 3 + type: iec-60320-c14 +power-outlets: + - name: Power Outlet 1 + type: iec-60320-c13 + power_port: Power Port 1 + feed_leg: A + - name: Power Outlet 2 + type: iec-60320-c13 + power_port: Power Port 1 + feed_leg: A + - name: Power Outlet 3 + type: iec-60320-c13 + power_port: Power Port 1 + feed_leg: A +interfaces: + - name: Interface 1 + type: 1000base-t + mgmt_only: true + - name: Interface 2 + type: 1000base-t + - name: Interface 3 + type: 1000base-t +rear-ports: + - name: Rear Port 1 + type: 8p8c + - name: Rear Port 2 + type: 8p8c + - name: Rear Port 3 + type: 8p8c +front-ports: + - name: Front Port 1 + type: 8p8c + rear_port: Rear Port 1 + - name: Front Port 2 + type: 8p8c + rear_port: Rear Port 2 + - name: Front Port 3 + type: 8p8c + rear_port: Rear Port 3 +""" + + # Create the manufacturer + Manufacturer(name='Generic', slug='generic').save() + + # Add all required permissions to the test user + self.add_permissions( + 'dcim.view_moduletype', + 'dcim.add_moduletype', + 'dcim.add_consoleporttemplate', + 'dcim.add_consoleserverporttemplate', + 'dcim.add_powerporttemplate', + 'dcim.add_poweroutlettemplate', + 'dcim.add_interfacetemplate', + 'dcim.add_frontporttemplate', + 'dcim.add_rearporttemplate', + ) + + form_data = { + 'data': IMPORT_DATA, + 'format': 'yaml' + } + response = self.client.post(reverse('dcim:moduletype_import'), data=form_data, follow=True) + self.assertHttpStatus(response, 200) + + module_type = ModuleType.objects.get(model='TEST-1000') + self.assertEqual(module_type.comments, 'Test comment') + + # Verify all the components were created + self.assertEqual(module_type.consoleporttemplates.count(), 3) + cp1 = ConsolePortTemplate.objects.first() + self.assertEqual(cp1.name, 'Console Port 1') + self.assertEqual(cp1.type, ConsolePortTypeChoices.TYPE_DE9) + + self.assertEqual(module_type.consoleserverporttemplates.count(), 3) + csp1 = ConsoleServerPortTemplate.objects.first() + self.assertEqual(csp1.name, 'Console Server Port 1') + self.assertEqual(csp1.type, ConsolePortTypeChoices.TYPE_RJ45) + + self.assertEqual(module_type.powerporttemplates.count(), 3) + pp1 = PowerPortTemplate.objects.first() + self.assertEqual(pp1.name, 'Power Port 1') + self.assertEqual(pp1.type, PowerPortTypeChoices.TYPE_IEC_C14) + + self.assertEqual(module_type.poweroutlettemplates.count(), 3) + po1 = PowerOutletTemplate.objects.first() + self.assertEqual(po1.name, 'Power Outlet 1') + self.assertEqual(po1.type, PowerOutletTypeChoices.TYPE_IEC_C13) + self.assertEqual(po1.power_port, pp1) + self.assertEqual(po1.feed_leg, PowerOutletFeedLegChoices.FEED_LEG_A) + + self.assertEqual(module_type.interfacetemplates.count(), 3) + iface1 = InterfaceTemplate.objects.first() + self.assertEqual(iface1.name, 'Interface 1') + self.assertEqual(iface1.type, InterfaceTypeChoices.TYPE_1GE_FIXED) + self.assertTrue(iface1.mgmt_only) + + self.assertEqual(module_type.rearporttemplates.count(), 3) + rp1 = RearPortTemplate.objects.first() + self.assertEqual(rp1.name, 'Rear Port 1') + + self.assertEqual(module_type.frontporttemplates.count(), 3) + fp1 = FrontPortTemplate.objects.first() + self.assertEqual(fp1.name, 'Front Port 1') + self.assertEqual(fp1.rear_port, rp1) + self.assertEqual(fp1.rear_port_position, 1) + + def test_export_objects(self): + url = reverse('dcim:moduletype_list') + self.add_permissions('dcim.view_moduletype') + + # Test default YAML export + response = self.client.get(f'{url}?export') + self.assertEqual(response.status_code, 200) + data = list(yaml.load_all(response.content, Loader=yaml.SafeLoader)) + self.assertEqual(len(data), 3) + self.assertEqual(data[0]['manufacturer'], 'Manufacturer 1') + self.assertEqual(data[0]['model'], 'Module Type 1') + + # Test table-based export + response = self.client.get(f'{url}?export=table') + self.assertHttpStatus(response, 200) + self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8') + + # # DeviceType components # diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index dbde8e348..e1c1e200f 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -120,6 +120,25 @@ urlpatterns = [ path('device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), path('device-types//journal/', ObjectJournalView.as_view(), name='devicetype_journal', kwargs={'model': DeviceType}), + # Module types + path('module-types/', views.ModuleTypeListView.as_view(), name='moduletype_list'), + path('module-types/add/', views.ModuleTypeEditView.as_view(), name='moduletype_add'), + path('module-types/import/', views.ModuleTypeImportView.as_view(), name='moduletype_import'), + path('module-types/edit/', views.ModuleTypeBulkEditView.as_view(), name='moduletype_bulk_edit'), + path('module-types/delete/', views.ModuleTypeBulkDeleteView.as_view(), name='moduletype_bulk_delete'), + path('module-types//', views.ModuleTypeView.as_view(), name='moduletype'), + path('module-types//console-ports/', views.ModuleTypeConsolePortsView.as_view(), name='moduletype_consoleports'), + path('module-types//console-server-ports/', views.ModuleTypeConsoleServerPortsView.as_view(), name='moduletype_consoleserverports'), + path('module-types//power-ports/', views.ModuleTypePowerPortsView.as_view(), name='moduletype_powerports'), + path('module-types//power-outlets/', views.ModuleTypePowerOutletsView.as_view(), name='moduletype_poweroutlets'), + path('module-types//interfaces/', views.ModuleTypeInterfacesView.as_view(), name='moduletype_interfaces'), + path('module-types//front-ports/', views.ModuleTypeFrontPortsView.as_view(), name='moduletype_frontports'), + path('module-types//rear-ports/', views.ModuleTypeRearPortsView.as_view(), name='moduletype_rearports'), + path('module-types//edit/', views.ModuleTypeEditView.as_view(), name='moduletype_edit'), + path('module-types//delete/', views.ModuleTypeDeleteView.as_view(), name='moduletype_delete'), + path('module-types//changelog/', ObjectChangeLogView.as_view(), name='moduletype_changelog', kwargs={'model': ModuleType}), + path('module-types//journal/', ObjectJournalView.as_view(), name='moduletype_journal', kwargs={'model': ModuleType}), + # Console port templates path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'), path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index fed6ea31d..15e7d2406 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, ModuleBay, ModuleBayTemplate, PathEndpoint, Platform, PowerFeed, PowerOutlet, - PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, - RearPortTemplate, Region, Site, SiteGroup, VirtualChassis, + InventoryItem, Manufacturer, ModuleBay, ModuleBayTemplate, ModuleType, PathEndpoint, Platform, PowerFeed, + PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, Location, RackReservation, + RackRole, RearPort, RearPortTemplate, Region, Site, SiteGroup, VirtualChassis, ) @@ -56,6 +56,14 @@ class DeviceTypeComponentsView(DeviceComponentsView): return self.child_model.objects.restrict(request.user, 'view').filter(device_type=parent) +class ModuleTypeComponentsView(DeviceComponentsView): + queryset = ModuleType.objects.all() + template_name = 'dcim/moduletype/component_templates.html' + + def get_children(self, request, parent): + return self.child_model.objects.restrict(request.user, 'view').filter(module_type=parent) + + class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ An extendable view for disconnection console/power/interface components in bulk. @@ -902,6 +910,123 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView): table = tables.DeviceTypeTable +# +# Module types +# + +class ModuleTypeListView(generic.ObjectListView): + queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( + # instance_count=count_related(Module, 'module_type') + ) + filterset = filtersets.ModuleTypeFilterSet + filterset_form = forms.ModuleTypeFilterForm + table = tables.ModuleTypeTable + + +class ModuleTypeView(generic.ObjectView): + queryset = ModuleType.objects.prefetch_related('manufacturer') + + def get_extra_context(self, request, instance): + # instance_count = Module.objects.restrict(request.user).filter(device_type=instance).count() + + return { + # 'instance_count': instance_count, + 'active_tab': 'moduletype', + } + + +class ModuleTypeConsolePortsView(ModuleTypeComponentsView): + child_model = ConsolePortTemplate + table = tables.ConsolePortTemplateTable + filterset = filtersets.ConsolePortTemplateFilterSet + + +class ModuleTypeConsoleServerPortsView(ModuleTypeComponentsView): + child_model = ConsoleServerPortTemplate + table = tables.ConsoleServerPortTemplateTable + filterset = filtersets.ConsoleServerPortTemplateFilterSet + + +class ModuleTypePowerPortsView(ModuleTypeComponentsView): + child_model = PowerPortTemplate + table = tables.PowerPortTemplateTable + filterset = filtersets.PowerPortTemplateFilterSet + + +class ModuleTypePowerOutletsView(ModuleTypeComponentsView): + child_model = PowerOutletTemplate + table = tables.PowerOutletTemplateTable + filterset = filtersets.PowerOutletTemplateFilterSet + + +class ModuleTypeInterfacesView(ModuleTypeComponentsView): + child_model = InterfaceTemplate + table = tables.InterfaceTemplateTable + filterset = filtersets.InterfaceTemplateFilterSet + + +class ModuleTypeFrontPortsView(ModuleTypeComponentsView): + child_model = FrontPortTemplate + table = tables.FrontPortTemplateTable + filterset = filtersets.FrontPortTemplateFilterSet + + +class ModuleTypeRearPortsView(ModuleTypeComponentsView): + child_model = RearPortTemplate + table = tables.RearPortTemplateTable + filterset = filtersets.RearPortTemplateFilterSet + + +class ModuleTypeEditView(generic.ObjectEditView): + queryset = ModuleType.objects.all() + model_form = forms.ModuleTypeForm + + +class ModuleTypeDeleteView(generic.ObjectDeleteView): + queryset = ModuleType.objects.all() + + +class ModuleTypeImportView(generic.ObjectImportView): + additional_permissions = [ + 'dcim.add_moduletype', + 'dcim.add_consoleporttemplate', + 'dcim.add_consoleserverporttemplate', + 'dcim.add_powerporttemplate', + 'dcim.add_poweroutlettemplate', + 'dcim.add_interfacetemplate', + 'dcim.add_frontporttemplate', + 'dcim.add_rearporttemplate', + ] + queryset = ModuleType.objects.all() + model_form = forms.ModuleTypeImportForm + related_object_forms = OrderedDict(( + ('console-ports', forms.ConsolePortTemplateImportForm), + ('console-server-ports', forms.ConsoleServerPortTemplateImportForm), + ('power-ports', forms.PowerPortTemplateImportForm), + ('power-outlets', forms.PowerOutletTemplateImportForm), + ('interfaces', forms.InterfaceTemplateImportForm), + ('rear-ports', forms.RearPortTemplateImportForm), + ('front-ports', forms.FrontPortTemplateImportForm), + )) + + +class ModuleTypeBulkEditView(generic.BulkEditView): + queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( + # instance_count=count_related(Module, 'module_type') + ) + filterset = filtersets.ModuleTypeFilterSet + table = tables.ModuleTypeTable + form = forms.ModuleTypeBulkEditForm + + +class ModuleTypeBulkDeleteView(generic.BulkDeleteView): + queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( + # instance_count=count_related(Module, 'module_type') + ) + filterset = filtersets.ModuleTypeFilterSet + table = tables.ModuleTypeTable + + # # Console port templates # diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 71be861f8..a2bec4710 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -148,6 +148,7 @@ DEVICES_MENU = Menu( label='Device Types', items=( get_model_item('dcim', 'devicetype', 'Device Types'), + get_model_item('dcim', 'moduletype', 'Module Types'), get_model_item('dcim', 'manufacturer', 'Manufacturers'), ), ), diff --git a/netbox/templates/dcim/moduletype.html b/netbox/templates/dcim/moduletype.html new file mode 100644 index 000000000..6bae7012b --- /dev/null +++ b/netbox/templates/dcim/moduletype.html @@ -0,0 +1,48 @@ +{% extends 'dcim/moduletype/base.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
Module Type
+
+ + + + + + + + + + + + + + {% comment %} + + + + + {% endcomment %} +
Manufacturer{{ object.manufacturer }}
Model Name{{ object.model }}
Part Number{{ object.part_number|placeholder }}
Instances{{ instance_count }}
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/moduletype/base.html b/netbox/templates/dcim/moduletype/base.html new file mode 100644 index 000000000..563e23a7b --- /dev/null +++ b/netbox/templates/dcim/moduletype/base.html @@ -0,0 +1,108 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} + +{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block extra_controls %} + {% if perms.dcim.change_devicetype %} + + {% 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 %}