From 1b7634a0d0e21faba71a27ae7951d7cb7aec0e49 Mon Sep 17 00:00:00 2001
From: Baptiste Mispelon <bmispelon@gmail.com>
Date: Sat, 15 Jun 2013 22:34:25 +0200
Subject: [PATCH] Fixed #20464 -- Added a `total_error_count` method on
 formsets.

Thanks to frog32 for the report and to Tim Graham for the review.
---
 .../admin/templates/admin/change_list.html    |   2 +-
 django/forms/formsets.py                      |   7 +
 docs/releases/1.6.txt                         |   3 +
 docs/topics/forms/formsets.txt                |  17 ++
 tests/forms_tests/tests/test_formsets.py      | 154 +++++++++---------
 5 files changed, 106 insertions(+), 77 deletions(-)

diff --git a/django/contrib/admin/templates/admin/change_list.html b/django/contrib/admin/templates/admin/change_list.html
index 5d1a6b2714..200b4cd74d 100644
--- a/django/contrib/admin/templates/admin/change_list.html
+++ b/django/contrib/admin/templates/admin/change_list.html
@@ -64,7 +64,7 @@
     {% endblock %}
     {% if cl.formset.errors %}
         <p class="errornote">
-        {% if cl.formset.errors|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %}
+        {% if cl.formset.total_error_count == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %}
         </p>
         {{ cl.formset.non_form_errors }}
     {% endif %}
diff --git a/django/forms/formsets.py b/django/forms/formsets.py
index fd98c43405..79e1c2298a 100644
--- a/django/forms/formsets.py
+++ b/django/forms/formsets.py
@@ -263,6 +263,13 @@ class BaseFormSet(object):
             self.full_clean()
         return self._errors
 
+    def total_error_count(self):
+        """
+        Returns the number of errors across all forms in the formset.
+        """
+        return len(self.non_form_errors()) +\
+            sum(len(form_errors) for form_errors in self.errors)
+
     def _should_delete_form(self, form):
         """
         Returns whether or not the form was marked for deletion.
diff --git a/docs/releases/1.6.txt b/docs/releases/1.6.txt
index 458ccdc2a5..7d449edbe5 100644
--- a/docs/releases/1.6.txt
+++ b/docs/releases/1.6.txt
@@ -315,6 +315,9 @@ Minor features
   :class:`~django.contrib.admin.InlineModelAdmin` may be overridden to
   customize the extra and maximum number of inline forms.
 
+* Formsets now have a
+  :meth:`~django.forms.formsets.BaseFormSet.total_error_count` method.
+
 Backwards incompatible changes in 1.6
 =====================================
 
diff --git a/docs/topics/forms/formsets.txt b/docs/topics/forms/formsets.txt
index 77341cf55d..29139c5dea 100644
--- a/docs/topics/forms/formsets.txt
+++ b/docs/topics/forms/formsets.txt
@@ -164,6 +164,23 @@ As we can see, ``formset.errors`` is a list whose entries correspond to the
 forms in the formset. Validation was performed for each of the two forms, and
 the expected error message appears for the second item.
 
+.. currentmodule:: django.forms.formsets.BaseFormSet
+
+.. method:: total_error_count(self)
+
+.. versionadded:: 1.6
+
+To check how many errors there are in the formset, we can use the
+``total_error_count`` method::
+
+    >>> # Using the previous example
+    >>> formset.errors
+    [{}, {'pub_date': [u'This field is required.']}]
+    >>> len(formset.errors)
+    2
+    >>> formset.total_error_count()
+    1
+
 We can also check if form data differs from the initial data (i.e. the form was
 sent without any data)::
 
diff --git a/tests/forms_tests/tests/test_formsets.py b/tests/forms_tests/tests/test_formsets.py
index 1e9e7db30c..b26017bc78 100644
--- a/tests/forms_tests/tests/test_formsets.py
+++ b/tests/forms_tests/tests/test_formsets.py
@@ -55,81 +55,82 @@ SplitDateTimeFormSet = formset_factory(SplitDateTimeForm)
 
 
 class FormsFormsetTestCase(TestCase):
+
+    def make_choiceformset(self, formset_data=None, formset_class=ChoiceFormSet,
+        total_forms=None, initial_forms=0, max_num_forms=0, **kwargs):
+        """
+        Make a ChoiceFormset from the given formset_data.
+        The data should be given as a list of (choice, votes) tuples.
+        """
+        kwargs.setdefault('prefix', 'choices')
+        kwargs.setdefault('auto_id', False)
+
+        if formset_data is None:
+            return formset_class(**kwargs)
+
+        if total_forms is None:
+            total_forms = len(formset_data)
+
+        def prefixed(*args):
+            args = (kwargs['prefix'],) + args
+            return '-'.join(args)
+
+        data = {
+            prefixed('TOTAL_FORMS'): str(total_forms),
+            prefixed('INITIAL_FORMS'): str(initial_forms),
+            prefixed('MAX_NUM_FORMS'): str(max_num_forms),
+        }
+        for i, (choice, votes) in enumerate(formset_data):
+            data[prefixed(str(i), 'choice')] = choice
+            data[prefixed(str(i), 'votes')] = votes
+
+        return formset_class(data, **kwargs)
+
     def test_basic_formset(self):
         # A FormSet constructor takes the same arguments as Form. Let's create a FormSet
         # for adding data. By default, it displays 1 blank form. It can display more,
         # but we'll look at how to do so later.
-        formset = ChoiceFormSet(auto_id=False, prefix='choices')
+        formset = self.make_choiceformset()
+        
         self.assertHTMLEqual(str(formset), """<input type="hidden" name="choices-TOTAL_FORMS" value="1" /><input type="hidden" name="choices-INITIAL_FORMS" value="0" /><input type="hidden" name="choices-MAX_NUM_FORMS" value="1000" />
 <tr><th>Choice:</th><td><input type="text" name="choices-0-choice" /></td></tr>
 <tr><th>Votes:</th><td><input type="number" name="choices-0-votes" /></td></tr>""")
 
-        # 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
-        # many forms it needs to clean and validate. You could use javascript to create
-        # new forms on the client side, but they won't get validated unless you increment
-        # the TOTAL_FORMS field appropriately.
-
-        data = {
-            'choices-TOTAL_FORMS': '1', # the number of forms rendered
-            'choices-INITIAL_FORMS': '0', # the number of forms with initial data
-            'choices-MAX_NUM_FORMS': '0', # max number of forms
-            'choices-0-choice': 'Calexico',
-            'choices-0-votes': '100',
-        }
         # We treat FormSet pretty much like we would treat a normal Form. FormSet has an
         # is_valid method, and a cleaned_data or errors attribute depending on whether all
         # the forms passed validation. However, unlike a Form instance, cleaned_data and
         # errors will be a list of dicts rather than just a single dict.
 
-        formset = ChoiceFormSet(data, auto_id=False, prefix='choices')
+        formset = self.make_choiceformset([('Calexico', '100')])
         self.assertTrue(formset.is_valid())
         self.assertEqual([form.cleaned_data for form in formset.forms], [{'votes': 100, 'choice': 'Calexico'}])
 
         # If a FormSet was not passed any data, its is_valid and has_changed
         # methods should return False.
-        formset = ChoiceFormSet()
+        formset = self.make_choiceformset()
         self.assertFalse(formset.is_valid())
         self.assertFalse(formset.has_changed())
 
     def test_formset_validation(self):
         # FormSet instances can also have an error attribute if validation failed for
         # any of the forms.
-
-        data = {
-            'choices-TOTAL_FORMS': '1', # the number of forms rendered
-            'choices-INITIAL_FORMS': '0', # the number of forms with initial data
-            'choices-MAX_NUM_FORMS': '0', # max number of forms
-            'choices-0-choice': 'Calexico',
-            'choices-0-votes': '',
-        }
-
-        formset = ChoiceFormSet(data, auto_id=False, prefix='choices')
+        formset = self.make_choiceformset([('Calexico', '')])
         self.assertFalse(formset.is_valid())
         self.assertEqual(formset.errors, [{'votes': ['This field is required.']}])
 
     def test_formset_has_changed(self):
         # FormSet instances has_changed method will be True if any data is
         # passed to his forms, even if the formset didn't validate
-        data = {
-            'choices-TOTAL_FORMS': '1', # the number of forms rendered
-            'choices-INITIAL_FORMS': '0', # the number of forms with initial data
-            'choices-MAX_NUM_FORMS': '0', # max number of forms
-            'choices-0-choice': '',
-            'choices-0-votes': '',
-        }
-        blank_formset = ChoiceFormSet(data, auto_id=False, prefix='choices')
+        blank_formset = self.make_choiceformset([('', '')])
         self.assertFalse(blank_formset.has_changed())
 
         # invalid formset test
-        data['choices-0-choice'] = 'Calexico'
-        invalid_formset = ChoiceFormSet(data, auto_id=False, prefix='choices')
+        invalid_formset = self.make_choiceformset([('Calexico', '')])
         self.assertFalse(invalid_formset.is_valid())
         self.assertTrue(invalid_formset.has_changed())
 
         # valid formset test
-        data['choices-0-votes'] = '100'
-        valid_formset = ChoiceFormSet(data, auto_id=False, prefix='choices')
+        valid_formset = self.make_choiceformset([('Calexico', '100')])
         self.assertTrue(valid_formset.is_valid())
         self.assertTrue(valid_formset.has_changed())
 
@@ -139,7 +140,7 @@ class FormsFormsetTestCase(TestCase):
         # an extra blank form is included.
 
         initial = [{'choice': 'Calexico', 'votes': 100}]
-        formset = ChoiceFormSet(initial=initial, auto_id=False, prefix='choices')
+        formset = self.make_choiceformset(initial=initial)
         form_output = []
 
         for form in formset.forms:
@@ -151,18 +152,7 @@ class FormsFormsetTestCase(TestCase):
 <li>Votes: <input type="number" name="choices-1-votes" /></li>""")
 
         # Let's simulate what would happen if we submitted this form.
-
-        data = {
-            'choices-TOTAL_FORMS': '2', # the number of forms rendered
-            'choices-INITIAL_FORMS': '1', # the number of forms with initial data
-            'choices-MAX_NUM_FORMS': '0', # max number of forms
-            'choices-0-choice': 'Calexico',
-            'choices-0-votes': '100',
-            'choices-1-choice': '',
-            'choices-1-votes': '',
-        }
-
-        formset = ChoiceFormSet(data, auto_id=False, prefix='choices')
+        formset = self.make_choiceformset([('Calexico', '100'), ('', '')], initial_forms=1)
         self.assertTrue(formset.is_valid())
         self.assertEqual([form.cleaned_data for form in formset.forms], [{'votes': 100, 'choice': 'Calexico'}, {}])
 
@@ -172,18 +162,7 @@ class FormsFormsetTestCase(TestCase):
         # one of the fields of a blank form though, it will be validated. We may want to
         # required that at least x number of forms are completed, but we'll show how to
         # handle that later.
-
-        data = {
-            'choices-TOTAL_FORMS': '2', # the number of forms rendered
-            'choices-INITIAL_FORMS': '1', # the number of forms with initial data
-            'choices-MAX_NUM_FORMS': '0', # max number of forms
-            'choices-0-choice': 'Calexico',
-            'choices-0-votes': '100',
-            'choices-1-choice': 'The Decemberists',
-            'choices-1-votes': '', # missing value
-        }
-
-        formset = ChoiceFormSet(data, auto_id=False, prefix='choices')
+        formset = self.make_choiceformset([('Calexico', '100'), ('The Decemberists', '')], initial_forms=1)
         self.assertFalse(formset.is_valid())
         self.assertEqual(formset.errors, [{}, {'votes': ['This field is required.']}])
 
@@ -191,18 +170,7 @@ class FormsFormsetTestCase(TestCase):
         # If we delete data that was pre-filled, we should get an error. Simply removing
         # data from form fields isn't the proper way to delete it. We'll see how to
         # handle that case later.
-
-        data = {
-            'choices-TOTAL_FORMS': '2', # the number of forms rendered
-            'choices-INITIAL_FORMS': '1', # the number of forms with initial data
-            'choices-MAX_NUM_FORMS': '0', # max number of forms
-            'choices-0-choice': '', # deleted value
-            'choices-0-votes': '', # deleted value
-            'choices-1-choice': '',
-            'choices-1-votes': '',
-        }
-
-        formset = ChoiceFormSet(data, auto_id=False, prefix='choices')
+        formset = self.make_choiceformset([('', ''), ('', '')], initial_forms=1)
         self.assertFalse(formset.is_valid())
         self.assertEqual(formset.errors, [{'votes': ['This field is required.'], 'choice': ['This field is required.']}, {}])
 
@@ -1027,6 +995,40 @@ class FormsFormsetTestCase(TestCase):
         self.assertTrue(formset.is_valid())
 
 
+    def test_formset_total_error_count(self):
+        """A valid formset should have 0 total errors."""
+        data = [ #  formset_data, expected error count
+            ([('Calexico', '100')], 0),
+            ([('Calexico', '')], 1),
+            ([('', 'invalid')], 2),
+            ([('Calexico', '100'), ('Calexico', '')], 1),
+            ([('Calexico', ''), ('Calexico', '')], 2),
+        ]
+        
+        for formset_data, expected_error_count in data:
+            formset = self.make_choiceformset(formset_data)
+            self.assertEqual(formset.total_error_count(), expected_error_count)
+
+    def test_formset_total_error_count_with_non_form_errors(self):
+        data = {
+            'choices-TOTAL_FORMS': '2', # the number of forms rendered
+            'choices-INITIAL_FORMS': '0', # the number of forms with initial data
+            'choices-MAX_NUM_FORMS': '2', # max number of forms - should be ignored
+            'choices-0-choice': 'Zero',
+            'choices-0-votes': '0',
+            'choices-1-choice': 'One',
+            'choices-1-votes': '1',
+        }
+
+        ChoiceFormSet = formset_factory(Choice, extra=1, max_num=1, validate_max=True)
+        formset = ChoiceFormSet(data, auto_id=False, prefix='choices')
+        self.assertEqual(formset.total_error_count(), 1)
+
+        data['choices-1-votes'] = ''
+        formset = ChoiceFormSet(data, auto_id=False, prefix='choices')
+        self.assertEqual(formset.total_error_count(), 2)
+
+
 data = {
     'choices-TOTAL_FORMS': '1', # the number of forms rendered
     'choices-INITIAL_FORMS': '0', # the number of forms with initial data
@@ -1087,7 +1089,7 @@ class TestIsBoundBehavior(TestCase):
         self.assertEqual([{}], formset.cleaned_data)
 
 
-    def test_form_errors_are_cought_by_formset(self):
+    def test_form_errors_are_caught_by_formset(self):
         data = {
             'form-TOTAL_FORMS': '2',
             'form-INITIAL_FORMS': '0',