Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/models/dcim/module.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment on lines +21 to +24
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not have been added. There is no profile field on the Module model.

### Status

The module's operational status.
Expand Down
13 changes: 13 additions & 0 deletions netbox/dcim/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
14 changes: 13 additions & 1 deletion netbox/dcim/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,7 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
profile_id = DynamicModelMultipleChoiceField(
queryset=ModuleTypeProfile.objects.all(),
required=False,
null_option='None',
label=_('Profile')
)
manufacturer_id = DynamicModelMultipleChoiceField(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand Down
8 changes: 7 additions & 1 deletion netbox/dcim/tables/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = (
Expand Down
44 changes: 41 additions & 3 deletions netbox/dcim/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -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 _
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions netbox/dcim/tests/test_filtersets.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.conf import settings
from django.test import TestCase

from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions netbox/dcim/tests/test_forms.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.http import QueryDict
from django.test import TestCase

from dcim.choices import (
Expand Down Expand Up @@ -176,6 +177,16 @@ def test_non_racked_device_with_position(self):
self.assertIn('position', form.errors)


class ModuleFilterFormTestCase(TestCase):

def test_profile_filter_shows_null_choice_when_unselected(self):
form = ModuleFilterForm(QueryDict(''))
html = form['profile_id'].as_widget()

self.assertIn('value="null"', html)
self.assertIn('None', html)


class FrontPortTestCase(TestCase):

@classmethod
Expand Down
3 changes: 3 additions & 0 deletions netbox/dcim/tests/test_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions netbox/utilities/forms/widgets/modifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,14 @@ def get_context(self, name, value, attrs):
else:
self.original_widget.choices = [choice for choice in original_choices if choice[0] in values]
else:
# No selection - render empty select element
self.original_widget.choices = []
# No selection - render just the null option if one is configured, otherwise an empty select.
null_option = self.original_widget.attrs.get('data-null-option')
if null_option:
self.original_widget.choices = [
(settings.FILTERS_NULL_CHOICE_VALUE, null_option),
]
else:
self.original_widget.choices = []

# Get context from the original widget
original_context = self.original_widget.get_context(name, value, attrs)
Expand Down
12 changes: 12 additions & 0 deletions netbox/utilities/tests/test_filter_modifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@ def test_get_context_handles_null_selection(self):
self.assertIn(f'value="{null_value}"', html)
self.assertIn(null_label, html)

def test_get_context_shows_null_choice_when_unselected(self):
"""Widget should show the null choice even before any selection is made."""

null_value = settings.FILTERS_NULL_CHOICE_VALUE
null_label = settings.FILTERS_NULL_CHOICE_LABEL

form = DeviceFilterForm(QueryDict(''))
html = form['tenant_id'].as_widget()

self.assertIn(f'value="{null_value}"', html)
self.assertIn(null_label, html)

def test_get_context_handles_mixed_selection(self):
"""Widget should preserve both real objects and the 'null' choice together."""

Expand Down
Loading