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

Co-authored-by: Jacob Rief <jacob.rief@uibk.ac.at>
This commit is contained in:
Kapil Bansal 2022-05-12 11:30:47 +02:00 committed by Carlton Gibson
parent 68da6b389c
commit 3a82b5f655
9 changed files with 137 additions and 19 deletions

View File

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

View File

@ -1,4 +1,5 @@
import ipaddress import ipaddress
import math
import re import re
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit, urlunsplit from urllib.parse import urlsplit, urlunsplit
@ -401,6 +402,15 @@ class MinValueValidator(BaseValidator):
return a < b 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 @deconstructible
class MinLengthValidator(BaseValidator): class MinLengthValidator(BaseValidator):
message = ngettext_lazy( message = ngettext_lazy(

View File

@ -299,8 +299,8 @@ class IntegerField(Field):
} }
re_decimal = _lazy_re_compile(r"\.0*\s*$") re_decimal = _lazy_re_compile(r"\.0*\s*$")
def __init__(self, *, max_value=None, min_value=None, **kwargs): def __init__(self, *, max_value=None, min_value=None, step_size=None, **kwargs):
self.max_value, self.min_value = max_value, min_value self.max_value, self.min_value, self.step_size = max_value, min_value, step_size
if kwargs.get("localize") and self.widget == NumberInput: if kwargs.get("localize") and self.widget == NumberInput:
# Localized number input is not well supported on most browsers # Localized number input is not well supported on most browsers
kwargs.setdefault("widget", super().widget) kwargs.setdefault("widget", super().widget)
@ -310,6 +310,8 @@ class IntegerField(Field):
self.validators.append(validators.MaxValueValidator(max_value)) self.validators.append(validators.MaxValueValidator(max_value))
if min_value is not None: if min_value is not None:
self.validators.append(validators.MinValueValidator(min_value)) 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): def to_python(self, value):
""" """
@ -335,6 +337,8 @@ class IntegerField(Field):
attrs["min"] = self.min_value attrs["min"] = self.min_value
if self.max_value is not None: if self.max_value is not None:
attrs["max"] = self.max_value attrs["max"] = self.max_value
if self.step_size is not None:
attrs["step"] = self.step_size
return attrs return attrs
@ -369,7 +373,11 @@ class FloatField(IntegerField):
def widget_attrs(self, widget): def widget_attrs(self, widget):
attrs = super().widget_attrs(widget) attrs = super().widget_attrs(widget)
if isinstance(widget, NumberInput) and "step" not in widget.attrs: 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 return attrs

View File

@ -492,18 +492,20 @@ For each field, we describe the default widget used if you don't specify
* Normalizes to: A Python ``decimal``. * Normalizes to: A Python ``decimal``.
* Validates that the given value is a decimal. Uses * Validates that the given value is a decimal. Uses
:class:`~django.core.validators.MaxValueValidator` and :class:`~django.core.validators.MaxValueValidator` and
:class:`~django.core.validators.MinValueValidator` if ``max_value`` and :class:`~django.core.validators.MinValueValidator` if ``max_value`` and
``min_value`` are provided. Leading and trailing whitespace is ignored. ``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``, * Error message keys: ``required``, ``invalid``, ``max_value``,
``min_value``, ``max_digits``, ``max_decimal_places``, ``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 The ``max_value`` and ``min_value`` error messages may contain
``%(limit_value)s``, which will be substituted by the appropriate limit. ``%(limit_value)s``, which will be substituted by the appropriate limit.
Similarly, the ``max_digits``, ``max_decimal_places`` and Similarly, the ``max_digits``, ``max_decimal_places`` and
``max_whole_digits`` error messages may contain ``%(max)s``. ``max_whole_digits`` error messages may contain ``%(max)s``.
Takes four optional arguments: Takes five optional arguments:
.. attribute:: max_value .. attribute:: max_value
.. attribute:: min_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. 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`` ``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 * Validates that the given value is a float. Uses
:class:`~django.core.validators.MaxValueValidator` and :class:`~django.core.validators.MaxValueValidator` and
:class:`~django.core.validators.MinValueValidator` if ``max_value`` and :class:`~django.core.validators.MinValueValidator` if ``max_value`` and
``min_value`` are provided. Leading and trailing whitespace is allowed, ``min_value`` are provided. Uses
as in Python's ``float()`` function. :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``, * Error message keys: ``required``, ``invalid``, ``max_value``,
``min_value`` ``min_value``, ``step_size``.
Takes two optional arguments for validation, ``max_value`` and ``min_value``. Takes three optional arguments:
These control the range of values permitted in the field.
.. 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`` ``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 * Validates that the given value is an integer. Uses
:class:`~django.core.validators.MaxValueValidator` and :class:`~django.core.validators.MaxValueValidator` and
:class:`~django.core.validators.MinValueValidator` if ``max_value`` and :class:`~django.core.validators.MinValueValidator` if ``max_value`` and
``min_value`` are provided. Leading and trailing whitespace is allowed, ``min_value`` are provided. Uses
as in Python's ``int()`` function. :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``, * Error message keys: ``required``, ``invalid``, ``max_value``,
``min_value`` ``min_value``, ``step_size``
The ``max_value`` and ``min_value`` error messages may contain The ``max_value``, ``min_value`` and ``step_size`` error messages may
``%(limit_value)s``, which will be substituted by the appropriate limit. 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:: max_value
.. attribute:: min_value .. attribute:: 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``.
``JSONField`` ``JSONField``
------------- -------------

View File

@ -333,3 +333,15 @@ to, or in lieu of custom ``field.clean()`` methods.
The error code used by :exc:`~django.core.exceptions.ValidationError` The error code used by :exc:`~django.core.exceptions.ValidationError`
if validation fails. Defaults to ``"null_characters_not_allowed"``. 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.

View File

@ -297,6 +297,11 @@ Forms
error messages for invalid number of forms by passing ``'too_few_forms'`` error messages for invalid number of forms by passing ``'too_few_forms'``
and ``'too_many_forms'`` keys. 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 Generic Views
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
@ -444,7 +449,10 @@ Utilities
Validators 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: .. _backwards-incompatible-4.1:

View File

@ -70,6 +70,21 @@ class FloatFieldTest(FormFieldAssertionsMixin, SimpleTestCase):
self.assertEqual(f.max_value, 1.5) self.assertEqual(f.max_value, 1.5)
self.assertEqual(f.min_value, 0.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): def test_floatfield_widget_attrs(self):
f = FloatField(widget=NumberInput(attrs={"step": 0.01, "max": 1.0, "min": 0.0})) f = FloatField(widget=NumberInput(attrs={"step": 0.01, "max": 1.0, "min": 0.0}))
self.assertWidgetRendersTo( self.assertWidgetRendersTo(

View File

@ -112,6 +112,20 @@ class IntegerFieldTest(FormFieldAssertionsMixin, SimpleTestCase):
self.assertEqual(f.max_value, 20) self.assertEqual(f.max_value, 20)
self.assertEqual(f.min_value, 10) 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): def test_integerfield_localized(self):
""" """
A localized IntegerField's widget renders to a text input without any A localized IntegerField's widget renders to a text input without any

View File

@ -17,6 +17,7 @@ from django.core.validators import (
MinValueValidator, MinValueValidator,
ProhibitNullCharactersValidator, ProhibitNullCharactersValidator,
RegexValidator, RegexValidator,
StepValueValidator,
URLValidator, URLValidator,
int_list_validator, int_list_validator,
validate_comma_separated_integer_list, validate_comma_separated_integer_list,
@ -440,12 +441,21 @@ TEST_DATA = [
# limit_value may be a callable. # limit_value may be a callable.
(MinValueValidator(lambda: 1), 0, ValidationError), (MinValueValidator(lambda: 1), 0, ValidationError),
(MinValueValidator(lambda: 1), 1, None), (MinValueValidator(lambda: 1), 1, None),
(StepValueValidator(3), 0, None),
(MaxLengthValidator(10), "", None), (MaxLengthValidator(10), "", None),
(MaxLengthValidator(10), 10 * "x", None), (MaxLengthValidator(10), 10 * "x", None),
(MaxLengthValidator(10), 15 * "x", ValidationError), (MaxLengthValidator(10), 15 * "x", ValidationError),
(MinLengthValidator(10), 15 * "x", None), (MinLengthValidator(10), 15 * "x", None),
(MinLengthValidator(10), 10 * "x", None), (MinLengthValidator(10), 10 * "x", None),
(MinLengthValidator(10), "", ValidationError), (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), "file://localhost/path", None),
(URLValidator(EXTENDED_SCHEMES), "git://example.com/", None), (URLValidator(EXTENDED_SCHEMES), "git://example.com/", None),
( (
@ -715,6 +725,10 @@ class TestValidatorEquality(TestCase):
MaxValueValidator(44), MaxValueValidator(44),
) )
self.assertEqual(MaxValueValidator(44), mock.ANY) self.assertEqual(MaxValueValidator(44), mock.ANY)
self.assertEqual(
StepValueValidator(0.003),
StepValueValidator(0.003),
)
self.assertNotEqual( self.assertNotEqual(
MaxValueValidator(44), MaxValueValidator(44),
MinValueValidator(44), MinValueValidator(44),
@ -723,6 +737,10 @@ class TestValidatorEquality(TestCase):
MinValueValidator(45), MinValueValidator(45),
MinValueValidator(11), MinValueValidator(11),
) )
self.assertNotEqual(
StepValueValidator(3),
StepValueValidator(2),
)
def test_decimal_equality(self): def test_decimal_equality(self):
self.assertEqual( self.assertEqual(