diff --git a/django/core/validators.py b/django/core/validators.py
index 6c622f57887..0f6515f6578 100644
--- a/django/core/validators.py
+++ b/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
diff --git a/django/forms/fields.py b/django/forms/fields.py
index d759da71abe..b8316079a3b 100644
--- a/django/forms/fields.py
+++ b/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):
"""
diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt
index 4084ae78d5d..aa0a7a84441 100644
--- a/docs/ref/forms/fields.txt
+++ b/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``
-------------
diff --git a/docs/ref/validators.txt b/docs/ref/validators.txt
index a091d20dbb0..8dd90e0e61b 100644
--- a/docs/ref/validators.txt
+++ b/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.
diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt
index fb446fcd7a7..1eb21190b7f 100644
--- a/docs/releases/5.0.txt
+++ b/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:
diff --git a/tests/forms_tests/field_tests/test_decimalfield.py b/tests/forms_tests/field_tests/test_decimalfield.py
index 4e24d553f3d..9d26bc88b0c 100644
--- a/tests/forms_tests/field_tests/test_decimalfield.py
+++ b/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,
+ '',
+ )
+ 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"):
diff --git a/tests/forms_tests/field_tests/test_floatfield.py b/tests/forms_tests/field_tests/test_floatfield.py
index 276520f04de..77b404102c1 100644
--- a/tests/forms_tests/field_tests/test_floatfield.py
+++ b/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(
diff --git a/tests/forms_tests/field_tests/test_integerfield.py b/tests/forms_tests/field_tests/test_integerfield.py
index 1361b5cc9e8..a76c2fd5084 100644
--- a/tests/forms_tests/field_tests/test_integerfield.py
+++ b/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,
+ '',
+ )
+ 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
diff --git a/tests/validators/tests.py b/tests/validators/tests.py
index 02bee30ac13..af08a785fe1 100644
--- a/tests/validators/tests.py
+++ b/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),
(