Skip to content

Commit 3a82b5f

Browse files
devkapilbansaljrief
authored andcommitted
Fixed #32559 -- Added 'step_size’ to numeric form fields.
Co-authored-by: Jacob Rief <[email protected]>
1 parent 68da6b3 commit 3a82b5f

File tree

9 files changed

+137
-19
lines changed

9 files changed

+137
-19
lines changed

AUTHORS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ answer newbie questions, and generally made Django that much better:
413413
Jacob Burch <[email protected]>
414414
Jacob Green
415415
Jacob Kaplan-Moss <[email protected]>
416+
Jacob Rief <[email protected]>
416417
Jacob Walls <http://www.jacobtylerwalls.com/>
417418
Jakub Paczkowski <[email protected]>
418419
Jakub Wilk <[email protected]>
@@ -526,6 +527,7 @@ answer newbie questions, and generally made Django that much better:
526527
Justin Myles Holmes <[email protected]>
527528
Jyrki Pulliainen <[email protected]>
528529
Kadesarin Sanjek
530+
Kapil Bansal <[email protected]>
529531
Karderio <[email protected]>
530532
Karen Tracey <[email protected]>
531533
Karol Sikora <[email protected]>

django/core/validators.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import ipaddress
2+
import math
23
import re
34
from pathlib import Path
45
from urllib.parse import urlsplit, urlunsplit
@@ -401,6 +402,15 @@ def compare(self, a, b):
401402
return a < b
402403

403404

405+
@deconstructible
406+
class StepValueValidator(BaseValidator):
407+
message = _("Ensure this value is a multiple of step size %(limit_value)s.")
408+
code = "step_size"
409+
410+
def compare(self, a, b):
411+
return not math.isclose(math.remainder(a, b), 0, abs_tol=1e-9)
412+
413+
404414
@deconstructible
405415
class MinLengthValidator(BaseValidator):
406416
message = ngettext_lazy(

django/forms/fields.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,8 +299,8 @@ class IntegerField(Field):
299299
}
300300
re_decimal = _lazy_re_compile(r"\.0*\s*$")
301301

302-
def __init__(self, *, max_value=None, min_value=None, **kwargs):
303-
self.max_value, self.min_value = max_value, min_value
302+
def __init__(self, *, max_value=None, min_value=None, step_size=None, **kwargs):
303+
self.max_value, self.min_value, self.step_size = max_value, min_value, step_size
304304
if kwargs.get("localize") and self.widget == NumberInput:
305305
# Localized number input is not well supported on most browsers
306306
kwargs.setdefault("widget", super().widget)
@@ -310,6 +310,8 @@ def __init__(self, *, max_value=None, min_value=None, **kwargs):
310310
self.validators.append(validators.MaxValueValidator(max_value))
311311
if min_value is not None:
312312
self.validators.append(validators.MinValueValidator(min_value))
313+
if step_size is not None:
314+
self.validators.append(validators.StepValueValidator(step_size))
313315

314316
def to_python(self, value):
315317
"""
@@ -335,6 +337,8 @@ def widget_attrs(self, widget):
335337
attrs["min"] = self.min_value
336338
if self.max_value is not None:
337339
attrs["max"] = self.max_value
340+
if self.step_size is not None:
341+
attrs["step"] = self.step_size
338342
return attrs
339343

340344

@@ -369,7 +373,11 @@ def validate(self, value):
369373
def widget_attrs(self, widget):
370374
attrs = super().widget_attrs(widget)
371375
if isinstance(widget, NumberInput) and "step" not in widget.attrs:
372-
attrs.setdefault("step", "any")
376+
if self.step_size is not None:
377+
step = str(self.step_size)
378+
else:
379+
step = "any"
380+
attrs.setdefault("step", step)
373381
return attrs
374382

375383

docs/ref/forms/fields.txt

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -492,18 +492,20 @@ For each field, we describe the default widget used if you don't specify
492492
* Normalizes to: A Python ``decimal``.
493493
* Validates that the given value is a decimal. Uses
494494
:class:`~django.core.validators.MaxValueValidator` and
495-
:class:`~django.core.validators.MinValueValidator` if ``max_value`` and
496-
``min_value`` are provided. Leading and trailing whitespace is ignored.
495+
:class:`~django.core.validators.MinValueValidator` if ``max_value`` and
496+
``min_value`` are provided. Uses
497+
:class:`~django.core.validators.StepValueValidator` if ``step_size`` is
498+
provided. Leading and trailing whitespace is ignored.
497499
* Error message keys: ``required``, ``invalid``, ``max_value``,
498500
``min_value``, ``max_digits``, ``max_decimal_places``,
499-
``max_whole_digits``
501+
``max_whole_digits``, ``step_size``.
500502

501503
The ``max_value`` and ``min_value`` error messages may contain
502504
``%(limit_value)s``, which will be substituted by the appropriate limit.
503505
Similarly, the ``max_digits``, ``max_decimal_places`` and
504506
``max_whole_digits`` error messages may contain ``%(max)s``.
505507

506-
Takes four optional arguments:
508+
Takes five optional arguments:
507509

508510
.. attribute:: max_value
509511
.. attribute:: min_value
@@ -521,6 +523,14 @@ For each field, we describe the default widget used if you don't specify
521523

522524
The maximum number of decimal places permitted.
523525

526+
.. attribute:: step_size
527+
528+
Limit valid inputs to an integral multiple of ``step_size``.
529+
530+
.. versionchanged:: 4.1
531+
532+
The ``step_size`` argument was added.
533+
524534
``DurationField``
525535
-----------------
526536

@@ -636,13 +646,25 @@ For each field, we describe the default widget used if you don't specify
636646
* Validates that the given value is a float. Uses
637647
:class:`~django.core.validators.MaxValueValidator` and
638648
:class:`~django.core.validators.MinValueValidator` if ``max_value`` and
639-
``min_value`` are provided. Leading and trailing whitespace is allowed,
640-
as in Python's ``float()`` function.
649+
``min_value`` are provided. Uses
650+
:class:`~django.core.validators.StepValueValidator` if ``step_size`` is
651+
provided. Leading and trailing whitespace is allowed, as in Python's
652+
``float()`` function.
641653
* Error message keys: ``required``, ``invalid``, ``max_value``,
642-
``min_value``
654+
``min_value``, ``step_size``.
655+
656+
Takes three optional arguments:
657+
658+
.. attribute:: max_value
659+
.. attribute:: min_value
643660

644-
Takes two optional arguments for validation, ``max_value`` and ``min_value``.
645-
These control the range of values permitted in the field.
661+
These control the range of values permitted in the field.
662+
663+
.. attribute:: step_size
664+
665+
.. versionadded:: 4.1
666+
667+
Limit valid inputs to an integral multiple of ``step_size``.
646668

647669
``GenericIPAddressField``
648670
-------------------------
@@ -755,21 +777,30 @@ For each field, we describe the default widget used if you don't specify
755777
* Validates that the given value is an integer. Uses
756778
:class:`~django.core.validators.MaxValueValidator` and
757779
:class:`~django.core.validators.MinValueValidator` if ``max_value`` and
758-
``min_value`` are provided. Leading and trailing whitespace is allowed,
759-
as in Python's ``int()`` function.
780+
``min_value`` are provided. Uses
781+
:class:`~django.core.validators.StepValueValidator` if ``step_size`` is
782+
provided. Leading and trailing whitespace is allowed, as in Python's
783+
``int()`` function.
760784
* Error message keys: ``required``, ``invalid``, ``max_value``,
761-
``min_value``
785+
``min_value``, ``step_size``
762786

763-
The ``max_value`` and ``min_value`` error messages may contain
764-
``%(limit_value)s``, which will be substituted by the appropriate limit.
787+
The ``max_value``, ``min_value`` and ``step_size`` error messages may
788+
contain ``%(limit_value)s``, which will be substituted by the appropriate
789+
limit.
765790

766-
Takes two optional arguments for validation:
791+
Takes three optional arguments for validation:
767792

768793
.. attribute:: max_value
769794
.. attribute:: min_value
770795

771796
These control the range of values permitted in the field.
772797

798+
.. attribute:: step_size
799+
800+
.. versionadded:: 4.1
801+
802+
Limit valid inputs to an integral multiple of ``step_size``.
803+
773804
``JSONField``
774805
-------------
775806

docs/ref/validators.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,15 @@ to, or in lieu of custom ``field.clean()`` methods.
333333

334334
The error code used by :exc:`~django.core.exceptions.ValidationError`
335335
if validation fails. Defaults to ``"null_characters_not_allowed"``.
336+
337+
``StepValueValidator``
338+
----------------------
339+
340+
.. versionadded:: 4.1
341+
342+
.. class:: StepValueValidator(limit_value, message=None)
343+
344+
Raises a :exc:`~django.core.exceptions.ValidationError` with a code of
345+
``'step_size'`` if ``value`` is not an integral multiple of
346+
``limit_value``, which can be a float, integer or decimal value or a
347+
callable.

docs/releases/4.1.txt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,11 @@ Forms
297297
error messages for invalid number of forms by passing ``'too_few_forms'``
298298
and ``'too_many_forms'`` keys.
299299

300+
* :class:`~django.forms.IntegerField`, :class:`~django.forms.FloatField`, and
301+
:class:`~django.forms.DecimalField` now optionally accept a ``step_size``
302+
argument. This is used to set the ``step`` HTML attribute, and is validated
303+
on form submission.
304+
300305
Generic Views
301306
~~~~~~~~~~~~~
302307

@@ -444,7 +449,10 @@ Utilities
444449
Validators
445450
~~~~~~~~~~
446451

447-
* ...
452+
* The new :class:`~django.core.validators.StepValueValidator` checks if a value
453+
is an integral multiple of a given step size. This new validator is used for
454+
the new ``step_size`` argument added to form fields representing numeric
455+
values.
448456

449457
.. _backwards-incompatible-4.1:
450458

tests/forms_tests/field_tests/test_floatfield.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,21 @@ def test_floatfield_3(self):
7070
self.assertEqual(f.max_value, 1.5)
7171
self.assertEqual(f.min_value, 0.5)
7272

73+
def test_floatfield_4(self):
74+
f = FloatField(step_size=0.02)
75+
self.assertWidgetRendersTo(
76+
f,
77+
'<input name="f" step="0.02" type="number" id="id_f" required>',
78+
)
79+
msg = "'Ensure this value is a multiple of step size 0.02.'"
80+
with self.assertRaisesMessage(ValidationError, msg):
81+
f.clean("0.01")
82+
self.assertEqual(2.34, f.clean("2.34"))
83+
self.assertEqual(2.1, f.clean("2.1"))
84+
self.assertEqual(-0.50, f.clean("-.5"))
85+
self.assertEqual(-1.26, f.clean("-1.26"))
86+
self.assertEqual(f.step_size, 0.02)
87+
7388
def test_floatfield_widget_attrs(self):
7489
f = FloatField(widget=NumberInput(attrs={"step": 0.01, "max": 1.0, "min": 0.0}))
7590
self.assertWidgetRendersTo(

tests/forms_tests/field_tests/test_integerfield.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,20 @@ def test_integerfield_5(self):
112112
self.assertEqual(f.max_value, 20)
113113
self.assertEqual(f.min_value, 10)
114114

115+
def test_integerfield_6(self):
116+
f = IntegerField(step_size=3)
117+
self.assertWidgetRendersTo(
118+
f,
119+
'<input name="f" step="3" type="number" id="id_f" required>',
120+
)
121+
with self.assertRaisesMessage(
122+
ValidationError, "'Ensure this value is a multiple of step size 3.'"
123+
):
124+
f.clean("10")
125+
self.assertEqual(12, f.clean(12))
126+
self.assertEqual(12, f.clean("12"))
127+
self.assertEqual(f.step_size, 3)
128+
115129
def test_integerfield_localized(self):
116130
"""
117131
A localized IntegerField's widget renders to a text input without any

tests/validators/tests.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
MinValueValidator,
1818
ProhibitNullCharactersValidator,
1919
RegexValidator,
20+
StepValueValidator,
2021
URLValidator,
2122
int_list_validator,
2223
validate_comma_separated_integer_list,
@@ -440,12 +441,21 @@
440441
# limit_value may be a callable.
441442
(MinValueValidator(lambda: 1), 0, ValidationError),
442443
(MinValueValidator(lambda: 1), 1, None),
444+
(StepValueValidator(3), 0, None),
443445
(MaxLengthValidator(10), "", None),
444446
(MaxLengthValidator(10), 10 * "x", None),
445447
(MaxLengthValidator(10), 15 * "x", ValidationError),
446448
(MinLengthValidator(10), 15 * "x", None),
447449
(MinLengthValidator(10), 10 * "x", None),
448450
(MinLengthValidator(10), "", ValidationError),
451+
(StepValueValidator(3), 1, ValidationError),
452+
(StepValueValidator(3), 8, ValidationError),
453+
(StepValueValidator(3), 9, None),
454+
(StepValueValidator(0.001), 0.55, None),
455+
(StepValueValidator(0.001), 0.5555, ValidationError),
456+
(StepValueValidator(Decimal(0.02)), 0.88, None),
457+
(StepValueValidator(Decimal(0.02)), Decimal(0.88), None),
458+
(StepValueValidator(Decimal(0.02)), Decimal(0.77), ValidationError),
449459
(URLValidator(EXTENDED_SCHEMES), "file://localhost/path", None),
450460
(URLValidator(EXTENDED_SCHEMES), "git://example.com/", None),
451461
(
@@ -715,6 +725,10 @@ def test_basic_equality(self):
715725
MaxValueValidator(44),
716726
)
717727
self.assertEqual(MaxValueValidator(44), mock.ANY)
728+
self.assertEqual(
729+
StepValueValidator(0.003),
730+
StepValueValidator(0.003),
731+
)
718732
self.assertNotEqual(
719733
MaxValueValidator(44),
720734
MinValueValidator(44),
@@ -723,6 +737,10 @@ def test_basic_equality(self):
723737
MinValueValidator(45),
724738
MinValueValidator(11),
725739
)
740+
self.assertNotEqual(
741+
StepValueValidator(3),
742+
StepValueValidator(2),
743+
)
726744

727745
def test_decimal_equality(self):
728746
self.assertEqual(

0 commit comments

Comments
 (0)