Browse Source

Fixed #34473 -- Fixed step validation for form fields with non-zero minimum value.

Jacob Rief 1 year ago
parent
commit
1fe0b167af

+ 30 - 1
django/core/validators.py

@@ -397,8 +397,37 @@ class StepValueValidator(BaseValidator):
     message = _("Ensure this value is a multiple of step size %(limit_value)s.")
     code = "step_size"
 
+    def __init__(self, limit_value, message=None, offset=None):
+        super().__init__(limit_value, message)
+        if offset is not None:
+            self.message = _(
+                "Ensure this value is a multiple of step size %(limit_value)s, "
+                "starting from %(offset)s, e.g. %(offset)s, %(valid_value1)s, "
+                "%(valid_value2)s, and so on."
+            )
+        self.offset = offset
+
+    def __call__(self, value):
+        if self.offset is None:
+            super().__call__(value)
+        else:
+            cleaned = self.clean(value)
+            limit_value = (
+                self.limit_value() if callable(self.limit_value) else self.limit_value
+            )
+            if self.compare(cleaned, limit_value):
+                offset = cleaned.__class__(self.offset)
+                params = {
+                    "limit_value": limit_value,
+                    "offset": offset,
+                    "valid_value1": offset + limit_value,
+                    "valid_value2": offset + 2 * limit_value,
+                }
+                raise ValidationError(self.message, code=self.code, params=params)
+
     def compare(self, a, b):
-        return not math.isclose(math.remainder(a, b), 0, abs_tol=1e-9)
+        offset = 0 if self.offset is None else self.offset
+        return not math.isclose(math.remainder(a - offset, b), 0, abs_tol=1e-9)
 
 
 @deconstructible

+ 3 - 1
django/forms/fields.py

@@ -316,7 +316,9 @@ class IntegerField(Field):
         if min_value is not None:
             self.validators.append(validators.MinValueValidator(min_value))
         if step_size is not None:
-            self.validators.append(validators.StepValueValidator(step_size))
+            self.validators.append(
+                validators.StepValueValidator(step_size, offset=min_value)
+            )
 
     def to_python(self, value):
         """

+ 9 - 3
docs/ref/forms/fields.txt

@@ -562,7 +562,9 @@ For each field, we describe the default widget used if you don't specify
 
     .. attribute:: step_size
 
-        Limit valid inputs to an integral multiple of ``step_size``.
+        Limit valid inputs to an integral multiple of ``step_size``. If
+        ``min_value`` is also provided, it's added as an offset to determine if
+        the step size matches.
 
 ``DurationField``
 -----------------
@@ -695,7 +697,9 @@ For each field, we describe the default widget used if you don't specify
 
     .. attribute:: step_size
 
-        Limit valid inputs to an integral multiple of ``step_size``.
+        Limit valid inputs to an integral multiple of ``step_size``. If
+        ``min_value`` is also provided, it's added as an offset to determine if
+        the step size matches.
 
 ``GenericIPAddressField``
 -------------------------
@@ -831,7 +835,9 @@ For each field, we describe the default widget used if you don't specify
 
     .. attribute:: step_size
 
-        Limit valid inputs to an integral multiple of ``step_size``.
+        Limit valid inputs to an integral multiple of ``step_size``. If
+        ``min_value`` is also provided, it's added as an offset to determine if
+        the step size matches.
 
 ``JSONField``
 -------------

+ 9 - 2
docs/ref/validators.txt

@@ -340,9 +340,16 @@ to, or in lieu of custom ``field.clean()`` methods.
 ``StepValueValidator``
 ----------------------
 
-.. class:: StepValueValidator(limit_value, message=None)
+.. class:: StepValueValidator(limit_value, message=None, offset=None)
 
     Raises a :exc:`~django.core.exceptions.ValidationError` with a code of
     ``'step_size'`` if ``value`` is not an integral multiple of
     ``limit_value``, which can be a float, integer or decimal value or a
-    callable.
+    callable. When ``offset`` is set, the validation occurs against
+    ``limit_value`` plus ``offset``. For example, for
+    ``StepValueValidator(3, offset=1.4)`` valid values include ``1.4``,
+    ``4.4``, ``7.4``, ``10.4``, and so on.
+
+    .. versionchanged:: 5.0
+
+        The ``offset`` argument was added.

+ 3 - 1
docs/releases/5.0.txt

@@ -367,7 +367,9 @@ Utilities
 Validators
 ~~~~~~~~~~
 
-* ...
+* The new ``offset`` argument of
+  :class:`~django.core.validators.StepValueValidator` allows specifying an
+  offset for valid values.
 
 .. _backwards-incompatible-5.0:
 

+ 19 - 0
tests/forms_tests/field_tests/test_decimalfield.py

@@ -152,6 +152,25 @@ class DecimalFieldTest(FormFieldAssertionsMixin, SimpleTestCase):
         with self.assertRaisesMessage(ValidationError, msg):
             f.clean("1.1")
 
+    def test_decimalfield_step_size_min_value(self):
+        f = DecimalField(
+            step_size=decimal.Decimal("0.3"),
+            min_value=decimal.Decimal("-0.4"),
+        )
+        self.assertWidgetRendersTo(
+            f,
+            '<input name="f" min="-0.4" step="0.3" type="number" id="id_f" required>',
+        )
+        msg = (
+            "Ensure this value is a multiple of step size 0.3, starting from -0.4, "
+            "e.g. -0.4, -0.1, 0.2, and so on."
+        )
+        with self.assertRaisesMessage(ValidationError, msg):
+            f.clean("1")
+        self.assertEqual(f.clean("0.2"), decimal.Decimal("0.2"))
+        self.assertEqual(f.clean(2), decimal.Decimal(2))
+        self.assertEqual(f.step_size, decimal.Decimal("0.3"))
+
     def test_decimalfield_scientific(self):
         f = DecimalField(max_digits=4, decimal_places=2)
         with self.assertRaisesMessage(ValidationError, "Ensure that there are no more"):

+ 12 - 0
tests/forms_tests/field_tests/test_floatfield.py

@@ -84,6 +84,18 @@ class FloatFieldTest(FormFieldAssertionsMixin, SimpleTestCase):
         self.assertEqual(-1.26, f.clean("-1.26"))
         self.assertEqual(f.step_size, 0.02)
 
+    def test_floatfield_step_size_min_value(self):
+        f = FloatField(step_size=0.02, min_value=0.01)
+        msg = (
+            "Ensure this value is a multiple of step size 0.02, starting from 0.01, "
+            "e.g. 0.01, 0.03, 0.05, and so on."
+        )
+        with self.assertRaisesMessage(ValidationError, msg):
+            f.clean("0.02")
+        self.assertEqual(f.clean("2.33"), 2.33)
+        self.assertEqual(f.clean("0.11"), 0.11)
+        self.assertEqual(f.step_size, 0.02)
+
     def test_floatfield_widget_attrs(self):
         f = FloatField(widget=NumberInput(attrs={"step": 0.01, "max": 1.0, "min": 0.0}))
         self.assertWidgetRendersTo(

+ 16 - 0
tests/forms_tests/field_tests/test_integerfield.py

@@ -126,6 +126,22 @@ class IntegerFieldTest(FormFieldAssertionsMixin, SimpleTestCase):
         self.assertEqual(12, f.clean("12"))
         self.assertEqual(f.step_size, 3)
 
+    def test_integerfield_step_size_min_value(self):
+        f = IntegerField(step_size=3, min_value=-1)
+        self.assertWidgetRendersTo(
+            f,
+            '<input name="f" min="-1" step="3" type="number" id="id_f" required>',
+        )
+        msg = (
+            "Ensure this value is a multiple of step size 3, starting from -1, e.g. "
+            "-1, 2, 5, and so on."
+        )
+        with self.assertRaisesMessage(ValidationError, msg):
+            f.clean("9")
+        self.assertEqual(f.clean("2"), 2)
+        self.assertEqual(f.clean("-1"), -1)
+        self.assertEqual(f.step_size, 3)
+
     def test_integerfield_localized(self):
         """
         A localized IntegerField's widget renders to a text input without any

+ 28 - 0
tests/validators/tests.py

@@ -451,11 +451,39 @@ TEST_DATA = [
     (StepValueValidator(3), 1, ValidationError),
     (StepValueValidator(3), 8, ValidationError),
     (StepValueValidator(3), 9, None),
+    (StepValueValidator(2), 4, None),
+    (StepValueValidator(2, offset=1), 3, None),
+    (StepValueValidator(2, offset=1), 4, ValidationError),
     (StepValueValidator(0.001), 0.55, None),
     (StepValueValidator(0.001), 0.5555, ValidationError),
+    (StepValueValidator(0.001, offset=0.0005), 0.5555, None),
+    (StepValueValidator(0.001, offset=0.0005), 0.555, ValidationError),
     (StepValueValidator(Decimal(0.02)), 0.88, None),
     (StepValueValidator(Decimal(0.02)), Decimal(0.88), None),
     (StepValueValidator(Decimal(0.02)), Decimal(0.77), ValidationError),
+    (StepValueValidator(Decimal(0.02), offset=Decimal(0.01)), Decimal(0.77), None),
+    (StepValueValidator(Decimal(2.0), offset=Decimal(0.1)), Decimal(0.1), None),
+    (
+        StepValueValidator(Decimal(0.02), offset=Decimal(0.01)),
+        Decimal(0.88),
+        ValidationError,
+    ),
+    (StepValueValidator(Decimal("1.2"), offset=Decimal("2.2")), Decimal("3.4"), None),
+    (
+        StepValueValidator(Decimal("1.2"), offset=Decimal("2.2")),
+        Decimal("1.2"),
+        ValidationError,
+    ),
+    (
+        StepValueValidator(Decimal("-1.2"), offset=Decimal("2.2")),
+        Decimal("1.1"),
+        ValidationError,
+    ),
+    (
+        StepValueValidator(Decimal("-1.2"), offset=Decimal("2.2")),
+        Decimal("1.0"),
+        None,
+    ),
     (URLValidator(EXTENDED_SCHEMES), "file://localhost/path", None),
     (URLValidator(EXTENDED_SCHEMES), "git://example.com/", None),
     (