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

Resolve the form field class before applying schema constraints

to_form_field() applied each constraint based on the property's declared type,
but an enum or a string format redirects the property to a field class which
accepts a narrower set of keyword arguments. A string with "format": "date" and
a minLength resolved to a DateField and raised TypeError on construction, taking
down the ModuleType form with it. The enum cases failed the same way.

Resolve field_class up front and gate each constraint on what that class accepts.
EmailField, URLField and UUIDField subclass CharField, so they keep their length
bounds, and FloatField subclasses IntegerField, so both keep their value bounds.
Validators are accepted by every field class, so pattern and multipleOf now apply
regardless of which class the property resolved to.
Jason Novinger 11 часов назад
Родитель
Сommit
3c6bab4839
2 измененных файлов с 120 добавлено и 10 удалено
  1. 15 10
      netbox/utilities/jsonschema.py
  2. 105 0
      netbox/utilities/tests/test_jsonschema.py

+ 15 - 10
netbox/utilities/jsonschema.py

@@ -86,6 +86,7 @@ class JSONSchemaProperty:
         """
         Instantiate and return a Django form field suitable for editing the property's value.
         """
+        field_class = self.field_class
         field_kwargs = {
             'label': self.title or title(name),
             'help_text': self.description,
@@ -110,22 +111,26 @@ class JSONSchemaProperty:
 
         # String validation
         if self.type == PropertyTypeEnum.STRING.value:
-            if self.minLength is not None:
-                field_kwargs['min_length'] = self.minLength
-            if self.maxLength is not None:
-                field_kwargs['max_length'] = self.maxLength
+            # it's safe to check against CharField here because the other
+            # CharField-derived fields are ruled out by the "is a string check" above
+            if issubclass(field_class, forms.CharField):
+                if self.minLength is not None and not self.enum:
+                    field_kwargs['min_length'] = self.minLength
+                if self.maxLength is not None and not self.enum:
+                    field_kwargs['max_length'] = self.maxLength
             if self.pattern is not None:
                 field_kwargs['validators'] = [
                     RegexValidator(regex=self.pattern)
                 ]
 
         # Integer/number validation
-        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 is not None:
-                field_kwargs['min_value'] = self.minimum
-            if self.maximum is not None:
-                field_kwargs['max_value'] = self.maximum
+        elif self.type in (PropertyTypeEnum.INTEGER.value, PropertyTypeEnum.NUMBER.value):
+            if issubclass(field_class, forms.IntegerField):
+                field_kwargs['widget'] = forms.NumberInput(attrs={'step': 'any'})
+                if self.minimum is not None:
+                    field_kwargs['min_value'] = self.minimum
+                if self.maximum is not None:
+                    field_kwargs['max_value'] = self.maximum
             if self.multipleOf:
                 field_kwargs['validators'] = [
                     MultipleOfValidator(multiple=self.multipleOf)

+ 105 - 0
netbox/utilities/tests/test_jsonschema.py

@@ -1,9 +1,11 @@
 from django import forms
 from django.contrib.postgres.forms import SimpleArrayField
 from django.core.exceptions import ValidationError
+from django.core.validators import RegexValidator
 from django.test import TestCase
 
 from utilities.jsonschema import JSONSchemaProperty
+from utilities.validators import MultipleOfValidator
 
 
 class JSONSchemaPropertyTestCase(TestCase):
@@ -122,9 +124,112 @@ class JSONSchemaPropertyTestCase(TestCase):
         self.assertEqual(list(field.choices), [(None, ''), (1, 1), (2, 2)])
 
     def test_numeric_enum_with_multiple_of_builds_choice_field(self):
+        """An enum suppresses the numeric bounds but retains the multipleOf validator.
+
+        Field.__init__() accepts validators, so a MultipleOfValidator remains applicable to a
+        ChoiceField even though min_value and max_value are not.
+        """
         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)])
+        self.assertEqual(len(field.validators), 1)
+        self.assertIsInstance(field.validators[0], MultipleOfValidator)
+
+    def test_string_enum_with_min_length_builds_choice_field(self):
+        """A string property carrying both an enum and a length bound resolves to a ChoiceField.
+
+        ChoiceField accepts neither min_length nor max_length, so the length bounds must not be
+        passed through when an enum is present.
+        """
+        prop = JSONSchemaProperty(type='string', title='Media', enum=['a', 'bb'], minLength=1)
+
+        field = prop.to_form_field('media')
+
+        self.assertIsInstance(field, forms.ChoiceField)
+        self.assertEqual(list(field.choices), [(None, ''), ('a', 'a'), ('bb', 'bb')])
+
+    def test_string_enum_with_max_length_builds_choice_field(self):
+        prop = JSONSchemaProperty(type='string', title='Media', enum=['a', 'bb'], maxLength=2)
+
+        field = prop.to_form_field('media')
+
+        self.assertIsInstance(field, forms.ChoiceField)
+        self.assertEqual(list(field.choices), [(None, ''), ('a', 'a'), ('bb', 'bb')])
+
+    def test_string_enum_retains_pattern_validator(self):
+        """Dropping the length bounds for an enum must not also drop the pattern validator.
+
+        Field.__init__() accepts validators, so a RegexValidator remains applicable to a
+        ChoiceField even though min_length and max_length are not.
+        """
+        prop = JSONSchemaProperty(
+            type='string', title='Media', enum=['a', 'bb'], minLength=1, pattern='^[ab]+$'
+        )
+
+        field = prop.to_form_field('media')
+
+        self.assertIsInstance(field, forms.ChoiceField)
+        self.assertEqual(len(field.validators), 1)
+        self.assertIsInstance(field.validators[0], RegexValidator)
+        self.assertEqual(field.validators[0].regex.pattern, '^[ab]+$')
+
+    def test_string_bounds_are_applied_without_an_enum(self):
+        prop = JSONSchemaProperty(type='string', title='Media', minLength=1, maxLength=4)
+
+        field = prop.to_form_field('media')
+
+        self.assertIsInstance(field, forms.CharField)
+        self.assertEqual(field.min_length, 1)
+        self.assertEqual(field.max_length, 4)
+        with self.assertRaises(ValidationError):
+            field.clean('toolong')
+
+    def test_string_format_with_length_bound_builds_format_field(self):
+        """A string format resolves to a field class which accepts no length bounds.
+
+        DateField, TimeField and DateTimeField do not subclass CharField, so passing minLength
+        or maxLength to one raises TypeError.
+        """
+        for string_format, expected_class in (
+            ('date', forms.DateField),
+            ('time', forms.TimeField),
+            ('datetime', forms.DateTimeField),
+        ):
+            with self.subTest(format=string_format):
+                prop = JSONSchemaProperty(
+                    type='string', title='Timestamp', format=string_format, minLength=10, maxLength=30
+                )
+
+                field = prop.to_form_field('timestamp')
+
+                self.assertIsInstance(field, expected_class)
+
+    def test_string_format_retains_pattern_validator(self):
+        prop = JSONSchemaProperty(type='string', title='Timestamp', format='date', pattern='^x$')
+
+        field = prop.to_form_field('timestamp')
+
+        self.assertIsInstance(field, forms.DateField)
+        self.assertEqual(len(field.validators), 1)
+        self.assertIsInstance(field.validators[0], RegexValidator)
+
+    def test_charfield_derived_format_retains_length_bounds(self):
+        """EmailField, URLField and UUIDField subclass CharField, so they keep their bounds."""
+        for string_format, expected_class in (
+            ('email', forms.EmailField),
+            ('uri', forms.URLField),
+            ('uuid', forms.UUIDField),
+        ):
+            with self.subTest(format=string_format):
+                prop = JSONSchemaProperty(
+                    type='string', title='Contact', format=string_format, minLength=5, maxLength=40
+                )
+
+                field = prop.to_form_field('contact')
+
+                self.assertIsInstance(field, expected_class)
+                self.assertEqual(field.min_length, 5)
+                self.assertEqual(field.max_length, 40)