Просмотр исходного кода

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.
Jason Novinger 8 часов назад
Родитель
Сommit
5fc8c56de1

+ 27 - 0
netbox/dcim/tests/test_forms.py

@@ -228,6 +228,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 ModuleBayTemplateImportFormTestCase(TestCase):
 

+ 3 - 3
netbox/utilities/jsonschema.py

@@ -120,11 +120,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'] = [

+ 84 - 0
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
@@ -44,3 +45,86 @@ class JSONSchemaPropertyTestCase(TestCase):
         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'])
+
+    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)])