diff options
| author | Tim Graham <timograham@gmail.com> | 2016-09-06 17:41:54 -0400 |
|---|---|---|
| committer | Tim Graham <timograham@gmail.com> | 2016-09-22 13:07:23 -0400 |
| commit | 0b59ea3343ef6f035064cfae1608f54c5a3fd147 (patch) | |
| tree | 81a3499027dd1397d6345a518c4b4cba8e5a1cdd | |
| parent | 190cd0e49f967d2ec0b6c50a63f0d58d13319611 (diff) | |
[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 3507d4e773aa9ff2336e7230ba231c4ba6eb568f from master
| -rw-r--r-- | django/contrib/postgres/forms/array.py | 6 | ||||
| -rw-r--r-- | django/forms/models.py | 4 | ||||
| -rw-r--r-- | django/forms/widgets.py | 27 | ||||
| -rw-r--r-- | docs/ref/forms/widgets.txt | 15 | ||||
| -rw-r--r-- | docs/releases/1.10.2.txt | 5 | ||||
| -rw-r--r-- | docs/topics/forms/modelforms.txt | 16 | ||||
| -rw-r--r-- | tests/forms_tests/widget_tests/test_checkboxinput.py | 4 | ||||
| -rw-r--r-- | tests/forms_tests/widget_tests/test_fileinput.py | 4 | ||||
| -rw-r--r-- | tests/forms_tests/widget_tests/test_multiwidget.py | 7 | ||||
| -rw-r--r-- | tests/forms_tests/widget_tests/test_selectdatewidget.py | 8 | ||||
| -rw-r--r-- | tests/forms_tests/widget_tests/test_widget.py | 12 | ||||
| -rw-r--r-- | tests/model_forms/models.py | 2 | ||||
| -rw-r--r-- | tests/model_forms/tests.py | 52 | ||||
| -rw-r--r-- | tests/postgres_tests/models.py | 2 | ||||
| -rw-r--r-- | tests/postgres_tests/test_array.py | 27 |
15 files changed, 178 insertions, 13 deletions
diff --git a/django/contrib/postgres/forms/array.py b/django/contrib/postgres/forms/array.py index dd9ac1178e..bd3daaae0b 100644 --- a/django/contrib/postgres/forms/array.py +++ b/django/contrib/postgres/forms/array.py @@ -103,6 +103,12 @@ class SplitArrayWidget(forms.Widget): return [self.widget.value_from_datadict(data, files, '%s_%s' % (name, index)) 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_): # See the comment for RadioSelect.id_for_label() if id_: diff --git a/django/forms/models.py b/django/forms/models.py index 1d54ea9676..ce93dead9e 100644 --- a/django/forms/models.py +++ b/django/forms/models.py @@ -54,8 +54,8 @@ def construct_instance(form, instance, fields=None, exclude=None): continue # 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. - if (f.has_default() and form.add_prefix(f.name) not in form.data and - not getattr(form[f.name].field.widget, 'dont_use_model_field_default_for_empty_data', False)): + if (f.has_default() and + form[f.name].field.widget.value_omitted_from_data(form.data, form.files, form.add_prefix(f.name))): continue # Defer saving file-type fields until after the other fields, so a # callable upload_to can use the values from other fields. diff --git a/django/forms/widgets.py b/django/forms/widgets.py index 735207aa80..965f84f7df 100644 --- a/django/forms/widgets.py +++ b/django/forms/widgets.py @@ -237,6 +237,9 @@ class Widget(six.with_metaclass(RenameWidgetMethods)): """ return data.get(name) + def value_omitted_from_data(self, data, files, name): + return name not in data + def id_for_label(self, id_): """ 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" return files.get(name) + def value_omitted_from_data(self, data, files, name): + return name not in files + FILE_INPUT_CONTRADICTION = object() @@ -480,10 +486,6 @@ def boolean_check(v): 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): super(CheckboxInput, self).__init__(attrs) # 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) 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): allow_multiple_selected = False @@ -871,6 +878,12 @@ class MultiWidget(Widget): 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)] + 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): """ 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 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): if 'id' in self.attrs: id_ = self.attrs['id'] diff --git a/docs/ref/forms/widgets.txt b/docs/ref/forms/widgets.txt index 0bc23aab66..fde7806b02 100644 --- a/docs/ref/forms/widgets.txt +++ b/docs/ref/forms/widgets.txt @@ -271,6 +271,21 @@ foundation for custom widgets. customize it and add expensive processing, you should implement some 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`` --------------- diff --git a/docs/releases/1.10.2.txt b/docs/releases/1.10.2.txt index 8c031d37f5..222323c245 100644 --- a/docs/releases/1.10.2.txt +++ b/docs/releases/1.10.2.txt @@ -17,3 +17,8 @@ Bugfixes * Disabled system check for URL patterns beginning with a '/' when ``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. diff --git a/docs/topics/forms/modelforms.txt b/docs/topics/forms/modelforms.txt index e2b57fe410..3d5bc746fe 100644 --- a/docs/topics/forms/modelforms.txt +++ b/docs/topics/forms/modelforms.txt @@ -299,6 +299,8 @@ to the ``error_messages`` dictionary of the ``ModelForm``’s inner ``Meta`` cla } } +.. _topics-modelform-save: + 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 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 -:class:`~django.forms.CheckboxInput` (or any custom widget with -``dont_use_model_field_default_for_empty_data=True``) since an unchecked -checkbox doesn't appear in the data of an HTML form submission. Use a custom -form field or widget if you're designing an API and want the default fallback -for a ``BooleanField``. +:class:`~django.forms.CheckboxInput` (or any custom widget whose +:meth:`~django.forms.Widget.value_omitted_from_data` method always returns +``False``) since an unchecked checkbox doesn't appear in the data of an HTML +form submission. Use a custom form field or widget if you're designing an API +and want the default fallback for a :class:`~django.db.models.BooleanField`. .. versionchanged:: 1.10.1 @@ -345,6 +347,10 @@ for a ``BooleanField``. :class:`~django.forms.CheckboxInput` which means that unchecked checkboxes 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 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 diff --git a/tests/forms_tests/widget_tests/test_checkboxinput.py b/tests/forms_tests/widget_tests/test_checkboxinput.py index 77b03b9ea9..ecfc2105c8 100644 --- a/tests/forms_tests/widget_tests/test_checkboxinput.py +++ b/tests/forms_tests/widget_tests/test_checkboxinput.py @@ -85,3 +85,7 @@ class CheckboxInputTest(WidgetTest): def test_value_from_datadict_string_int(self): value = self.widget.value_from_datadict({'testing': '0'}, {}, 'testing') 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) diff --git a/tests/forms_tests/widget_tests/test_fileinput.py b/tests/forms_tests/widget_tests/test_fileinput.py index eb1c9d81ed..6e96dc7b5f 100644 --- a/tests/forms_tests/widget_tests/test_fileinput.py +++ b/tests/forms_tests/widget_tests/test_fileinput.py @@ -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', '', 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) diff --git a/tests/forms_tests/widget_tests/test_multiwidget.py b/tests/forms_tests/widget_tests/test_multiwidget.py index bb6f8bfc4f..99aecea1ab 100644 --- a/tests/forms_tests/widget_tests/test_multiwidget.py +++ b/tests/forms_tests/widget_tests/test_multiwidget.py @@ -118,6 +118,13 @@ class MultiWidgetTest(WidgetTest): '<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): """ needs_multipart_form should be True if any widgets need it. diff --git a/tests/forms_tests/widget_tests/test_selectdatewidget.py b/tests/forms_tests/widget_tests/test_selectdatewidget.py index 6efa240b66..fcce916f76 100644 --- a/tests/forms_tests/widget_tests/test_selectdatewidget.py +++ b/tests/forms_tests/widget_tests/test_selectdatewidget.py @@ -477,3 +477,11 @@ class SelectDateWidgetTest(WidgetTest): w.value_from_datadict({'date_year': '1899', 'date_month': '8', 'date_day': '13'}, {}, 'date'), '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) diff --git a/tests/forms_tests/widget_tests/test_widget.py b/tests/forms_tests/widget_tests/test_widget.py new file mode 100644 index 0000000000..3911627a0c --- /dev/null +++ b/tests/forms_tests/widget_tests/test_widget.py @@ -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) diff --git a/tests/model_forms/models.py b/tests/model_forms/models.py index dad245ba78..9898022cca 100644 --- a/tests/model_forms/models.py +++ b/tests/model_forms/models.py @@ -120,9 +120,11 @@ class PublicationDefaults(models.Model): CATEGORY_CHOICES = ((1, 'Games'), (2, 'Comics'), (3, 'Novel')) title = models.CharField(max_length=30) 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) category = models.IntegerField(choices=CATEGORY_CHOICES, default=default_category) active = models.BooleanField(default=True) + file = models.FileField(default='default.txt') class Author(models.Model): diff --git a/tests/model_forms/tests.py b/tests/model_forms/tests.py index 593e18fdaa..f2931262c2 100644 --- a/tests/model_forms/tests.py +++ b/tests/model_forms/tests.py @@ -601,6 +601,58 @@ class ModelFormBaseTest(TestCase): m1 = mf1.save(commit=False) 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 Meta: diff --git a/tests/postgres_tests/models.py b/tests/postgres_tests/models.py index d94eb90d4a..7d2dabcd01 100644 --- a/tests/postgres_tests/models.py +++ b/tests/postgres_tests/models.py @@ -40,7 +40,7 @@ class PostgreSQLModel(models.Model): class IntegerArrayModel(PostgreSQLModel): - field = ArrayField(models.IntegerField()) + field = ArrayField(models.IntegerField(), default=[], blank=True) class NullableIntegerArrayModel(PostgreSQLModel): diff --git a/tests/postgres_tests/test_array.py b/tests/postgres_tests/test_array.py index 6a305556d4..5929c565d6 100644 --- a/tests/postgres_tests/test_array.py +++ b/tests/postgres_tests/test_array.py @@ -20,7 +20,9 @@ from .models import ( try: 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: 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 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) |
