Browse Source

Fixed #9061 -- Allowed FormSets to disable deleting extra forms.

Thanks to Dan Ward for the initial patch.
David Smith 4 years ago
parent
commit
162765d6c3

+ 6 - 3
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,

+ 4 - 3
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

+ 2 - 2
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.

+ 7 - 6
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
     </topics/forms/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.

+ 5 - 0
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
 ~~~~~~~~~~~~~
 

+ 12 - 0
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
 =====================================
 

+ 45 - 0
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):

+ 54 - 0
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)