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

This commit is contained in:
Jacob Rief 2023-04-08 22:10:17 +02:00 committed by Mariusz Felisiak
parent 549d6ffeb6
commit 1fe0b167af
9 changed files with 129 additions and 8 deletions

View File

@ -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

View File

@ -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):
"""

View File

@ -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``
-------------

View File

@ -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.

View File

@ -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:

View File

@ -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"):

View File

@ -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(

View File

@ -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

View File

@ -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),
(