Closes #21710: Support multi-select fields for module type profile array enums (#22495)

This commit is contained in:
Jamie (Bear) Murphy 2026-06-26 17:19:18 +01:00 committed by GitHub
parent 4da65854e1
commit 2f87dab011
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 216 additions and 8 deletions

View File

@ -1,3 +1,5 @@
from collections.abc import Iterable, Mapping
import jsonschema
import yaml
from django.core.exceptions import ValidationError
@ -170,7 +172,10 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
attrs = {}
for name, options in self.profile.schema.get('properties', {}).items():
key = options.get('title', title(name))
attrs[key] = self.attribute_data.get(name)
value = self.attribute_data.get(name)
if isinstance(value, Iterable) and not isinstance(value, (str, bytes, Mapping)):
value = ', '.join(str(v) for v in value)
attrs[key] = value
return dict(sorted(attrs.items()))
def clean(self):

View File

@ -42,7 +42,7 @@
"modulebay:list_objects_with_permission": 21,
"modulebaytemplate:api_list_objects": 11,
"moduletype:api_list_objects": 14,
"moduletype:list_objects_with_permission": 21,
"moduletype:list_objects_with_permission": 22,
"moduletypeprofile:api_list_objects": 13,
"moduletypeprofile:list_objects_with_permission": 20,
"platform:api_list_objects": 13,

View File

@ -1,3 +1,6 @@
from unittest.mock import patch
from django import forms
from django.test import TestCase
from dcim.choices import (
@ -177,6 +180,51 @@ class DeviceTestCase(TestCase):
self.assertIn('position', form.errors)
class ModuleTypeFormTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
cls.profile = ModuleTypeProfile.objects.create(
name='Module Type Profile 1',
schema={
'properties': {
'media': {
'title': 'Media',
'type': 'array',
'items': {
'type': 'string',
'enum': ['copper', 'sfp', 'qsfp28'],
},
},
},
},
)
def test_enum_array_attribute_uses_multiselect_field(self):
form = ModuleTypeForm(data={
'manufacturer': self.manufacturer.pk,
'model': 'Module Type 1',
'profile': self.profile.pk,
'attr_media': ['copper', 'qsfp28'],
})
self.assertIsInstance(form.fields['attr_media'], forms.MultipleChoiceField)
self.assertEqual(
list(form.fields['attr_media'].choices),
[
('copper', 'copper'),
('sfp', 'sfp'),
('qsfp28', 'qsfp28'),
],
)
with patch('utilities.forms.fields.dynamic.get_action_url', return_value='/'):
self.assertTrue(form.is_valid(), form.errors)
module_type = form.save()
self.assertEqual(module_type.attribute_data, {'media': ['copper', 'qsfp28']})
class VCPositionTokenFormTestCase(TestCase):
@classmethod

View File

@ -179,6 +179,45 @@ class ModuleTypeTestCase(TestCase):
module_type.refresh_from_db()
self.assertEqual(module_type.interface_template_count, 1)
def test_attributes(self):
"""
ModuleType.attributes should normalize iterable values into strings for presentation.
"""
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
profile = ModuleTypeProfile.objects.create(
name='Module Type Profile 1',
schema={
'properties': {
'media': {
'title': 'Media',
'type': 'array',
'items': {'type': 'string'},
},
'enabled': {
'title': 'Enabled',
'type': 'boolean',
},
},
},
)
module_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='Module Type 1',
profile=profile,
attribute_data={
'media': ['sfp', 'qsfp28'],
'enabled': True,
},
)
self.assertEqual(
module_type.attributes,
{
'Enabled': True,
'Media': 'sfp, qsfp28',
},
)
class RackTypeTestCase(TestCase):

View File

@ -1226,6 +1226,19 @@ console-ports:
class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ModuleType
SCHEMA = {
'properties': {
'media': {
'title': 'Media',
'type': 'array',
'items': {
'type': 'string',
'enum': ['copper', 'sfp', 'qsfp28'],
},
},
},
}
@classmethod
def setUpTestData(cls):
@ -1235,8 +1248,15 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
Manufacturer.objects.bulk_create(manufacturers)
profile = ModuleTypeProfile.objects.create(name='Module Type Profile 1', schema=cls.SCHEMA)
module_types = ModuleType.objects.bulk_create([
ModuleType(model='Module Type 1', manufacturer=manufacturers[0]),
ModuleType(
model='Module Type 1',
manufacturer=manufacturers[0],
profile=profile,
attribute_data={'media': ['copper', 'qsfp28']},
),
ModuleType(model='Module Type 2', manufacturer=manufacturers[0]),
ModuleType(model='Module Type 3', manufacturer=manufacturers[0]),
])
@ -1319,6 +1339,19 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
super().test_bulk_import_objects_with_constrained_permission()
@tag('regression')
def test_get_object_renders_profile_attribute_lists(self):
self.add_permissions(
'dcim.view_moduletype',
'dcim.view_moduletypeprofile',
)
moduletype = ModuleType.objects.first()
response = self.client.get(moduletype.get_absolute_url())
self.assertHttpStatus(response, 200)
self.assertContains(response, 'Media')
self.assertContains(response, 'copper, qsfp28')
def test_moduletype_consoleports(self):
self.add_permissions('dcim.view_moduletype', 'dcim.view_consoleporttemplate')
moduletype = ModuleType.objects.first()
@ -2558,14 +2591,33 @@ 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')
module_type_profile = ModuleTypeProfile.objects.create(
name='Module Type Profile 1',
schema={
'properties': {
'media': {
'title': 'Media',
'type': 'array',
'items': {
'type': 'string',
'enum': ['copper', 'sfp', 'qsfp28'],
},
},
},
},
)
devices = (
create_test_device('Device 1'),
create_test_device('Device 2'),
)
module_types = (
ModuleType(manufacturer=manufacturer, model='Module Type 1', profile=module_type_profile),
ModuleType(
manufacturer=manufacturer,
model='Module Type 1',
profile=module_type_profile,
attribute_data={'media': ['copper', 'qsfp28']},
),
ModuleType(manufacturer=manufacturer, model='Module Type 2'),
ModuleType(manufacturer=manufacturer, model='Module Type 3'),
ModuleType(manufacturer=manufacturer, model='Module Type 4'),
@ -2634,6 +2686,19 @@ class ModuleTestCase(
self.assertContains(response, 'Module Type Profile 1')
@tag('regression')
def test_module_detail_renders_module_type_attribute_lists(self):
self.add_permissions(
'dcim.view_module',
'dcim.view_moduletype',
'dcim.view_moduletypeprofile',
)
response = self.client.get(self._get_queryset().first().get_absolute_url())
self.assertHttpStatus(response, 200)
self.assertContains(response, 'Media')
self.assertContains(response, 'copper, qsfp28')
def test_module_component_replication(self):
self.add_permissions(
'dcim.view_module',

View File

@ -94,7 +94,9 @@ class JSONSchemaProperty:
}
# Choices
if self.enum:
if self.type == PropertyTypeEnum.ARRAY.value and (item_enums := self.items.get('enum')):
field_kwargs['choices'] = [(v, v) for v in item_enums]
elif self.enum:
choices = [(v, v) for v in self.enum]
if not required:
choices = [(None, ''), *choices]
@ -102,8 +104,9 @@ class JSONSchemaProperty:
# Arrays
if self.type == PropertyTypeEnum.ARRAY.value:
items_type = self.items.get('type', PropertyTypeEnum.STRING.value)
field_kwargs['base_field'] = FORM_FIELDS[items_type]()
if not self.items.get('enum'):
items_type = self.items.get('type', PropertyTypeEnum.STRING.value)
field_kwargs['base_field'] = FORM_FIELDS[items_type]()
# String validation
if self.type == PropertyTypeEnum.STRING.value:
@ -135,6 +138,8 @@ class JSONSchemaProperty:
"""
Resolve the property's type (and string format, if specified) to the appropriate field class.
"""
if self.type == PropertyTypeEnum.ARRAY.value and self.items.get('enum'):
return forms.MultipleChoiceField
if self.enum:
if self.type == PropertyTypeEnum.ARRAY.value:
return forms.MultipleChoiceField

View File

@ -0,0 +1,46 @@
from django import forms
from django.contrib.postgres.forms import SimpleArrayField
from django.test import TestCase
from utilities.jsonschema import JSONSchemaProperty
class JSONSchemaPropertyTestCase(TestCase):
def test_array_enum_uses_multiple_choice_field(self):
prop = JSONSchemaProperty(
type='array',
title='Media',
items={
'type': 'string',
'enum': ['copper', 'sfp', 'qsfp28'],
},
)
field = prop.to_form_field('media')
self.assertIsInstance(field, forms.MultipleChoiceField)
self.assertEqual(
list(field.choices),
[
('copper', 'copper'),
('sfp', 'sfp'),
('qsfp28', 'qsfp28'),
],
)
self.assertEqual(field.clean(['copper', 'qsfp28']), ['copper', 'qsfp28'])
def test_plain_array_uses_simple_array_field(self):
prop = JSONSchemaProperty(
type='array',
title='Ports',
items={
'type': 'string',
},
)
field = prop.to_form_field('ports')
self.assertIsInstance(field, SimpleArrayField)
self.assertIsInstance(field.base_field, forms.CharField)
self.assertEqual(field.clean('ge-0/0/0,ge-0/0/1'), ['ge-0/0/0', 'ge-0/0/1'])