diff --git a/AUTHORS b/AUTHORS index 9f25f5933e..3a9a521c19 100644 --- a/AUTHORS +++ b/AUTHORS @@ -413,6 +413,7 @@ answer newbie questions, and generally made Django that much better: Jacob Burch Jacob Green Jacob Kaplan-Moss + Jacob Rief Jacob Walls Jakub Paczkowski Jakub Wilk @@ -526,6 +527,7 @@ answer newbie questions, and generally made Django that much better: Justin Myles Holmes Jyrki Pulliainen Kadesarin Sanjek + Kapil Bansal Karderio Karen Tracey Karol Sikora diff --git a/django/core/validators.py b/django/core/validators.py index 605b491ba7..473203a67e 100644 --- a/django/core/validators.py +++ b/django/core/validators.py @@ -1,4 +1,5 @@ import ipaddress +import math import re from pathlib import Path from urllib.parse import urlsplit, urlunsplit @@ -401,6 +402,15 @@ class MinValueValidator(BaseValidator): return a < b +@deconstructible +class StepValueValidator(BaseValidator): + message = _("Ensure this value is a multiple of step size %(limit_value)s.") + code = "step_size" + + def compare(self, a, b): + return not math.isclose(math.remainder(a, b), 0, abs_tol=1e-9) + + @deconstructible class MinLengthValidator(BaseValidator): message = ngettext_lazy( diff --git a/django/forms/fields.py b/django/forms/fields.py index a7031936dd..8fa7b72cfd 100644 --- a/django/forms/fields.py +++ b/django/forms/fields.py @@ -299,8 +299,8 @@ class IntegerField(Field): } re_decimal = _lazy_re_compile(r"\.0*\s*$") - def __init__(self, *, max_value=None, min_value=None, **kwargs): - self.max_value, self.min_value = max_value, min_value + def __init__(self, *, max_value=None, min_value=None, step_size=None, **kwargs): + self.max_value, self.min_value, self.step_size = max_value, min_value, step_size if kwargs.get("localize") and self.widget == NumberInput: # Localized number input is not well supported on most browsers kwargs.setdefault("widget", super().widget) @@ -310,6 +310,8 @@ class IntegerField(Field): self.validators.append(validators.MaxValueValidator(max_value)) 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)) def to_python(self, value): """ @@ -335,6 +337,8 @@ class IntegerField(Field): attrs["min"] = self.min_value if self.max_value is not None: attrs["max"] = self.max_value + if self.step_size is not None: + attrs["step"] = self.step_size return attrs @@ -369,7 +373,11 @@ class FloatField(IntegerField): def widget_attrs(self, widget): attrs = super().widget_attrs(widget) if isinstance(widget, NumberInput) and "step" not in widget.attrs: - attrs.setdefault("step", "any") + if self.step_size is not None: + step = str(self.step_size) + else: + step = "any" + attrs.setdefault("step", step) return attrs diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt index fc92f8998f..4d0205b4d3 100644 --- a/docs/ref/forms/fields.txt +++ b/docs/ref/forms/fields.txt @@ -492,18 +492,20 @@ For each field, we describe the default widget used if you don't specify * Normalizes to: A Python ``decimal``. * Validates that the given value is a decimal. Uses :class:`~django.core.validators.MaxValueValidator` and - :class:`~django.core.validators.MinValueValidator` if ``max_value`` and - ``min_value`` are provided. Leading and trailing whitespace is ignored. + :class:`~django.core.validators.MinValueValidator` if ``max_value`` and + ``min_value`` are provided. Uses + :class:`~django.core.validators.StepValueValidator` if ``step_size`` is + provided. Leading and trailing whitespace is ignored. * Error message keys: ``required``, ``invalid``, ``max_value``, ``min_value``, ``max_digits``, ``max_decimal_places``, - ``max_whole_digits`` + ``max_whole_digits``, ``step_size``. The ``max_value`` and ``min_value`` error messages may contain ``%(limit_value)s``, which will be substituted by the appropriate limit. Similarly, the ``max_digits``, ``max_decimal_places`` and ``max_whole_digits`` error messages may contain ``%(max)s``. - Takes four optional arguments: + Takes five optional arguments: .. attribute:: max_value .. attribute:: min_value @@ -521,6 +523,14 @@ For each field, we describe the default widget used if you don't specify The maximum number of decimal places permitted. + .. attribute:: step_size + + Limit valid inputs to an integral multiple of ``step_size``. + + .. versionchanged:: 4.1 + + The ``step_size`` argument was added. + ``DurationField`` ----------------- @@ -636,13 +646,25 @@ For each field, we describe the default widget used if you don't specify * Validates that the given value is a float. Uses :class:`~django.core.validators.MaxValueValidator` and :class:`~django.core.validators.MinValueValidator` if ``max_value`` and - ``min_value`` are provided. Leading and trailing whitespace is allowed, - as in Python's ``float()`` function. + ``min_value`` are provided. Uses + :class:`~django.core.validators.StepValueValidator` if ``step_size`` is + provided. Leading and trailing whitespace is allowed, as in Python's + ``float()`` function. * Error message keys: ``required``, ``invalid``, ``max_value``, - ``min_value`` + ``min_value``, ``step_size``. - Takes two optional arguments for validation, ``max_value`` and ``min_value``. - These control the range of values permitted in the field. + Takes three optional arguments: + + .. attribute:: max_value + .. attribute:: min_value + + These control the range of values permitted in the field. + + .. attribute:: step_size + + .. versionadded:: 4.1 + + Limit valid inputs to an integral multiple of ``step_size``. ``GenericIPAddressField`` ------------------------- @@ -755,21 +777,30 @@ For each field, we describe the default widget used if you don't specify * Validates that the given value is an integer. Uses :class:`~django.core.validators.MaxValueValidator` and :class:`~django.core.validators.MinValueValidator` if ``max_value`` and - ``min_value`` are provided. Leading and trailing whitespace is allowed, - as in Python's ``int()`` function. + ``min_value`` are provided. Uses + :class:`~django.core.validators.StepValueValidator` if ``step_size`` is + provided. Leading and trailing whitespace is allowed, as in Python's + ``int()`` function. * Error message keys: ``required``, ``invalid``, ``max_value``, - ``min_value`` + ``min_value``, ``step_size`` - The ``max_value`` and ``min_value`` error messages may contain - ``%(limit_value)s``, which will be substituted by the appropriate limit. + The ``max_value``, ``min_value`` and ``step_size`` error messages may + contain ``%(limit_value)s``, which will be substituted by the appropriate + limit. - Takes two optional arguments for validation: + Takes three optional arguments for validation: .. attribute:: max_value .. attribute:: min_value These control the range of values permitted in the field. + .. attribute:: step_size + + .. versionadded:: 4.1 + + Limit valid inputs to an integral multiple of ``step_size``. + ``JSONField`` ------------- diff --git a/docs/ref/validators.txt b/docs/ref/validators.txt index 5fe1384bd8..2a7b85e614 100644 --- a/docs/ref/validators.txt +++ b/docs/ref/validators.txt @@ -333,3 +333,15 @@ to, or in lieu of custom ``field.clean()`` methods. The error code used by :exc:`~django.core.exceptions.ValidationError` if validation fails. Defaults to ``"null_characters_not_allowed"``. + +``StepValueValidator`` +---------------------- + +.. versionadded:: 4.1 + +.. class:: StepValueValidator(limit_value, message=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. diff --git a/docs/releases/4.1.txt b/docs/releases/4.1.txt index 84eca03563..677e8c0345 100644 --- a/docs/releases/4.1.txt +++ b/docs/releases/4.1.txt @@ -297,6 +297,11 @@ Forms error messages for invalid number of forms by passing ``'too_few_forms'`` and ``'too_many_forms'`` keys. +* :class:`~django.forms.IntegerField`, :class:`~django.forms.FloatField`, and + :class:`~django.forms.DecimalField` now optionally accept a ``step_size`` + argument. This is used to set the ``step`` HTML attribute, and is validated + on form submission. + Generic Views ~~~~~~~~~~~~~ @@ -444,7 +449,10 @@ Utilities Validators ~~~~~~~~~~ -* ... +* The new :class:`~django.core.validators.StepValueValidator` checks if a value + is an integral multiple of a given step size. This new validator is used for + the new ``step_size`` argument added to form fields representing numeric + values. .. _backwards-incompatible-4.1: diff --git a/tests/forms_tests/field_tests/test_floatfield.py b/tests/forms_tests/field_tests/test_floatfield.py index f6022807be..94676fb9ee 100644 --- a/tests/forms_tests/field_tests/test_floatfield.py +++ b/tests/forms_tests/field_tests/test_floatfield.py @@ -70,6 +70,21 @@ class FloatFieldTest(FormFieldAssertionsMixin, SimpleTestCase): self.assertEqual(f.max_value, 1.5) self.assertEqual(f.min_value, 0.5) + def test_floatfield_4(self): + f = FloatField(step_size=0.02) + self.assertWidgetRendersTo( + f, + '', + ) + msg = "'Ensure this value is a multiple of step size 0.02.'" + with self.assertRaisesMessage(ValidationError, msg): + f.clean("0.01") + self.assertEqual(2.34, f.clean("2.34")) + self.assertEqual(2.1, f.clean("2.1")) + self.assertEqual(-0.50, f.clean("-.5")) + self.assertEqual(-1.26, f.clean("-1.26")) + 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 15314d53a4..1361b5cc9e 100644 --- a/tests/forms_tests/field_tests/test_integerfield.py +++ b/tests/forms_tests/field_tests/test_integerfield.py @@ -112,6 +112,20 @@ class IntegerFieldTest(FormFieldAssertionsMixin, SimpleTestCase): self.assertEqual(f.max_value, 20) self.assertEqual(f.min_value, 10) + def test_integerfield_6(self): + f = IntegerField(step_size=3) + self.assertWidgetRendersTo( + f, + '', + ) + with self.assertRaisesMessage( + ValidationError, "'Ensure this value is a multiple of step size 3.'" + ): + f.clean("10") + self.assertEqual(12, f.clean(12)) + self.assertEqual(12, f.clean("12")) + 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 79369b172e..f9ffdfd605 100644 --- a/tests/validators/tests.py +++ b/tests/validators/tests.py @@ -17,6 +17,7 @@ from django.core.validators import ( MinValueValidator, ProhibitNullCharactersValidator, RegexValidator, + StepValueValidator, URLValidator, int_list_validator, validate_comma_separated_integer_list, @@ -440,12 +441,21 @@ TEST_DATA = [ # limit_value may be a callable. (MinValueValidator(lambda: 1), 0, ValidationError), (MinValueValidator(lambda: 1), 1, None), + (StepValueValidator(3), 0, None), (MaxLengthValidator(10), "", None), (MaxLengthValidator(10), 10 * "x", None), (MaxLengthValidator(10), 15 * "x", ValidationError), (MinLengthValidator(10), 15 * "x", None), (MinLengthValidator(10), 10 * "x", None), (MinLengthValidator(10), "", ValidationError), + (StepValueValidator(3), 1, ValidationError), + (StepValueValidator(3), 8, ValidationError), + (StepValueValidator(3), 9, None), + (StepValueValidator(0.001), 0.55, None), + (StepValueValidator(0.001), 0.5555, ValidationError), + (StepValueValidator(Decimal(0.02)), 0.88, None), + (StepValueValidator(Decimal(0.02)), Decimal(0.88), None), + (StepValueValidator(Decimal(0.02)), Decimal(0.77), ValidationError), (URLValidator(EXTENDED_SCHEMES), "file://localhost/path", None), (URLValidator(EXTENDED_SCHEMES), "git://example.com/", None), ( @@ -715,6 +725,10 @@ class TestValidatorEquality(TestCase): MaxValueValidator(44), ) self.assertEqual(MaxValueValidator(44), mock.ANY) + self.assertEqual( + StepValueValidator(0.003), + StepValueValidator(0.003), + ) self.assertNotEqual( MaxValueValidator(44), MinValueValidator(44), @@ -723,6 +737,10 @@ class TestValidatorEquality(TestCase): MinValueValidator(45), MinValueValidator(11), ) + self.assertNotEqual( + StepValueValidator(3), + StepValueValidator(2), + ) def test_decimal_equality(self): self.assertEqual(