diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 88b31aeb5..8df753a6f 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -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): """ diff --git a/netbox/utilities/jsonschema.py b/netbox/utilities/jsonschema.py index 418eb300e..1608439e8 100644 --- a/netbox/utilities/jsonschema.py +++ b/netbox/utilities/jsonschema.py @@ -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'] = [ diff --git a/netbox/utilities/tests/test_jsonschema.py b/netbox/utilities/tests/test_jsonschema.py index 50210fc48..7600118d8 100644 --- a/netbox/utilities/tests/test_jsonschema.py +++ b/netbox/utilities/tests/test_jsonschema.py @@ -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): """