[1.10.x] Fixed #27186 -- Fixed model form default fallback for MultiWidget, FileInput, SplitDateTimeWidget, SelectDateWidget, and SplitArrayWidget.

Thanks Matt Westcott for the review.

Backport of 3507d4e773 from master
This commit is contained in:
Tim Graham 2016-09-06 17:41:54 -04:00
parent 190cd0e49f
commit 0b59ea3343
15 changed files with 178 additions and 13 deletions

View File

@ -103,6 +103,12 @@ class SplitArrayWidget(forms.Widget):
return [self.widget.value_from_datadict(data, files, '%s_%s' % (name, index)) return [self.widget.value_from_datadict(data, files, '%s_%s' % (name, index))
for index in range(self.size)] for index in range(self.size)]
def value_omitted_from_data(self, data, files, name):
return all(
self.widget.value_omitted_from_data(data, files, '%s_%s' % (name, index))
for index in range(self.size)
)
def id_for_label(self, id_): def id_for_label(self, id_):
# See the comment for RadioSelect.id_for_label() # See the comment for RadioSelect.id_for_label()
if id_: if id_:

View File

@ -54,8 +54,8 @@ def construct_instance(form, instance, fields=None, exclude=None):
continue continue
# Leave defaults for fields that aren't in POST data, except for # Leave defaults for fields that aren't in POST data, except for
# checkbox inputs because they don't appear in POST data if not checked. # checkbox inputs because they don't appear in POST data if not checked.
if (f.has_default() and form.add_prefix(f.name) not in form.data and if (f.has_default() and
not getattr(form[f.name].field.widget, 'dont_use_model_field_default_for_empty_data', False)): form[f.name].field.widget.value_omitted_from_data(form.data, form.files, form.add_prefix(f.name))):
continue continue
# Defer saving file-type fields until after the other fields, so a # Defer saving file-type fields until after the other fields, so a
# callable upload_to can use the values from other fields. # callable upload_to can use the values from other fields.

View File

@ -237,6 +237,9 @@ class Widget(six.with_metaclass(RenameWidgetMethods)):
""" """
return data.get(name) return data.get(name)
def value_omitted_from_data(self, data, files, name):
return name not in data
def id_for_label(self, id_): def id_for_label(self, id_):
""" """
Returns the HTML ID attribute of this Widget for use by a <label>, Returns the HTML ID attribute of this Widget for use by a <label>,
@ -350,6 +353,9 @@ class FileInput(Input):
"File widgets take data from FILES, not POST" "File widgets take data from FILES, not POST"
return files.get(name) return files.get(name)
def value_omitted_from_data(self, data, files, name):
return name not in files
FILE_INPUT_CONTRADICTION = object() FILE_INPUT_CONTRADICTION = object()
@ -480,10 +486,6 @@ def boolean_check(v):
class CheckboxInput(Widget): class CheckboxInput(Widget):
# Don't use model field defaults for fields that aren't in POST data,
# because checkboxes don't appear in POST data if not checked.
dont_use_model_field_default_for_empty_data = True
def __init__(self, attrs=None, check_test=None): def __init__(self, attrs=None, check_test=None):
super(CheckboxInput, self).__init__(attrs) super(CheckboxInput, self).__init__(attrs)
# check_test is a callable that takes a value and returns True # check_test is a callable that takes a value and returns True
@ -511,6 +513,11 @@ class CheckboxInput(Widget):
value = values.get(value.lower(), value) value = values.get(value.lower(), value)
return bool(value) return bool(value)
def value_omitted_from_data(self, data, files, name):
# HTML checkboxes don't appear in POST data if not checked, so it's
# never known if the value is actually omitted.
return False
class Select(Widget): class Select(Widget):
allow_multiple_selected = False allow_multiple_selected = False
@ -871,6 +878,12 @@ class MultiWidget(Widget):
def value_from_datadict(self, data, files, name): def value_from_datadict(self, data, files, name):
return [widget.value_from_datadict(data, files, name + '_%s' % i) for i, widget in enumerate(self.widgets)] return [widget.value_from_datadict(data, files, name + '_%s' % i) for i, widget in enumerate(self.widgets)]
def value_omitted_from_data(self, data, files, name):
return all(
widget.value_omitted_from_data(data, files, name + '_%s' % i)
for i, widget in enumerate(self.widgets)
)
def format_output(self, rendered_widgets): def format_output(self, rendered_widgets):
""" """
Given a list of rendered widgets (as strings), returns a Unicode string Given a list of rendered widgets (as strings), returns a Unicode string
@ -1056,6 +1069,12 @@ class SelectDateWidget(Widget):
return '%s-%s-%s' % (y, m, d) return '%s-%s-%s' % (y, m, d)
return data.get(name) return data.get(name)
def value_omitted_from_data(self, data, files, name):
return not any(
('{}_{}'.format(name, interval) in data)
for interval in ('year', 'month', 'day')
)
def create_select(self, name, field, value, val, choices, none_value): def create_select(self, name, field, value, val, choices, none_value):
if 'id' in self.attrs: if 'id' in self.attrs:
id_ = self.attrs['id'] id_ = self.attrs['id']

View File

@ -271,6 +271,21 @@ foundation for custom widgets.
customize it and add expensive processing, you should implement some customize it and add expensive processing, you should implement some
caching mechanism yourself. caching mechanism yourself.
.. method:: value_omitted_from_data(data, files, name)
.. versionadded:: 1.10.2
Given ``data`` and ``files`` dictionaries and this widget's name,
returns whether or not there's data or files for the widget.
The method's result affects whether or not a field in a model form
:ref:`falls back to its default <topics-modelform-save>`.
A special case is :class:`~django.forms.CheckboxInput`, which always
returns ``False`` because an unchecked checkbox doesn't appear in the
data of an HTML form submission, so it's unknown whether or not the
user actually submitted a value.
``MultiWidget`` ``MultiWidget``
--------------- ---------------

View File

@ -17,3 +17,8 @@ Bugfixes
* Disabled system check for URL patterns beginning with a '/' when * Disabled system check for URL patterns beginning with a '/' when
``APPEND_SLASH=False`` (:ticket:`27238`). ``APPEND_SLASH=False`` (:ticket:`27238`).
* Fixed model form ``default`` fallback for ``MultiWidget``, ``FileInput``,
``SplitDateTimeWidget``, ``SelectDateWidget``, and ``SplitArrayWidget``
(:ticket:`27186`). Custom widgets affected by this issue may need to
implement a :meth:`~django.forms.Widget.value_omitted_from_data` method.

View File

@ -299,6 +299,8 @@ to the ``error_messages`` dictionary of the ``ModelForm``s inner ``Meta`` cla
} }
} }
.. _topics-modelform-save:
The ``save()`` method The ``save()`` method
--------------------- ---------------------
@ -333,11 +335,11 @@ doesn't validate -- i.e., if ``form.errors`` evaluates to ``True``.
If an optional field doesn't appear in the form's data, the resulting model If an optional field doesn't appear in the form's data, the resulting model
instance uses the model field :attr:`~django.db.models.Field.default`, if instance uses the model field :attr:`~django.db.models.Field.default`, if
there is one, for that field. This behavior doesn't apply to fields that use there is one, for that field. This behavior doesn't apply to fields that use
:class:`~django.forms.CheckboxInput` (or any custom widget with :class:`~django.forms.CheckboxInput` (or any custom widget whose
``dont_use_model_field_default_for_empty_data=True``) since an unchecked :meth:`~django.forms.Widget.value_omitted_from_data` method always returns
checkbox doesn't appear in the data of an HTML form submission. Use a custom ``False``) since an unchecked checkbox doesn't appear in the data of an HTML
form field or widget if you're designing an API and want the default fallback form submission. Use a custom form field or widget if you're designing an API
for a ``BooleanField``. and want the default fallback for a :class:`~django.db.models.BooleanField`.
.. versionchanged:: 1.10.1 .. versionchanged:: 1.10.1
@ -345,6 +347,10 @@ for a ``BooleanField``.
:class:`~django.forms.CheckboxInput` which means that unchecked checkboxes :class:`~django.forms.CheckboxInput` which means that unchecked checkboxes
receive a value of ``True`` if that's the model field default. receive a value of ``True`` if that's the model field default.
.. versionchanged:: 1.10.2
The :meth:`~django.forms.Widget.value_omitted_from_data` method was added.
This ``save()`` method accepts an optional ``commit`` keyword argument, which This ``save()`` method accepts an optional ``commit`` keyword argument, which
accepts either ``True`` or ``False``. If you call ``save()`` with accepts either ``True`` or ``False``. If you call ``save()`` with
``commit=False``, then it will return an object that hasn't yet been saved to ``commit=False``, then it will return an object that hasn't yet been saved to

View File

@ -85,3 +85,7 @@ class CheckboxInputTest(WidgetTest):
def test_value_from_datadict_string_int(self): def test_value_from_datadict_string_int(self):
value = self.widget.value_from_datadict({'testing': '0'}, {}, 'testing') value = self.widget.value_from_datadict({'testing': '0'}, {}, 'testing')
self.assertIs(value, True) self.assertIs(value, True)
def test_value_omitted_from_data(self):
self.assertIs(self.widget.value_omitted_from_data({'field': 'value'}, {}, 'field'), False)
self.assertIs(self.widget.value_omitted_from_data({}, {}, 'field'), False)

View File

@ -14,3 +14,7 @@ class FileInputTest(WidgetTest):
self.check_html(self.widget, 'email', 'test@example.com', html='<input type="file" name="email" />') self.check_html(self.widget, 'email', 'test@example.com', html='<input type="file" name="email" />')
self.check_html(self.widget, 'email', '', html='<input type="file" name="email" />') self.check_html(self.widget, 'email', '', html='<input type="file" name="email" />')
self.check_html(self.widget, 'email', None, html='<input type="file" name="email" />') self.check_html(self.widget, 'email', None, html='<input type="file" name="email" />')
def test_value_omitted_from_data(self):
self.assertIs(self.widget.value_omitted_from_data({}, {}, 'field'), True)
self.assertIs(self.widget.value_omitted_from_data({}, {'field': 'value'}, 'field'), False)

View File

@ -118,6 +118,13 @@ class MultiWidgetTest(WidgetTest):
'<input id="bar_1" type="text" class="small" value="lennon" name="name_1" />' '<input id="bar_1" type="text" class="small" value="lennon" name="name_1" />'
)) ))
def test_value_omitted_from_data(self):
widget = MyMultiWidget(widgets=(TextInput(), TextInput()))
self.assertIs(widget.value_omitted_from_data({}, {}, 'field'), True)
self.assertIs(widget.value_omitted_from_data({'field_0': 'x'}, {}, 'field'), False)
self.assertIs(widget.value_omitted_from_data({'field_1': 'y'}, {}, 'field'), False)
self.assertIs(widget.value_omitted_from_data({'field_0': 'x', 'field_1': 'y'}, {}, 'field'), False)
def test_needs_multipart_true(self): def test_needs_multipart_true(self):
""" """
needs_multipart_form should be True if any widgets need it. needs_multipart_form should be True if any widgets need it.

View File

@ -477,3 +477,11 @@ class SelectDateWidgetTest(WidgetTest):
w.value_from_datadict({'date_year': '1899', 'date_month': '8', 'date_day': '13'}, {}, 'date'), w.value_from_datadict({'date_year': '1899', 'date_month': '8', 'date_day': '13'}, {}, 'date'),
'13-08-1899', '13-08-1899',
) )
def test_value_omitted_from_data(self):
self.assertIs(self.widget.value_omitted_from_data({}, {}, 'field'), True)
self.assertIs(self.widget.value_omitted_from_data({'field_month': '12'}, {}, 'field'), False)
self.assertIs(self.widget.value_omitted_from_data({'field_year': '2000'}, {}, 'field'), False)
self.assertIs(self.widget.value_omitted_from_data({'field_day': '1'}, {}, 'field'), False)
data = {'field_day': '1', 'field_month': '12', 'field_year': '2000'}
self.assertIs(self.widget.value_omitted_from_data(data, {}, 'field'), False)

View File

@ -0,0 +1,12 @@
from __future__ import unicode_literals
from django.forms import Widget
from django.test import SimpleTestCase
class WidgetTests(SimpleTestCase):
def test_value_omitted_from_data(self):
widget = Widget()
self.assertIs(widget.value_omitted_from_data({}, {}, 'field'), True)
self.assertIs(widget.value_omitted_from_data({'field': 'value'}, {}, 'field'), False)

View File

@ -120,9 +120,11 @@ class PublicationDefaults(models.Model):
CATEGORY_CHOICES = ((1, 'Games'), (2, 'Comics'), (3, 'Novel')) CATEGORY_CHOICES = ((1, 'Games'), (2, 'Comics'), (3, 'Novel'))
title = models.CharField(max_length=30) title = models.CharField(max_length=30)
date_published = models.DateField(default=datetime.date.today) date_published = models.DateField(default=datetime.date.today)
datetime_published = models.DateTimeField(default=datetime.datetime(2000, 1, 1))
mode = models.CharField(max_length=2, choices=MODE_CHOICES, default=default_mode) mode = models.CharField(max_length=2, choices=MODE_CHOICES, default=default_mode)
category = models.IntegerField(choices=CATEGORY_CHOICES, default=default_category) category = models.IntegerField(choices=CATEGORY_CHOICES, default=default_category)
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
file = models.FileField(default='default.txt')
class Author(models.Model): class Author(models.Model):

View File

@ -601,6 +601,58 @@ class ModelFormBaseTest(TestCase):
m1 = mf1.save(commit=False) m1 = mf1.save(commit=False)
self.assertEqual(m1.mode, mode) self.assertEqual(m1.mode, mode)
def test_default_splitdatetime_field(self):
class PubForm(forms.ModelForm):
datetime_published = forms.SplitDateTimeField(required=False)
class Meta:
model = PublicationDefaults
fields = ('datetime_published',)
mf1 = PubForm({})
self.assertEqual(mf1.errors, {})
m1 = mf1.save(commit=False)
self.assertEqual(m1.datetime_published, datetime.datetime(2000, 1, 1))
mf2 = PubForm({'datetime_published_0': '2010-01-01', 'datetime_published_1': '0:00:00'})
self.assertEqual(mf2.errors, {})
m2 = mf2.save(commit=False)
self.assertEqual(m2.datetime_published, datetime.datetime(2010, 1, 1))
def test_default_filefield(self):
class PubForm(forms.ModelForm):
class Meta:
model = PublicationDefaults
fields = ('file',)
mf1 = PubForm({})
self.assertEqual(mf1.errors, {})
m1 = mf1.save(commit=False)
self.assertEqual(m1.file.name, 'default.txt')
mf2 = PubForm({}, {'file': SimpleUploadedFile('name', b'foo')})
self.assertEqual(mf2.errors, {})
m2 = mf2.save(commit=False)
self.assertEqual(m2.file.name, 'name')
def test_selectdatewidget(self):
class PubForm(forms.ModelForm):
date_published = forms.DateField(required=False, widget=forms.SelectDateWidget)
class Meta:
model = PublicationDefaults
fields = ('date_published',)
mf1 = PubForm({})
self.assertEqual(mf1.errors, {})
m1 = mf1.save(commit=False)
self.assertEqual(m1.date_published, datetime.date.today())
mf2 = PubForm({'date_published_year': '2010', 'date_published_month': '1', 'date_published_day': '1'})
self.assertEqual(mf2.errors, {})
m2 = mf2.save(commit=False)
self.assertEqual(m2.date_published, datetime.date(2010, 1, 1))
class FieldOverridesByFormMetaForm(forms.ModelForm): class FieldOverridesByFormMetaForm(forms.ModelForm):
class Meta: class Meta:

View File

@ -40,7 +40,7 @@ class PostgreSQLModel(models.Model):
class IntegerArrayModel(PostgreSQLModel): class IntegerArrayModel(PostgreSQLModel):
field = ArrayField(models.IntegerField()) field = ArrayField(models.IntegerField(), default=[], blank=True)
class NullableIntegerArrayModel(PostgreSQLModel): class NullableIntegerArrayModel(PostgreSQLModel):

View File

@ -20,7 +20,9 @@ from .models import (
try: try:
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.forms import SimpleArrayField, SplitArrayField from django.contrib.postgres.forms import (
SimpleArrayField, SplitArrayField, SplitArrayWidget,
)
except ImportError: except ImportError:
pass pass
@ -714,3 +716,26 @@ class TestSplitFormField(PostgreSQLTestCase):
'Item 0 in the array did not validate: Ensure this value has at most 2 characters (it has 3).', 'Item 0 in the array did not validate: Ensure this value has at most 2 characters (it has 3).',
'Item 2 in the array did not validate: Ensure this value has at most 2 characters (it has 4).', 'Item 2 in the array did not validate: Ensure this value has at most 2 characters (it has 4).',
]) ])
def test_splitarraywidget_value_omitted_from_data(self):
class Form(forms.ModelForm):
field = SplitArrayField(forms.IntegerField(), required=False, size=2)
class Meta:
model = IntegerArrayModel
fields = ('field',)
form = Form({'field_0': '1', 'field_1': '2'})
self.assertEqual(form.errors, {})
obj = form.save(commit=False)
self.assertEqual(obj.field, [1, 2])
class TestSplitFormWidget(PostgreSQLTestCase):
def test_value_omitted_from_data(self):
widget = SplitArrayWidget(forms.TextInput(), size=2)
self.assertIs(widget.value_omitted_from_data({}, {}, 'field'), True)
self.assertIs(widget.value_omitted_from_data({'field_0': 'value'}, {}, 'field'), False)
self.assertIs(widget.value_omitted_from_data({'field_1': 'value'}, {}, 'field'), False)
self.assertIs(widget.value_omitted_from_data({'field_0': 'value', 'field_1': 'value'}, {}, 'field'), False)