From 162765d6c3182e36095d29543e21b44b908625fc Mon Sep 17 00:00:00 2001 From: David Smith Date: Fri, 19 Jun 2020 22:46:25 +0100 Subject: [PATCH] Fixed #9061 -- Allowed FormSets to disable deleting extra forms. Thanks to Dan Ward for the initial patch. --- django/forms/formsets.py | 9 ++-- django/forms/models.py | 7 +-- docs/ref/forms/formsets.txt | 4 +- docs/ref/forms/models.txt | 13 +++--- docs/releases/3.2.txt | 5 +++ docs/topics/forms/formsets.txt | 12 ++++++ tests/forms_tests/tests/test_formsets.py | 45 ++++++++++++++++++++ tests/model_formsets/tests.py | 54 ++++++++++++++++++++++++ 8 files changed, 135 insertions(+), 14 deletions(-) diff --git a/django/forms/formsets.py b/django/forms/formsets.py index 6f819bd696..414ec70d28 100644 --- a/django/forms/formsets.py +++ b/django/forms/formsets.py @@ -372,9 +372,10 @@ class BaseFormSet: def add_fields(self, form, index): """A hook for adding extra fields on to each form instance.""" + initial_form_count = self.initial_form_count() if self.can_order: # Only pre-fill the ordering field for initial forms. - if index is not None and index < self.initial_form_count(): + if index is not None and index < initial_form_count: form.fields[ORDERING_FIELD_NAME] = IntegerField( label=_('Order'), initial=index + 1, @@ -387,7 +388,7 @@ class BaseFormSet: required=False, widget=self.get_ordering_widget(), ) - if self.can_delete: + if self.can_delete and (self.can_delete_extra or index < initial_form_count): form.fields[DELETION_FIELD_NAME] = BooleanField(label=_('Delete'), required=False) def add_prefix(self, index): @@ -433,7 +434,8 @@ class BaseFormSet: def formset_factory(form, formset=BaseFormSet, extra=1, can_order=False, can_delete=False, max_num=None, validate_max=False, - min_num=None, validate_min=False, absolute_max=None): + min_num=None, validate_min=False, absolute_max=None, + can_delete_extra=True): """Return a FormSet for the given form class.""" if min_num is None: min_num = DEFAULT_MIN_NUM @@ -453,6 +455,7 @@ def formset_factory(form, formset=BaseFormSet, extra=1, can_order=False, 'extra': extra, 'can_order': can_order, 'can_delete': can_delete, + 'can_delete_extra': can_delete_extra, 'min_num': min_num, 'max_num': max_num, 'absolute_max': absolute_max, diff --git a/django/forms/models.py b/django/forms/models.py index 9f21156329..ab611afca7 100644 --- a/django/forms/models.py +++ b/django/forms/models.py @@ -863,7 +863,7 @@ def modelformset_factory(model, form=ModelForm, formfield_callback=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None, - absolute_max=None): + absolute_max=None, can_delete_extra=True): """Return a FormSet class for the given Django model class.""" meta = getattr(form, 'Meta', None) if (getattr(meta, 'fields', fields) is None and @@ -881,7 +881,7 @@ def modelformset_factory(model, form=ModelForm, formfield_callback=None, FormSet = formset_factory(form, formset, extra=extra, min_num=min_num, max_num=max_num, can_order=can_order, can_delete=can_delete, validate_min=validate_min, validate_max=validate_max, - absolute_max=absolute_max) + absolute_max=absolute_max, can_delete_extra=can_delete_extra) FormSet.model = model return FormSet @@ -1051,7 +1051,7 @@ def inlineformset_factory(parent_model, model, form=ModelForm, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None, - absolute_max=None): + absolute_max=None, can_delete_extra=True): """ Return an ``InlineFormSet`` for the given kwargs. @@ -1082,6 +1082,7 @@ def inlineformset_factory(parent_model, model, form=ModelForm, 'error_messages': error_messages, 'field_classes': field_classes, 'absolute_max': absolute_max, + 'can_delete_extra': can_delete_extra, } FormSet = modelformset_factory(model, **kwargs) FormSet.fk = fk diff --git a/docs/ref/forms/formsets.txt b/docs/ref/forms/formsets.txt index e145eeb103..0e281f2f59 100644 --- a/docs/ref/forms/formsets.txt +++ b/docs/ref/forms/formsets.txt @@ -11,7 +11,7 @@ Formset API reference. For introductory material about formsets, see the ``formset_factory`` =================== -.. function:: formset_factory(form, formset=BaseFormSet, extra=1, can_order=False, can_delete=False, max_num=None, validate_max=False, min_num=None, validate_min=False, absolute_max=None) +.. function:: formset_factory(form, formset=BaseFormSet, extra=1, can_order=False, can_delete=False, max_num=None, validate_max=False, min_num=None, validate_min=False, absolute_max=None, can_delete_extra=True) Returns a ``FormSet`` class for the given ``form`` class. @@ -19,4 +19,4 @@ Formset API reference. For introductory material about formsets, see the .. versionchanged:: 3.2 - The ``absolute_max`` argument was added. + The ``absolute_max`` and ``can_delete_extra`` arguments were added. diff --git a/docs/ref/forms/models.txt b/docs/ref/forms/models.txt index e18660b3c2..9041385409 100644 --- a/docs/ref/forms/models.txt +++ b/docs/ref/forms/models.txt @@ -52,7 +52,7 @@ Model Form API reference. For introductory material about model forms, see the ``modelformset_factory`` ======================== -.. function:: modelformset_factory(model, form=ModelForm, formfield_callback=None, formset=BaseModelFormSet, extra=1, can_delete=False, can_order=False, max_num=None, fields=None, exclude=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None, absolute_max=None) +.. function:: modelformset_factory(model, form=ModelForm, formfield_callback=None, formset=BaseModelFormSet, extra=1, can_delete=False, can_order=False, max_num=None, fields=None, exclude=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None, absolute_max=None, can_delete_extra=True) Returns a ``FormSet`` class for the given ``model`` class. @@ -62,20 +62,21 @@ Model Form API reference. For introductory material about model forms, see the through to :func:`~django.forms.models.modelform_factory`. Arguments ``formset``, ``extra``, ``max_num``, ``can_order``, - ``can_delete``, ``validate_max``, and ``absolute_max`` are passed through - to :func:`~django.forms.formsets.formset_factory`. See :doc:`formsets + ``can_delete``, ``can_delete_extra``,``validate_max``, and + ``absolute_max`` are passed through to + :func:`~django.forms.formsets.formset_factory`. See :doc:`formsets ` for details. See :ref:`model-formsets` for example usage. .. versionchanged:: 3.2 - The ``absolute_max`` argument was added. + The ``absolute_max`` and ``can_delete_extra`` arguments were added. ``inlineformset_factory`` ========================= -.. function:: inlineformset_factory(parent_model, model, form=ModelForm, formset=BaseInlineFormSet, fk_name=None, fields=None, exclude=None, extra=3, can_order=False, can_delete=True, max_num=None, formfield_callback=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None, absolute_max=None) +.. function:: inlineformset_factory(parent_model, model, form=ModelForm, formset=BaseInlineFormSet, fk_name=None, fields=None, exclude=None, extra=3, can_order=False, can_delete=True, max_num=None, formfield_callback=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None, absolute_max=None, can_delete_extra=True) Returns an ``InlineFormSet`` using :func:`modelformset_factory` with defaults of ``formset=``:class:`~django.forms.models.BaseInlineFormSet`, @@ -88,4 +89,4 @@ Model Form API reference. For introductory material about model forms, see the .. versionchanged:: 3.2 - The ``absolute_max`` argument was added. + The ``absolute_max`` and ``can_delete_extra`` arguments were added. diff --git a/docs/releases/3.2.txt b/docs/releases/3.2.txt index 4308d142e8..3cc39ff285 100644 --- a/docs/releases/3.2.txt +++ b/docs/releases/3.2.txt @@ -158,6 +158,11 @@ Forms customizing the maximum number of forms that can be instantiated when supplying ``POST`` data. See :ref:`formsets-absolute-max` for more details. +* The new ``can_delete_extra`` argument for :func:`.formset_factory`, + :func:`.inlineformset_factory`, and :func:`.modelformset_factory` allows + removal of the option to delete extra forms. See + :attr:`~.BaseFormSet.can_delete_extra` for more information. + Generic Views ~~~~~~~~~~~~~ diff --git a/docs/topics/forms/formsets.txt b/docs/topics/forms/formsets.txt index b3e696ed8c..b7be5eac4f 100644 --- a/docs/topics/forms/formsets.txt +++ b/docs/topics/forms/formsets.txt @@ -593,6 +593,18 @@ On the other hand, if you are using a plain ``FormSet``, it's up to you to handle ``formset.deleted_forms``, perhaps in your formset's ``save()`` method, as there's no general notion of what it means to delete a form. +``can_delete_extra`` +-------------------- + +.. versionadded:: 3.2 + +.. attribute:: BaseFormSet.can_delete_extra + +Default: ``True`` + +While setting ``can_delete=True``, specifying ``can_delete_extra=False`` will +remove the option to delete extra forms. + Adding additional fields to a formset ===================================== diff --git a/tests/forms_tests/tests/test_formsets.py b/tests/forms_tests/tests/test_formsets.py index e44b347802..b9c3de55ca 100644 --- a/tests/forms_tests/tests/test_formsets.py +++ b/tests/forms_tests/tests/test_formsets.py @@ -1197,6 +1197,51 @@ class FormsFormsetTestCase(SimpleTestCase): self.assertTrue(hasattr(formset, '__html__')) self.assertEqual(str(formset), formset.__html__()) + def test_can_delete_extra_formset_forms(self): + ChoiceFormFormset = formset_factory(form=Choice, can_delete=True, extra=2) + formset = ChoiceFormFormset() + self.assertEqual(len(formset), 2) + self.assertIn('DELETE', formset.forms[0].fields) + self.assertIn('DELETE', formset.forms[1].fields) + + def test_disable_delete_extra_formset_forms(self): + ChoiceFormFormset = formset_factory( + form=Choice, + can_delete=True, + can_delete_extra=False, + extra=2, + ) + formset = ChoiceFormFormset() + self.assertEqual(len(formset), 2) + self.assertNotIn('DELETE', formset.forms[0].fields) + self.assertNotIn('DELETE', formset.forms[1].fields) + + formset = ChoiceFormFormset(initial=[{'choice': 'Zero', 'votes': '1'}]) + self.assertEqual(len(formset), 3) + self.assertIn('DELETE', formset.forms[0].fields) + self.assertNotIn('DELETE', formset.forms[1].fields) + self.assertNotIn('DELETE', formset.forms[2].fields) + + formset = ChoiceFormFormset(data={ + 'form-0-choice': 'Zero', + 'form-0-votes': '0', + 'form-0-DELETE': 'on', + 'form-1-choice': 'One', + 'form-1-votes': '1', + 'form-2-choice': '', + 'form-2-votes': '', + 'form-TOTAL_FORMS': '3', + 'form-INITIAL_FORMS': '1', + }, initial=[{'choice': 'Zero', 'votes': '1'}]) + self.assertEqual(formset.cleaned_data, [ + {'choice': 'Zero', 'votes': 0, 'DELETE': True}, + {'choice': 'One', 'votes': 1}, + {}, + ]) + self.assertIs(formset._should_delete_form(formset.forms[0]), True) + self.assertIs(formset._should_delete_form(formset.forms[1]), False) + self.assertIs(formset._should_delete_form(formset.forms[2]), False) + class FormsetAsTagTests(SimpleTestCase): def setUp(self): diff --git a/tests/model_formsets/tests.py b/tests/model_formsets/tests.py index 8c6d87e59c..a01dd75e87 100644 --- a/tests/model_formsets/tests.py +++ b/tests/model_formsets/tests.py @@ -1916,3 +1916,57 @@ class TestModelFormsetOverridesTroughFormMeta(TestCase): formset.non_form_errors(), ['Please submit 20 or fewer forms.'], ) + + def test_modelformset_factory_can_delete_extra(self): + AuthorFormSet = modelformset_factory( + Author, + fields='__all__', + can_delete=True, + can_delete_extra=True, + extra=2, + ) + formset = AuthorFormSet() + self.assertEqual(len(formset), 2) + self.assertIn('DELETE', formset.forms[0].fields) + self.assertIn('DELETE', formset.forms[1].fields) + + def test_modelformset_factory_disable_delete_extra(self): + AuthorFormSet = modelformset_factory( + Author, + fields='__all__', + can_delete=True, + can_delete_extra=False, + extra=2, + ) + formset = AuthorFormSet() + self.assertEqual(len(formset), 2) + self.assertNotIn('DELETE', formset.forms[0].fields) + self.assertNotIn('DELETE', formset.forms[1].fields) + + def test_inlineformset_factory_can_delete_extra(self): + BookFormSet = inlineformset_factory( + Author, + Book, + fields='__all__', + can_delete=True, + can_delete_extra=True, + extra=2, + ) + formset = BookFormSet() + self.assertEqual(len(formset), 2) + self.assertIn('DELETE', formset.forms[0].fields) + self.assertIn('DELETE', formset.forms[1].fields) + + def test_inlineformset_factory_can_not_delete_extra(self): + BookFormSet = inlineformset_factory( + Author, + Book, + fields='__all__', + can_delete=True, + can_delete_extra=False, + extra=2, + ) + formset = BookFormSet() + self.assertEqual(len(formset), 2) + self.assertNotIn('DELETE', formset.forms[0].fields) + self.assertNotIn('DELETE', formset.forms[1].fields)