From 2bed744ec29ebbddb61924df0247695f11365905 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Mon, 14 Sep 2026 10:37:40 -0500 Subject: [PATCH] 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. --- netbox/dcim/tests/test_forms.py | 27 ++++++++ netbox/utilities/jsonschema.py | 6 +- netbox/utilities/tests/test_jsonschema.py | 84 +++++++++++++++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) 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): """