diff --git a/docs/models/dcim/module.md b/docs/models/dcim/module.md index 060c2b0942a..3091c7f0e8c 100644 --- a/docs/models/dcim/module.md +++ b/docs/models/dcim/module.md @@ -18,6 +18,10 @@ The [module bay](./modulebay.md) into which the module is installed. The [module type](./moduletype.md) which represents the physical make & model of hardware. By default, module components will be instantiated automatically from the module type when creating a new module. +### Profile + +The [module type profile](./moduletypeprofile.md) associated with the selected module type. Module list views and the REST API can be filtered by this related field. + ### Status The module's operational status. diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 339537b4c5c..6d243797852 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1537,6 +1537,19 @@ def _has_primary_ip(self, queryset, name, value): @register_filterset class ModuleFilterSet(PrimaryModelFilterSet): + profile_id = django_filters.ModelMultipleChoiceFilter( + field_name='module_type__profile', + queryset=ModuleTypeProfile.objects.all(), + distinct=False, + label=_('Profile (ID)'), + ) + profile = django_filters.ModelMultipleChoiceFilter( + field_name='module_type__profile__name', + queryset=ModuleTypeProfile.objects.all(), + distinct=False, + to_field_name='name', + label=_('Profile (name)'), + ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='module_type__manufacturer', queryset=Manufacturer.objects.all(), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index ebd380206e0..82ee655b847 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -687,6 +687,7 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm): profile_id = DynamicModelMultipleChoiceField( queryset=ModuleTypeProfile.objects.all(), required=False, + null_option='None', label=_('Profile') ) manufacturer_id = DynamicModelMultipleChoiceField( @@ -1040,9 +1041,13 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryM fieldsets = ( FieldSet('q', 'filter_id', 'tag'), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')), - FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')), + FieldSet( + 'profile_id', 'manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', + name=_('Hardware') + ), FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), ) + selector_fields = ('filter_id', 'q', 'manufacturer_id', 'profile_id') device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, @@ -1095,10 +1100,17 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryM required=False, label=_('Manufacturer') ) + profile_id = DynamicModelMultipleChoiceField( + queryset=ModuleTypeProfile.objects.all(), + required=False, + null_option='None', + label=_('Profile') + ) module_type_id = DynamicModelMultipleChoiceField( queryset=ModuleType.objects.all(), required=False, query_params={ + 'profile_id': '$profile_id', 'manufacturer_id': '$manufacturer_id' }, label=_('Type') diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 948c7a664e7..67959e92e04 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -93,6 +93,11 @@ class ModuleTable(PrimaryModelTable): accessor=tables.A('module_type__manufacturer'), linkify=True ) + profile = tables.Column( + verbose_name=_('Profile'), + accessor=tables.A('module_type__profile'), + linkify=True, + ) module_type = tables.Column( verbose_name=_('Module Type'), linkify=True @@ -107,7 +112,8 @@ class ModuleTable(PrimaryModelTable): class Meta(PrimaryModelTable.Meta): model = Module fields = ( - 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag', + 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'profile', 'module_type', 'status', + 'serial', 'asset_tag', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 61928e5626e..3c4c0cabcea 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1,5 +1,6 @@ import json +from django.conf import settings from django.test import override_settings, tag from django.urls import reverse from django.utils.translation import gettext as _ @@ -1640,16 +1641,23 @@ class ModuleTest(APIViewTestCases.APIViewTestCase): bulk_update_data = { 'serial': '1234ABCD', } - user_permissions = ('dcim.view_modulebay', 'dcim.view_moduletype', 'dcim.view_device') + user_permissions = ( + 'dcim.view_modulebay', 'dcim.view_moduletype', 'dcim.view_moduletypeprofile', 'dcim.view_device' + ) @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + profiles = ( + ModuleTypeProfile(name='Test CPU'), + ModuleTypeProfile(name='Test Hard disk'), + ) + ModuleTypeProfile.objects.bulk_create(profiles) 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 1', profile=profiles[0]), + ModuleType(manufacturer=manufacturer, model='Module Type 2', profile=profiles[1]), ModuleType(manufacturer=manufacturer, model='Module Type 3'), ) ModuleType.objects.bulk_create(module_types) @@ -1699,6 +1707,36 @@ def setUpTestData(cls): }, ] + def test_list_objects_by_profile_id(self): + profiles = ModuleTypeProfile.objects.filter(name__startswith='Test').order_by('name') + self.add_permissions('dcim.view_module') + response = self.client.get(self._get_list_url(), {'profile_id': [profiles[0].pk]}, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data['results']), 1) + + response = self.client.get(self._get_list_url(), {'profile_id': [profiles[1].pk]}, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data['results']), 1) + + response = self.client.get( + self._get_list_url(), + {'profile_id': [settings.FILTERS_NULL_CHOICE_VALUE]}, + **self.header, + ) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data['results']), 1) + + def test_list_objects_by_profile(self): + profiles = ModuleTypeProfile.objects.filter(name__startswith='Test').order_by('name') + self.add_permissions('dcim.view_module') + response = self.client.get(self._get_list_url(), {'profile': [profiles[0].name]}, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data['results']), 1) + + response = self.client.get(self._get_list_url(), {'profile': [profiles[1].name]}, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data['results']), 1) + class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index fb8340a6de5..ed4d7f87089 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.test import TestCase from circuits.models import Circuit, CircuitTermination, CircuitType, Provider @@ -3085,6 +3086,11 @@ def setUpTestData(cls): Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), ) Manufacturer.objects.bulk_create(manufacturers) + module_type_profiles = ( + ModuleTypeProfile(name='Test CPU'), + ModuleTypeProfile(name='Test Hard disk'), + ) + ModuleTypeProfile.objects.bulk_create(module_type_profiles) device_types = ( DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'), @@ -3148,8 +3154,8 @@ def setUpTestData(cls): Device.objects.bulk_create(devices) module_types = ( - ModuleType(manufacturer=manufacturers[0], model='Module Type 1'), - ModuleType(manufacturer=manufacturers[1], model='Module Type 2'), + ModuleType(manufacturer=manufacturers[0], model='Module Type 1', profile=module_type_profiles[0]), + ModuleType(manufacturer=manufacturers[1], model='Module Type 2', profile=module_type_profiles[1]), ModuleType(manufacturer=manufacturers[2], model='Module Type 3'), ) ModuleType.objects.bulk_create(module_types) @@ -3265,6 +3271,19 @@ def test_module_type(self): params = {'module_type': [module_types[0].model, module_types[1].model]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + def test_profile(self): + profiles = ModuleTypeProfile.objects.filter(name__startswith='Test').order_by('name') + params = {'profile_id': [profiles[0].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'profile': [profiles[0].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'profile_id': [profiles[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'profile': [profiles[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'profile_id': [settings.FILTERS_NULL_CHOICE_VALUE]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_tables.py b/netbox/dcim/tests/test_tables.py index debd8256f03..336aa186cff 100644 --- a/netbox/dcim/tests/test_tables.py +++ b/netbox/dcim/tests/test_tables.py @@ -70,6 +70,9 @@ class ModuleTypeTableTest(TableTestCases.StandardTableTestCase): class ModuleTableTest(TableTestCases.StandardTableTestCase): table = ModuleTable + def test_profile_column_available(self): + self.assertIn('profile', self.table.base_columns) + # # Devices diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index a67cea1273d..d5419fba70d 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -2424,13 +2424,14 @@ class ModuleTestCase( @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + module_type_profile = ModuleTypeProfile.objects.create(name='Module Type Profile 1') 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 1', profile=module_type_profile), ModuleType(manufacturer=manufacturer, model='Module Type 2'), ModuleType(manufacturer=manufacturer, model='Module Type 3'), ModuleType(manufacturer=manufacturer, model='Module Type 4'), @@ -2489,6 +2490,12 @@ def setUpTestData(cls): f"{modules[2].pk},offline,Serial 1", ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_module_detail_includes_module_type_profile(self): + response = self.client.get(self._get_queryset().first().get_absolute_url()) + + self.assertContains(response, 'Module Type Profile 1') + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_module_component_replication(self): self.add_permissions('dcim.add_module') diff --git a/netbox/templates/dcim/panels/module_type.html b/netbox/templates/dcim/panels/module_type.html index 7fb90470be5..4107ed2f869 100644 --- a/netbox/templates/dcim/panels/module_type.html +++ b/netbox/templates/dcim/panels/module_type.html @@ -11,6 +11,10 @@