From 262fde94de5eb6544fc0f289575583436613c045 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc=20Segu=C3=AD=20Coll?= <metarizard@gmail.com>
Date: Sun, 8 May 2022 00:53:13 +0200
Subject: [PATCH] Fixed #33622 -- Allowed customizing error messages for
 invalid number of forms.

Co-authored-by: Mariusz Felisiak <felisiak.mariusz@gmail.com>
---
 AUTHORS                                  |  1 +
 django/forms/formsets.py                 | 26 +++++-----
 docs/releases/4.1.txt                    |  5 ++
 docs/topics/forms/formsets.txt           | 20 +++++++-
 tests/forms_tests/tests/test_formsets.py | 62 ++++++++++++++++++++++++
 5 files changed, 100 insertions(+), 14 deletions(-)

diff --git a/AUTHORS b/AUTHORS
index 6f0096e7cb..9f25f5933e 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -600,6 +600,7 @@ answer newbie questions, and generally made Django that much better:
     Marc Garcia <marc.garcia@accopensys.com>
     Marcin Wróbel
     Marc Remolt <m.remolt@webmasters.de>
+    Marc Seguí Coll <metarizard@gmail.com>
     Marc Tamlyn <marc.tamlyn@gmail.com>
     Marc-Aurèle Brothier <ma.brothier@gmail.com>
     Marian Andre <django@andre.sk>
diff --git a/django/forms/formsets.py b/django/forms/formsets.py
index 2df80297d3..3adbc6979a 100644
--- a/django/forms/formsets.py
+++ b/django/forms/formsets.py
@@ -6,7 +6,7 @@ from django.forms.utils import ErrorList, RenderableFormMixin
 from django.forms.widgets import CheckboxInput, HiddenInput, NumberInput
 from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
-from django.utils.translation import ngettext
+from django.utils.translation import ngettext_lazy
 
 __all__ = ("BaseFormSet", "formset_factory", "all_valid")
 
@@ -61,6 +61,16 @@ class BaseFormSet(RenderableFormMixin):
             "ManagementForm data is missing or has been tampered with. Missing fields: "
             "%(field_names)s. You may need to file a bug report if the issue persists."
         ),
+        "too_many_forms": ngettext_lazy(
+            "Please submit at most %(num)d form.",
+            "Please submit at most %(num)d forms.",
+            "num",
+        ),
+        "too_few_forms": ngettext_lazy(
+            "Please submit at least %(num)d form.",
+            "Please submit at least %(num)d forms.",
+            "num",
+        ),
     }
 
     template_name_div = "django/forms/formsets/div.html"
@@ -425,12 +435,7 @@ class BaseFormSet(RenderableFormMixin):
                 TOTAL_FORM_COUNT
             ] > self.absolute_max:
                 raise ValidationError(
-                    ngettext(
-                        "Please submit at most %d form.",
-                        "Please submit at most %d forms.",
-                        self.max_num,
-                    )
-                    % self.max_num,
+                    self.error_messages["too_many_forms"] % {"num": self.max_num},
                     code="too_many_forms",
                 )
             if (
@@ -441,12 +446,7 @@ class BaseFormSet(RenderableFormMixin):
                 < self.min_num
             ):
                 raise ValidationError(
-                    ngettext(
-                        "Please submit at least %d form.",
-                        "Please submit at least %d forms.",
-                        self.min_num,
-                    )
-                    % self.min_num,
+                    self.error_messages["too_few_forms"] % {"num": self.min_num},
                     code="too_few_forms",
                 )
             # Give self.clean() a chance to do cross-form validation.
diff --git a/docs/releases/4.1.txt b/docs/releases/4.1.txt
index fa5990393b..a9e6d71cd1 100644
--- a/docs/releases/4.1.txt
+++ b/docs/releases/4.1.txt
@@ -292,6 +292,11 @@ Forms
   attributes help to identify widgets where its inputs should be grouped in a
   ``<fieldset>`` with a ``<legend>``.
 
+* The :ref:`formsets-error-messages` argument for
+  :class:`~django.forms.formsets.BaseFormSet` now allows customizing
+  error messages for invalid number of forms by passing ``'too_few_forms'``
+  and ``'too_many_forms'`` keys.
+
 Generic Views
 ~~~~~~~~~~~~~
 
diff --git a/docs/topics/forms/formsets.txt b/docs/topics/forms/formsets.txt
index 1b54f2accb..c0f6df0855 100644
--- a/docs/topics/forms/formsets.txt
+++ b/docs/topics/forms/formsets.txt
@@ -287,12 +287,20 @@ sure you understand what they do before doing so.
 a form instance with a prefix of ``__prefix__`` for easier use in dynamic
 forms with JavaScript.
 
+.. _formsets-error-messages:
+
 ``error_messages``
 ------------------
 
 The ``error_messages`` argument lets you override the default messages that the
 formset will raise. Pass in a dictionary with keys matching the error messages
-you want to override. For example, here is the default error message when the
+you want to override. Error message keys include ``'too_few_forms'``,
+``'too_many_forms'``, and ``'missing_management_form'``. The
+``'too_few_forms'`` and ``'too_many_forms'`` error messages may contain
+``%(num)d``, which will be replaced with ``min_num`` and ``max_num``,
+respectively.
+
+For example, here is the default error message when the
 management form is missing::
 
     >>> formset = ArticleFormSet({})
@@ -309,6 +317,10 @@ And here is a custom error message::
     >>> formset.non_form_errors()
     ['Sorry, something went wrong.']
 
+.. versionchanged:: 4.1
+
+    The ``'too_few_forms'`` and ``'too_many_forms'`` keys were added.
+
 Custom formset validation
 -------------------------
 
@@ -410,6 +422,9 @@ deletion, is less than or equal to ``max_num``.
 ``max_num`` was exceeded because the amount of initial data supplied was
 excessive.
 
+The error message can be customized by passing the ``'too_many_forms'`` message
+to the :ref:`formsets-error-messages` argument.
+
 .. note::
 
     Regardless of ``validate_max``, if the number of forms in a data set
@@ -446,6 +461,9 @@ deletion, is greater than or equal to ``min_num``.
     >>> formset.non_form_errors()
     ['Please submit at least 3 forms.']
 
+The error message can be customized by passing the ``'too_few_forms'`` message
+to the :ref:`formsets-error-messages` argument.
+
 .. note::
 
     Regardless of ``validate_min``, if a formset contains no data, then
diff --git a/tests/forms_tests/tests/test_formsets.py b/tests/forms_tests/tests/test_formsets.py
index c713c85bfb..0868b41644 100644
--- a/tests/forms_tests/tests/test_formsets.py
+++ b/tests/forms_tests/tests/test_formsets.py
@@ -404,6 +404,37 @@ class FormsFormsetTestCase(SimpleTestCase):
             '<ul class="errorlist nonform"><li>Please submit at most 1 form.</li></ul>',
         )
 
+    def test_formset_validate_max_flag_custom_error(self):
+        data = {
+            "choices-TOTAL_FORMS": "2",
+            "choices-INITIAL_FORMS": "0",
+            "choices-MIN_NUM_FORMS": "0",
+            "choices-MAX_NUM_FORMS": "2",
+            "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",
+            error_messages={
+                "too_many_forms": "Number of submitted forms should be at most %(num)d."
+            },
+        )
+        self.assertFalse(formset.is_valid())
+        self.assertEqual(
+            formset.non_form_errors(),
+            ["Number of submitted forms should be at most 1."],
+        )
+        self.assertEqual(
+            str(formset.non_form_errors()),
+            '<ul class="errorlist nonform">'
+            "<li>Number of submitted forms should be at most 1.</li></ul>",
+        )
+
     def test_formset_validate_min_flag(self):
         """
         If validate_min is set and min_num is more than TOTAL_FORMS in the
@@ -431,6 +462,37 @@ class FormsFormsetTestCase(SimpleTestCase):
             "Please submit at least 3 forms.</li></ul>",
         )
 
+    def test_formset_validate_min_flag_custom_formatted_error(self):
+        data = {
+            "choices-TOTAL_FORMS": "2",
+            "choices-INITIAL_FORMS": "0",
+            "choices-MIN_NUM_FORMS": "0",
+            "choices-MAX_NUM_FORMS": "0",
+            "choices-0-choice": "Zero",
+            "choices-0-votes": "0",
+            "choices-1-choice": "One",
+            "choices-1-votes": "1",
+        }
+        ChoiceFormSet = formset_factory(Choice, extra=1, min_num=3, validate_min=True)
+        formset = ChoiceFormSet(
+            data,
+            auto_id=False,
+            prefix="choices",
+            error_messages={
+                "too_few_forms": "Number of submitted forms should be at least %(num)d."
+            },
+        )
+        self.assertFalse(formset.is_valid())
+        self.assertEqual(
+            formset.non_form_errors(),
+            ["Number of submitted forms should be at least 3."],
+        )
+        self.assertEqual(
+            str(formset.non_form_errors()),
+            '<ul class="errorlist nonform">'
+            "<li>Number of submitted forms should be at least 3.</li></ul>",
+        )
+
     def test_formset_validate_min_unchanged_forms(self):
         """
         min_num validation doesn't consider unchanged forms with initial data