Browse Source

Fixed #32559 -- Added 'step_size’ to numeric form fields.

Co-authored-by: Jacob Rief <jacob.rief@uibk.ac.at>
Kapil Bansal 2 years ago
parent
commit
3a82b5f655

+ 2 - 0
AUTHORS

@@ -413,6 +413,7 @@ answer newbie questions, and generally made Django that much better:
     Jacob Burch <jacobburch@gmail.com>
     Jacob Green
     Jacob Kaplan-Moss <jacob@jacobian.org>
+    Jacob Rief <jacob.rief@gmail.com>
     Jacob Walls <http://www.jacobtylerwalls.com/>
     Jakub Paczkowski <jakub@paczkowski.eu>
     Jakub Wilk <jwilk@jwilk.net>
@@ -526,6 +527,7 @@ answer newbie questions, and generally made Django that much better:
     Justin Myles Holmes <justin@slashrootcafe.com>
     Jyrki Pulliainen <jyrki.pulliainen@gmail.com>
     Kadesarin Sanjek
+    Kapil Bansal <kapilbansal.gbpecdelhi@gmail.com>
     Karderio <karderio@gmail.com>
     Karen Tracey <kmtracey@gmail.com>
     Karol Sikora <elektrrrus@gmail.com>

+ 10 - 0
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(

+ 11 - 3
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
 
 

+ 46 - 15
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 three optional arguments:
+
+    .. attribute:: max_value
+    .. attribute:: min_value
 
-    Takes two optional arguments for validation, ``max_value`` and ``min_value``.
-    These control the range of values permitted in the field.
+        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``
 -------------
 

+ 12 - 0
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.

+ 9 - 1
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:
 

+ 15 - 0
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,
+            '<input name="f" step="0.02" type="number" id="id_f" required>',
+        )
+        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(

+ 14 - 0
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,
+            '<input name="f" step="3" type="number" id="id_f" required>',
+        )
+        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

+ 18 - 0
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(