Fixes #23166: Apply zero-valued numeric bounds to profile attribute form fields

JSONSchemaProperty.to_form_field() guarded the numeric bounds on truthiness, so a
minimum or maximum of 0 never reached the form field. The UI accepted values the
model then rejected in ModuleType.clean(). Compare against None instead, matching
the string bounds directly above.

Skip the numeric branch entirely when the property carries an enum. Those resolve
to a ChoiceField, which accepts neither min_value nor max_value, so passing the
bounds through raised a TypeError. The zero-bound case reached this only once the
truthiness guard was removed. A non-zero bound alongside an enum failed before.
This commit is contained in:
Jason Novinger 2026-09-14 10:37:40 -05:00
parent 1793874dd9
commit 2bed744ec2
3 changed files with 114 additions and 3 deletions

View File

@ -229,6 +229,33 @@ class ModuleTypeFormTestCase(TestCase):
module_type = form.save()
self.assertEqual(module_type.attribute_data, {'media': ['copper', 'qsfp28']})
def test_zero_bound_attribute_is_enforced_by_the_form(self):
profile = ModuleTypeProfile.objects.create(
name='Module Type Profile 2',
schema={
'properties': {
'offset': {
'title': 'Offset',
'type': 'number',
'minimum': 0,
'maximum': 0,
},
},
},
)
form = ModuleTypeForm(data={
'manufacturer': self.manufacturer.pk,
'model': 'Module Type 2',
'profile': profile.pk,
'attr_offset': -5,
})
self.assertEqual(form.fields['attr_offset'].min_value, 0)
self.assertEqual(form.fields['attr_offset'].max_value, 0)
with patch('utilities.forms.fields.dynamic.get_action_url', return_value='/'):
self.assertFalse(form.is_valid())
self.assertIn('attr_offset', form.errors)
class ModuleTypeProfileDescriptionRenderingTestCase(TestCase):
"""

View File

@ -121,11 +121,11 @@ class JSONSchemaProperty:
]
# Integer/number validation
elif self.type in (PropertyTypeEnum.INTEGER.value, PropertyTypeEnum.NUMBER.value):
elif self.type in (PropertyTypeEnum.INTEGER.value, PropertyTypeEnum.NUMBER.value) and not self.enum:
field_kwargs['widget'] = forms.NumberInput(attrs={'step': 'any'})
if self.minimum:
if self.minimum is not None:
field_kwargs['min_value'] = self.minimum
if self.maximum:
if self.maximum is not None:
field_kwargs['max_value'] = self.maximum
if self.multipleOf:
field_kwargs['validators'] = [

View File

@ -1,5 +1,6 @@
from django import forms
from django.contrib.postgres.forms import SimpleArrayField
from django.core.exceptions import ValidationError
from django.test import TestCase
from utilities.jsonschema import JSONSchemaProperty
@ -45,6 +46,89 @@ class JSONSchemaPropertyTestCase(TestCase):
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'])
def test_zero_minimum_is_applied_to_form_field(self):
prop = JSONSchemaProperty(type='number', title='Offset', minimum=0)
field = prop.to_form_field('offset')
self.assertEqual(field.min_value, 0)
with self.assertRaises(ValidationError):
field.clean(-5)
self.assertEqual(field.clean(0), 0)
def test_zero_maximum_is_applied_to_form_field(self):
prop = JSONSchemaProperty(type='number', title='Offset', maximum=0)
field = prop.to_form_field('offset')
self.assertEqual(field.max_value, 0)
with self.assertRaises(ValidationError):
field.clean(5)
self.assertEqual(field.clean(0), 0)
def test_zero_bounds_are_applied_to_integer_form_field(self):
prop = JSONSchemaProperty(type='integer', title='Slots', minimum=0, maximum=0)
field = prop.to_form_field('slots')
self.assertEqual(field.min_value, 0)
self.assertEqual(field.max_value, 0)
with self.assertRaises(ValidationError):
field.clean(-1)
with self.assertRaises(ValidationError):
field.clean(1)
self.assertEqual(field.clean(0), 0)
def test_nonzero_bounds_are_applied_to_form_field(self):
prop = JSONSchemaProperty(type='number', title='Offset', minimum=1, maximum=10)
field = prop.to_form_field('offset')
self.assertEqual(field.min_value, 1)
self.assertEqual(field.max_value, 10)
with self.assertRaises(ValidationError):
field.clean(0)
with self.assertRaises(ValidationError):
field.clean(11)
def test_omitted_bounds_are_not_applied_to_form_field(self):
prop = JSONSchemaProperty(type='number', title='Offset')
field = prop.to_form_field('offset')
self.assertIsNone(field.min_value)
self.assertIsNone(field.max_value)
self.assertEqual(field.clean(-100), -100)
def test_numeric_enum_with_zero_bound_builds_choice_field(self):
"""A numeric property carrying both an enum and a zero bound resolves to a ChoiceField.
ChoiceField accepts neither min_value nor max_value, so the numeric bounds must not be
passed through when an enum is present.
"""
prop = JSONSchemaProperty(type='integer', title='Slots', enum=[0, 1, 2], minimum=0)
field = prop.to_form_field('slots')
self.assertIsInstance(field, forms.ChoiceField)
self.assertEqual(list(field.choices), [(None, ''), (0, 0), (1, 1), (2, 2)])
def test_numeric_enum_with_nonzero_bound_builds_choice_field(self):
prop = JSONSchemaProperty(type='integer', title='Slots', enum=[1, 2], minimum=1, maximum=2)
field = prop.to_form_field('slots')
self.assertIsInstance(field, forms.ChoiceField)
self.assertEqual(list(field.choices), [(None, ''), (1, 1), (2, 2)])
def test_numeric_enum_with_multiple_of_builds_choice_field(self):
prop = JSONSchemaProperty(type='integer', title='Slots', enum=[2, 4], multipleOf=2)
field = prop.to_form_field('slots')
self.assertIsInstance(field, forms.ChoiceField)
self.assertEqual(list(field.choices), [(None, ''), (2, 2), (4, 4)])
class JSONSchemaPropertyDescriptionSanitizationTestCase(TestCase):
"""