Fixes #21711: Added support for filtering and viewing modules by their module type profile (#21900)

This commit is contained in:
Jamie (Bear) Murphy 2026-04-17 18:34:50 +01:00 committed by GitHub
parent 0d1e9d88a8
commit 87b17ff26d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 114 additions and 8 deletions

View File

@ -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.

View File

@ -1537,6 +1537,19 @@ class VirtualDeviceContextFilterSet(PrimaryModelFilterSet, TenancyFilterSet, Pri
@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(),

View File

@ -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')

View File

@ -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 = (

View File

@ -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 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
},
]
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

View File

@ -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 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
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 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
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 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
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)

View File

@ -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

View File

@ -2446,13 +2446,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'),
@ -2511,6 +2512,12 @@ class ModuleTestCase(
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')

View File

@ -11,6 +11,10 @@
<th scope="row">{% trans "Model" %}</th>
<td>{{ object.module_type|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Profile" %}</th>
<td>{{ object.module_type.profile|linkify|placeholder }}</td>
</tr>
{% for k, v in object.module_type.attributes.items %}
<tr>
<th scope="row">{{ k }}</th>