Fixed #32559 -- Added 'step_size’ to numeric form fields.
Co-authored-by: Jacob Rief <jacob.rief@uibk.ac.at>
This commit is contained in:
parent
68da6b389c
commit
3a82b5f655
2
AUTHORS
2
AUTHORS
|
@ -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>
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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``
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
Loading…
Reference in New Issue