diff --git a/django/forms/fields.py b/django/forms/fields.py index c547d1456c..621d3801f2 100644 --- a/django/forms/fields.py +++ b/django/forms/fields.py @@ -19,7 +19,7 @@ from django.core import validators from django.core.exceptions import ValidationError from django.forms.util import ErrorList, from_current_timezone, to_current_timezone from django.forms.widgets import ( - TextInput, PasswordInput, EmailInput, URLInput, HiddenInput, + TextInput, NumberInput, EmailInput, URLInput, HiddenInput, MultipleHiddenInput, ClearableFileInput, CheckboxInput, Select, NullBooleanSelect, SelectMultiple, DateInput, DateTimeInput, TimeInput, SplitDateTimeWidget, SplitHiddenDateTimeWidget, FILE_INPUT_CONTRADICTION @@ -234,6 +234,7 @@ class IntegerField(Field): def __init__(self, max_value=None, min_value=None, *args, **kwargs): self.max_value, self.min_value = max_value, min_value + kwargs.setdefault('widget', NumberInput if not kwargs.get('localize') else self.widget) super(IntegerField, self).__init__(*args, **kwargs) if max_value is not None: @@ -257,6 +258,16 @@ class IntegerField(Field): raise ValidationError(self.error_messages['invalid']) return value + def widget_attrs(self, widget): + attrs = super(IntegerField, self).widget_attrs(widget) + if isinstance(widget, NumberInput): + if self.min_value is not None: + attrs['min'] = self.min_value + if self.max_value is not None: + attrs['max'] = self.max_value + return attrs + + class FloatField(IntegerField): default_error_messages = { 'invalid': _('Enter a number.'), @@ -278,25 +289,24 @@ class FloatField(IntegerField): raise ValidationError(self.error_messages['invalid']) return value -class DecimalField(Field): + def widget_attrs(self, widget): + attrs = super(FloatField, self).widget_attrs(widget) + if isinstance(widget, NumberInput): + attrs.setdefault('step', 'any') + return attrs + + +class DecimalField(IntegerField): default_error_messages = { 'invalid': _('Enter a number.'), - 'max_value': _('Ensure this value is less than or equal to %(limit_value)s.'), - 'min_value': _('Ensure this value is greater than or equal to %(limit_value)s.'), 'max_digits': _('Ensure that there are no more than %s digits in total.'), 'max_decimal_places': _('Ensure that there are no more than %s decimal places.'), 'max_whole_digits': _('Ensure that there are no more than %s digits before the decimal point.') } def __init__(self, max_value=None, min_value=None, max_digits=None, decimal_places=None, *args, **kwargs): - self.max_value, self.min_value = max_value, min_value self.max_digits, self.decimal_places = max_digits, decimal_places - Field.__init__(self, *args, **kwargs) - - if max_value is not None: - self.validators.append(validators.MaxValueValidator(max_value)) - if min_value is not None: - self.validators.append(validators.MinValueValidator(min_value)) + super(DecimalField, self).__init__(max_value, min_value, *args, **kwargs) def to_python(self, value): """ @@ -345,6 +355,19 @@ class DecimalField(Field): raise ValidationError(self.error_messages['max_whole_digits'] % (self.max_digits - self.decimal_places)) return value + def widget_attrs(self, widget): + attrs = super(DecimalField, self).widget_attrs(widget) + if isinstance(widget, NumberInput): + if self.max_digits is not None: + max_length = self.max_digits + 1 # for the sign + if self.decimal_places is None or self.decimal_places > 0: + max_length += 1 # for the dot + attrs['maxlength'] = max_length + if self.decimal_places: + attrs['step'] = '0.%s1' % ('0' * (self.decimal_places-1)) + return attrs + + class BaseTemporalField(Field): def __init__(self, input_formats=None, *args, **kwargs): diff --git a/django/forms/widgets.py b/django/forms/widgets.py index e906ed5bc6..026e8dc36a 100644 --- a/django/forms/widgets.py +++ b/django/forms/widgets.py @@ -23,7 +23,7 @@ from django.utils import datetime_safe, formats, six __all__ = ( 'Media', 'MediaDefiningClass', 'Widget', 'TextInput', - 'EmailInput', 'URLInput', 'PasswordInput', + 'EmailInput', 'URLInput', 'NumberInput', 'PasswordInput', 'HiddenInput', 'MultipleHiddenInput', 'ClearableFileInput', 'FileInput', 'DateInput', 'DateTimeInput', 'TimeInput', 'Textarea', 'CheckboxInput', 'Select', 'NullBooleanSelect', 'SelectMultiple', 'RadioSelect', @@ -252,6 +252,10 @@ class TextInput(Input): super(TextInput, self).__init__(attrs) +class NumberInput(TextInput): + input_type = 'number' + + class EmailInput(TextInput): input_type = 'email' diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt index 2e4e779f0c..85650adcf4 100644 --- a/docs/ref/forms/fields.txt +++ b/docs/ref/forms/fields.txt @@ -454,7 +454,8 @@ For each field, we describe the default widget used if you don't specify .. class:: DecimalField(**kwargs) - * Default widget: :class:`TextInput` + * Default widget: :class:`NumberInput` when :attr:`Field.localize` is + ``False``, else :class:`TextInput`. * Empty value: ``None`` * Normalizes to: A Python ``decimal``. * Validates that the given value is a decimal. Leading and trailing @@ -580,7 +581,8 @@ For each field, we describe the default widget used if you don't specify .. class:: FloatField(**kwargs) - * Default widget: :class:`TextInput` + * Default widget: :class:`NumberInput` when :attr:`Field.localize` is + ``False``, else :class:`TextInput`. * Empty value: ``None`` * Normalizes to: A Python float. * Validates that the given value is an float. Leading and trailing @@ -621,7 +623,8 @@ For each field, we describe the default widget used if you don't specify .. class:: IntegerField(**kwargs) - * Default widget: :class:`TextInput` + * Default widget: :class:`NumberInput` when :attr:`Field.localize` is + ``False``, else :class:`TextInput`. * Empty value: ``None`` * Normalizes to: A Python integer or long integer. * Validates that the given value is an integer. Leading and trailing diff --git a/docs/ref/forms/widgets.txt b/docs/ref/forms/widgets.txt index 970901a9ae..7218e41082 100644 --- a/docs/ref/forms/widgets.txt +++ b/docs/ref/forms/widgets.txt @@ -389,6 +389,19 @@ These widgets make use of the HTML elements ``input`` and ``textarea``. Text input: ```` +``NumberInput`` +~~~~~~~~~~~~~~~ + +.. class:: NumberInput + + .. versionadded:: 1.6 + + Text input: ```` + + Beware that not all browsers support entering localized numbers in + ``number`` input types. Django itself avoids using them for fields having + their :attr:`~django.forms.Field.localize` property to ``True``. + ``EmailInput`` ~~~~~~~~~~~~~~ diff --git a/docs/releases/1.6.txt b/docs/releases/1.6.txt index d7b1547a91..67c032a362 100644 --- a/docs/releases/1.6.txt +++ b/docs/releases/1.6.txt @@ -60,9 +60,13 @@ Minor features * In addition to :lookup:`year`, :lookup:`month` and :lookup:`day`, the ORM now supports :lookup:`hour`, :lookup:`minute` and :lookup:`second` lookups. -* The default widgets for :class:`~django.forms.EmailField` and - :class:`~django.forms.URLField` use the new type attributes available in - HTML5 (type='email', type='url'). +* The default widgets for :class:`~django.forms.EmailField`, + :class:`~django.forms.URLField`, :class:`~django.forms.IntegerField`, + :class:`~django.forms.FloatField` and :class:`~django.forms.DecimalField` use + the new type attributes available in HTML5 (type='email', type='url', + type='number'). Note that due to erratic support of the ``number`` input type + with localized numbers in current browsers, Django only uses it when numeric + fields are not localized. * The ``number`` argument for :ref:`lazy plural translations ` can be provided at translation time rather than @@ -122,7 +126,8 @@ Backwards incompatible changes in 1.6 * If your CSS/Javascript code used to access HTML input widgets by type, you should review it as ``type='text'`` widgets might be now output as - ``type='email'`` or ``type='url'`` depending on their corresponding field type. + ``type='email'``, ``type='url'`` or ``type='number'`` depending on their + corresponding field type. * Extraction of translatable literals from templates with the :djadmin:`makemessages` command now correctly detects i18n constructs when diff --git a/docs/topics/forms/formsets.txt b/docs/topics/forms/formsets.txt index d2d102b5d6..2534947dd3 100644 --- a/docs/topics/forms/formsets.txt +++ b/docs/topics/forms/formsets.txt @@ -273,13 +273,13 @@ Lets you create a formset with the ability to order:: ... print(form.as_table()) - + - + - + This adds an additional field to each form. This new field is named ``ORDER`` and is an ``forms.IntegerField``. For the forms that came from the initial diff --git a/tests/modeltests/model_forms/tests.py b/tests/modeltests/model_forms/tests.py index 6ecd1278dd..fb400375b8 100644 --- a/tests/modeltests/model_forms/tests.py +++ b/tests/modeltests/model_forms/tests.py @@ -1133,7 +1133,7 @@ class OldFormForXTests(TestCase):

-

''' % (w_woodward.pk, w_bernstein.pk, bw.pk, w_royko.pk)) +

''' % (w_woodward.pk, w_bernstein.pk, bw.pk, w_royko.pk)) data = { 'writer': six.text_type(w_woodward.pk), @@ -1151,7 +1151,7 @@ class OldFormForXTests(TestCase):

-

''' % (w_woodward.pk, w_bernstein.pk, bw.pk, w_royko.pk)) +

''' % (w_woodward.pk, w_bernstein.pk, bw.pk, w_royko.pk)) def test_file_field(self): # Test conditions when files is either not given or empty. diff --git a/tests/modeltests/model_formsets/tests.py b/tests/modeltests/model_formsets/tests.py index 50ee3c73fb..037163b741 100644 --- a/tests/modeltests/model_formsets/tests.py +++ b/tests/modeltests/model_formsets/tests.py @@ -392,7 +392,7 @@ class ModelFormsetTest(TestCase): self.assertEqual(len(formset.forms), 1) self.assertHTMLEqual(formset.forms[0].as_p(), '

\n' - '

') + '

') data = { 'form-TOTAL_FORMS': '1', # the number of forms rendered @@ -415,10 +415,10 @@ class ModelFormsetTest(TestCase): self.assertEqual(len(formset.forms), 2) self.assertHTMLEqual(formset.forms[0].as_p(), '

\n' - '

' % hemingway_id) + '

' % hemingway_id) self.assertHTMLEqual(formset.forms[1].as_p(), '

\n' - '

') + '

') data = { 'form-TOTAL_FORMS': '2', # the number of forms rendered @@ -551,6 +551,7 @@ class ModelFormsetTest(TestCase): def test_inline_formsets_with_custom_pk(self): # Test inline formsets where the inline-edited object has a custom # primary key that is not the fk to the parent object. + self.maxDiff = 1024 AuthorBooksFormSet2 = inlineformset_factory(Author, BookWithCustomPK, can_delete=False, extra=1) author = Author.objects.create(pk=1, name='Charles Baudelaire') @@ -558,7 +559,7 @@ class ModelFormsetTest(TestCase): formset = AuthorBooksFormSet2(instance=author) self.assertEqual(len(formset.forms), 1) self.assertHTMLEqual(formset.forms[0].as_p(), - '

\n' + '

\n' '

') data = { @@ -806,7 +807,7 @@ class ModelFormsetTest(TestCase): '\n' '\n' '

\n' - '

' + '

' % (owner1.auto_id, owner2.auto_id)) owner1 = Owner.objects.get(name='Joe Perry') @@ -816,7 +817,7 @@ class ModelFormsetTest(TestCase): formset = FormSet(instance=owner1) self.assertEqual(len(formset.forms), 1) self.assertHTMLEqual(formset.forms[0].as_p(), - '

' + '

' % owner1.auto_id) data = { @@ -837,7 +838,7 @@ class ModelFormsetTest(TestCase): formset = FormSet(instance=owner1) self.assertEqual(len(formset.forms), 1) self.assertHTMLEqual(formset.forms[0].as_p(), - '

' + '

' % owner1.auto_id) data = { @@ -993,7 +994,7 @@ class ModelFormsetTest(TestCase): result = re.sub(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?', '__DATETIME__', result) self.assertHTMLEqual(result, '

\n' - '

' + '

' % person.id) # test for validation with callable defaults. Validations rely on hidden fields diff --git a/tests/regressiontests/forms/tests/fields.py b/tests/regressiontests/forms/tests/fields.py index fc7fc70da4..3fe2cd2ace 100644 --- a/tests/regressiontests/forms/tests/fields.py +++ b/tests/regressiontests/forms/tests/fields.py @@ -35,7 +35,6 @@ from decimal import Decimal from django.core.files.uploadedfile import SimpleUploadedFile from django.forms import * from django.test import SimpleTestCase -from django.utils import formats from django.utils import six from django.utils._os import upath @@ -131,6 +130,7 @@ class FieldsTests(SimpleTestCase): def test_integerfield_1(self): f = IntegerField() + self.assertWidgetRendersTo(f, '') self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, '') self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, None) self.assertEqual(1, f.clean('1')) @@ -165,6 +165,7 @@ class FieldsTests(SimpleTestCase): def test_integerfield_3(self): f = IntegerField(max_value=10) + self.assertWidgetRendersTo(f, '') self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, None) self.assertEqual(1, f.clean(1)) self.assertEqual(10, f.clean(10)) @@ -176,6 +177,7 @@ class FieldsTests(SimpleTestCase): def test_integerfield_4(self): f = IntegerField(min_value=10) + self.assertWidgetRendersTo(f, '') self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, None) self.assertRaisesMessage(ValidationError, "'Ensure this value is greater than or equal to 10.'", f.clean, 1) self.assertEqual(10, f.clean(10)) @@ -187,6 +189,7 @@ class FieldsTests(SimpleTestCase): def test_integerfield_5(self): f = IntegerField(min_value=10, max_value=20) + self.assertWidgetRendersTo(f, '') self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, None) self.assertRaisesMessage(ValidationError, "'Ensure this value is greater than or equal to 10.'", f.clean, 1) self.assertEqual(10, f.clean(10)) @@ -198,10 +201,19 @@ class FieldsTests(SimpleTestCase): self.assertEqual(f.max_value, 20) self.assertEqual(f.min_value, 10) + def test_integerfield_localized(self): + """ + Make sure localized IntegerField's widget renders to a text input with + no number input specific attributes. + """ + f1 = IntegerField(localize=True) + self.assertWidgetRendersTo(f1, '') + # FloatField ################################################################## def test_floatfield_1(self): f = FloatField() + self.assertWidgetRendersTo(f, '') self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, '') self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, None) self.assertEqual(1.0, f.clean('1')) @@ -228,6 +240,7 @@ class FieldsTests(SimpleTestCase): def test_floatfield_3(self): f = FloatField(max_value=1.5, min_value=0.5) + self.assertWidgetRendersTo(f, '') self.assertRaisesMessage(ValidationError, "'Ensure this value is less than or equal to 1.5.'", f.clean, '1.6') self.assertRaisesMessage(ValidationError, "'Ensure this value is greater than or equal to 0.5.'", f.clean, '0.4') self.assertEqual(1.5, f.clean('1.5')) @@ -235,10 +248,19 @@ class FieldsTests(SimpleTestCase): self.assertEqual(f.max_value, 1.5) self.assertEqual(f.min_value, 0.5) + def test_floatfield_localized(self): + """ + Make sure localized FloatField's widget renders to a text input with + no number input specific attributes. + """ + f = FloatField(localize=True) + self.assertWidgetRendersTo(f, '') + # DecimalField ################################################################ def test_decimalfield_1(self): f = DecimalField(max_digits=4, decimal_places=2) + self.assertWidgetRendersTo(f, '') self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, '') self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, None) self.assertEqual(f.clean('1'), Decimal("1")) @@ -284,6 +306,7 @@ class FieldsTests(SimpleTestCase): def test_decimalfield_3(self): f = DecimalField(max_digits=4, decimal_places=2, max_value=Decimal('1.5'), min_value=Decimal('0.5')) + self.assertWidgetRendersTo(f, '') self.assertRaisesMessage(ValidationError, "'Ensure this value is less than or equal to 1.5.'", f.clean, '1.6') self.assertRaisesMessage(ValidationError, "'Ensure this value is greater than or equal to 0.5.'", f.clean, '0.4') self.assertEqual(f.clean('1.5'), Decimal("1.5")) @@ -315,6 +338,14 @@ class FieldsTests(SimpleTestCase): self.assertEqual(f.clean('.01'), Decimal(".01")) self.assertRaisesMessage(ValidationError, "'Ensure that there are no more than 0 digits before the decimal point.'", f.clean, '1.1') + def test_decimalfield_localized(self): + """ + Make sure localized DecimalField's widget renders to a text input with + no number input specific attributes. + """ + f = DecimalField(localize=True) + self.assertWidgetRendersTo(f, '') + # DateField ################################################################### def test_datefield_1(self): diff --git a/tests/regressiontests/forms/tests/forms.py b/tests/regressiontests/forms/tests/forms.py index f2fa78e229..f856e30d33 100644 --- a/tests/regressiontests/forms/tests/forms.py +++ b/tests/regressiontests/forms/tests/forms.py @@ -1740,7 +1740,7 @@ class FormsTestCase(TestCase):
  • -
  • """) +
  • """) self.assertHTMLEqual(p.as_p(), """

    @@ -1751,7 +1751,7 @@ class FormsTestCase(TestCase):

    -

    """) +

    """) self.assertHTMLEqual(p.as_table(), """ -""") +""") def test_label_split_datetime_not_displayed(self): class EventForm(Form): diff --git a/tests/regressiontests/forms/tests/formsets.py b/tests/regressiontests/forms/tests/formsets.py index 573a8f6a6d..2bef0c5c33 100644 --- a/tests/regressiontests/forms/tests/formsets.py +++ b/tests/regressiontests/forms/tests/formsets.py @@ -53,7 +53,7 @@ class FormsFormsetTestCase(TestCase): formset = ChoiceFormSet(auto_id=False, prefix='choices') self.assertHTMLEqual(str(formset), """ Choice: -Votes:""") +Votes:""") # On thing to note is that there needs to be a special value in the data. This # value tells the FormSet how many forms were displayed so it can tell how @@ -137,9 +137,9 @@ class FormsFormsetTestCase(TestCase): form_output.append(form.as_ul()) self.assertHTMLEqual('\n'.join(form_output), """
  • Choice:
  • -
  • Votes:
  • +
  • Votes:
  • Choice:
  • -
  • Votes:
  • """) +
  • Votes:
  • """) # Let's simulate what would happen if we submitted this form. @@ -210,11 +210,11 @@ class FormsFormsetTestCase(TestCase): form_output.append(form.as_ul()) self.assertHTMLEqual('\n'.join(form_output), """
  • Choice:
  • -
  • Votes:
  • +
  • Votes:
  • Choice:
  • -
  • Votes:
  • +
  • Votes:
  • Choice:
  • -
  • Votes:
  • """) +
  • Votes:
  • """) # Since we displayed every form as blank, we will also accept them back as blank. # This may seem a little strange, but later we will show how to require a minimum @@ -301,19 +301,19 @@ class FormsFormsetTestCase(TestCase): form_output.append(form.as_ul()) self.assertHTMLEqual('\n'.join(form_output), """
  • Choice:
  • -
  • Votes:
  • +
  • Votes:
  • Choice:
  • -
  • Votes:
  • +
  • Votes:
  • Choice:
  • -
  • Votes:
  • +
  • Votes:
  • Choice:
  • -
  • Votes:
  • """) +
  • Votes:
  • """) # Make sure retrieving an empty form works, and it shows up in the form list self.assertTrue(formset.empty_form.empty_permitted) self.assertHTMLEqual(formset.empty_form.as_ul(), """
  • Choice:
  • -
  • Votes:
  • """) +
  • Votes:
  • """) def test_formset_with_deletion(self): # FormSets with deletion ###################################################### @@ -331,13 +331,13 @@ class FormsFormsetTestCase(TestCase): form_output.append(form.as_ul()) self.assertHTMLEqual('\n'.join(form_output), """
  • Choice:
  • -
  • Votes:
  • +
  • Votes:
  • Delete:
  • Choice:
  • -
  • Votes:
  • +
  • Votes:
  • Delete:
  • Choice:
  • -
  • Votes:
  • +
  • Votes:
  • Delete:
  • """) # To delete something, we just need to set that form's special delete field to @@ -428,14 +428,14 @@ class FormsFormsetTestCase(TestCase): form_output.append(form.as_ul()) self.assertHTMLEqual('\n'.join(form_output), """
  • Choice:
  • -
  • Votes:
  • -
  • Order:
  • +
  • Votes:
  • +
  • Order:
  • Choice:
  • -
  • Votes:
  • -
  • Order:
  • +
  • Votes:
  • +
  • Order:
  • Choice:
  • -
  • Votes:
  • -
  • Order:
  • """) +
  • Votes:
  • +
  • Order:
  • """) data = { 'choices-TOTAL_FORMS': '3', # the number of forms rendered @@ -539,20 +539,20 @@ class FormsFormsetTestCase(TestCase): form_output.append(form.as_ul()) self.assertHTMLEqual('\n'.join(form_output), """
  • Choice:
  • -
  • Votes:
  • -
  • Order:
  • +
  • Votes:
  • +
  • Order:
  • Delete:
  • Choice:
  • -
  • Votes:
  • -
  • Order:
  • +
  • Votes:
  • +
  • Order:
  • Delete:
  • Choice:
  • -
  • Votes:
  • -
  • Order:
  • +
  • Votes:
  • +
  • Order:
  • Delete:
  • Choice:
  • -
  • Votes:
  • -
  • Order:
  • +
  • Votes:
  • +
  • Order:
  • Delete:
  • """) # Let's delete Fergie, and put The Decemberists ahead of Calexico. @@ -956,19 +956,19 @@ class FormsetAsFooTests(TestCase): formset = ChoiceFormSet(data, auto_id=False, prefix='choices') self.assertHTMLEqual(formset.as_table(),""" Choice: -Votes:""") +Votes:""") def test_as_p(self): formset = ChoiceFormSet(data, auto_id=False, prefix='choices') self.assertHTMLEqual(formset.as_p(),"""

    Choice:

    -

    Votes:

    """) +

    Votes:

    """) def test_as_ul(self): formset = ChoiceFormSet(data, auto_id=False, prefix='choices') self.assertHTMLEqual(formset.as_ul(),"""
  • Choice:
  • -
  • Votes:
  • """) +
  • Votes:
  • """) # Regression test for #11418 ################################################# diff --git a/tests/regressiontests/i18n/tests.py b/tests/regressiontests/i18n/tests.py index 45d49d5766..3fcb60c3b7 100644 --- a/tests/regressiontests/i18n/tests.py +++ b/tests/regressiontests/i18n/tests.py @@ -651,6 +651,7 @@ class FormattingTests(TestCase): """ Tests if form input is correctly localized """ + self.maxDiff = 1200 with translation.override('de-at', deactivate=True): form6 = CompanyForm({ 'name': 'acme',