Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

Fixed #9532 -- min_num on formsets #1208

Closed
wants to merge 1 commit into from

2 participants

@yokomizor

Added min_num parameter to formsets.

@yokomizor yokomizor Fixed #9532 -- min_num on formsets
Added min_num parameter to formsets.
47ccb96
@timgraham
Owner

Please open a new PR if you can update this per the comments in the ticket, thanks!

@timgraham timgraham closed this
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Jun 16, 2013
  1. @yokomizor

    Fixed #9532 -- min_num on formsets

    yokomizor authored
    Added min_num parameter to formsets.
This page is out of date. Refresh to see the latest.
Showing with 108 additions and 10 deletions.
  1. +22 −5 django/forms/formsets.py
  2. +86 −5 tests/forms_tests/tests/test_formsets.py
View
27 django/forms/formsets.py
@@ -17,10 +17,14 @@
# special field names
TOTAL_FORM_COUNT = 'TOTAL_FORMS'
INITIAL_FORM_COUNT = 'INITIAL_FORMS'
+MIN_NUM_FORM_COUNT = 'MIN_NUM_FORMS'
MAX_NUM_FORM_COUNT = 'MAX_NUM_FORMS'
ORDERING_FIELD_NAME = 'ORDER'
DELETION_FIELD_NAME = 'DELETE'
+# default minimum number of forms in a formset
+DEFAULT_MIN_NUM = 0
+
# default maximum number of forms in a formset, to prevent memory exhaustion
DEFAULT_MAX_NUM = 1000
@@ -33,9 +37,10 @@ class ManagementForm(Form):
def __init__(self, *args, **kwargs):
self.base_fields[TOTAL_FORM_COUNT] = IntegerField(widget=HiddenInput)
self.base_fields[INITIAL_FORM_COUNT] = IntegerField(widget=HiddenInput)
- # MAX_NUM_FORM_COUNT is output with the rest of the management form,
- # but only for the convenience of client-side code. The POST
- # value of MAX_NUM_FORM_COUNT returned from the client is not checked.
+ # MIN_NUM_FORM_COUNT and MAX_NUM_FORM_COUNT is output with the rest of
+ # the management form, but only for the convenience of client-side
+ # code. The POST value of them returned from the client is not checked.
+ self.base_fields[MIN_NUM_FORM_COUNT] = IntegerField(required=False, widget=HiddenInput)
self.base_fields[MAX_NUM_FORM_COUNT] = IntegerField(required=False, widget=HiddenInput)
super(ManagementForm, self).__init__(*args, **kwargs)
@@ -90,6 +95,7 @@ def management_form(self):
form = ManagementForm(auto_id=self.auto_id, prefix=self.prefix, initial={
TOTAL_FORM_COUNT: self.total_form_count(),
INITIAL_FORM_COUNT: self.initial_form_count(),
+ MIN_NUM_FORM_COUNT: self.min_num,
MAX_NUM_FORM_COUNT: self.max_num
})
return form
@@ -309,6 +315,11 @@ def full_clean(self):
raise ValidationError(ungettext(
"Please submit %d or fewer forms.",
"Please submit %d or fewer forms.", self.max_num) % self.max_num)
+ if (self.validate_min and
+ self.total_form_count() - len(self.deleted_forms) < self.min_num):
+ raise ValidationError(ungettext(
+ "Please submit %d or more forms.",
+ "Please submit %d or more forms.", self.min_num) % self.min_num)
# Give self.clean() a chance to do cross-form validation.
self.clean()
except ValidationError as e:
@@ -381,17 +392,23 @@ def as_ul(self):
return mark_safe('\n'.join([six.text_type(self.management_form), forms]))
def formset_factory(form, formset=BaseFormSet, extra=1, can_order=False,
- can_delete=False, max_num=None, validate_max=False):
+ can_delete=False, min_num=None, max_num=None,
+ validate_min=False, validate_max=False):
"""Return a FormSet for the given form class."""
+ if min_num is None:
+ min_num = DEFAULT_MIN_NUM
if max_num is None:
max_num = DEFAULT_MAX_NUM
# hard limit on forms instantiated, to prevent memory-exhaustion attacks
# limit is simply max_num + DEFAULT_MAX_NUM (which is 2*DEFAULT_MAX_NUM
# if max_num is None in the first place)
absolute_max = max_num + DEFAULT_MAX_NUM
+ #
+ extra = extra + min_num
attrs = {'form': form, 'extra': extra,
'can_order': can_order, 'can_delete': can_delete,
- 'max_num': max_num, 'absolute_max': absolute_max,
+ 'min_num': min_num, 'max_num': max_num,
+ 'absolute_max': absolute_max, 'validate_min' : validate_min,
'validate_max' : validate_max}
return type(form.__name__ + str('FormSet'), (formset,), attrs)
View
91 tests/forms_tests/tests/test_formsets.py
@@ -60,7 +60,7 @@ def test_basic_formset(self):
# 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')
- 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" />
+ 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-MIN_NUM_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>""")
@@ -73,6 +73,7 @@ def test_basic_formset(self):
data = {
'choices-TOTAL_FORMS': '1', # the number of forms rendered
'choices-INITIAL_FORMS': '0', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
'choices-0-choice': 'Calexico',
'choices-0-votes': '100',
@@ -99,6 +100,7 @@ def test_formset_validation(self):
data = {
'choices-TOTAL_FORMS': '1', # the number of forms rendered
'choices-INITIAL_FORMS': '0', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
'choices-0-choice': 'Calexico',
'choices-0-votes': '',
@@ -114,6 +116,7 @@ def test_formset_has_changed(self):
data = {
'choices-TOTAL_FORMS': '1', # the number of forms rendered
'choices-INITIAL_FORMS': '0', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
'choices-0-choice': '',
'choices-0-votes': '',
@@ -155,6 +158,7 @@ def test_formset_initial_data(self):
data = {
'choices-TOTAL_FORMS': '2', # the number of forms rendered
'choices-INITIAL_FORMS': '1', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
'choices-0-choice': 'Calexico',
'choices-0-votes': '100',
@@ -176,6 +180,7 @@ def test_second_form_partially_filled(self):
data = {
'choices-TOTAL_FORMS': '2', # the number of forms rendered
'choices-INITIAL_FORMS': '1', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
'choices-0-choice': 'Calexico',
'choices-0-votes': '100',
@@ -195,6 +200,7 @@ def test_delete_prefilled_data(self):
data = {
'choices-TOTAL_FORMS': '2', # the number of forms rendered
'choices-INITIAL_FORMS': '1', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
'choices-0-choice': '', # deleted value
'choices-0-votes': '', # deleted value
@@ -232,6 +238,7 @@ def test_displaying_more_than_one_blank_form(self):
data = {
'choices-TOTAL_FORMS': '3', # the number of forms rendered
'choices-INITIAL_FORMS': '0', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
'choices-0-choice': '',
'choices-0-votes': '',
@@ -245,12 +252,46 @@ def test_displaying_more_than_one_blank_form(self):
self.assertTrue(formset.is_valid())
self.assertEqual([form.cleaned_data for form in formset.forms], [{}, {}, {}])
+ def test_min_num_displaying_more_than_one_blank_form(self):
+ # We can also display more than 1 empty form passing min_num argument
+ # to formset_factory. It will increment the extra argument
+ ChoiceFormSet = formset_factory(Choice, extra=1, min_num=1)
+
+ formset = ChoiceFormSet(auto_id=False, prefix='choices')
+ form_output = []
+
+ for form in formset.forms:
+ form_output.append(form.as_ul())
+
+ self.assertHTMLEqual('\n'.join(form_output), """<li>Choice: <input type="text" name="choices-0-choice" /></li>
+<li>Votes: <input type="number" name="choices-0-votes" /></li>
+<li>Choice: <input type="text" name="choices-1-choice" /></li>
+<li>Votes: <input type="number" name="choices-1-votes" /></li>""")
+
+ def test_min_num_displaying_more_than_one_blank_form_with_zero_extra(self):
+ # We can also display more than 1 empty form passing min_num argument
+ ChoiceFormSet = formset_factory(Choice, extra=0, min_num=3)
+
+ formset = ChoiceFormSet(auto_id=False, prefix='choices')
+ form_output = []
+
+ for form in formset.forms:
+ form_output.append(form.as_ul())
+
+ self.assertHTMLEqual('\n'.join(form_output), """<li>Choice: <input type="text" name="choices-0-choice" /></li>
+<li>Votes: <input type="number" name="choices-0-votes" /></li>
+<li>Choice: <input type="text" name="choices-1-choice" /></li>
+<li>Votes: <input type="number" name="choices-1-votes" /></li>
+<li>Choice: <input type="text" name="choices-2-choice" /></li>
+<li>Votes: <input type="number" name="choices-2-votes" /></li>""")
+
def test_single_form_completed(self):
# We can just fill out one of the forms.
data = {
'choices-TOTAL_FORMS': '3', # the number of forms rendered
'choices-INITIAL_FORMS': '0', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
'choices-0-choice': 'Calexico',
'choices-0-votes': '100',
@@ -274,6 +315,7 @@ def test_formset_validate_max_flag(self):
data = {
'choices-TOTAL_FORMS': '2', # the number of forms rendered
'choices-INITIAL_FORMS': '0', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '2', # max number of forms - should be ignored
'choices-0-choice': 'Zero',
'choices-0-votes': '0',
@@ -286,12 +328,35 @@ def test_formset_validate_max_flag(self):
self.assertFalse(formset.is_valid())
self.assertEqual(formset.non_form_errors(), ['Please submit 1 or fewer forms.'])
+ def test_formset_validate_min_flag(self):
+ # If validate_min is set and min_num is more than TOTAL_FORMS in the
+ # data, then throw an exception. MIN_NUM_FORMS in the data is
+ # irrelevant here (it's output as a hint for the client but its
+ # value in the returned data is not checked)
+
+ data = {
+ 'choices-TOTAL_FORMS': '2', # the number of forms rendered
+ 'choices-INITIAL_FORMS': '0', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
+ 'choices-MAX_NUM_FORMS': '0', # 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, min_num=3, validate_min=True)
+ formset = ChoiceFormSet(data, auto_id=False, prefix='choices')
+ self.assertFalse(formset.is_valid())
+ self.assertEqual(formset.non_form_errors(), ['Please submit 3 or more forms.'])
+
def test_second_form_partially_filled_2(self):
# And once again, if we try to partially complete a form, validation will fail.
data = {
'choices-TOTAL_FORMS': '3', # the number of forms rendered
'choices-INITIAL_FORMS': '0', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
'choices-0-choice': 'Calexico',
'choices-0-votes': '100',
@@ -313,6 +378,7 @@ def test_more_initial_data(self):
data = {
'choices-TOTAL_FORMS': '3', # the number of forms rendered
'choices-INITIAL_FORMS': '0', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
'choices-0-choice': 'Calexico',
'choices-0-votes': '100',
@@ -376,6 +442,7 @@ def test_formset_with_deletion(self):
data = {
'choices-TOTAL_FORMS': '3', # the number of forms rendered
'choices-INITIAL_FORMS': '2', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
'choices-0-choice': 'Calexico',
'choices-0-votes': '100',
@@ -403,6 +470,7 @@ class CheckForm(Form):
data = {
'check-TOTAL_FORMS': '3', # the number of forms rendered
'check-INITIAL_FORMS': '2', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'check-MAX_NUM_FORMS': '0', # max number of forms
'check-0-field': '200',
'check-0-DELETE': '',
@@ -433,7 +501,7 @@ class Person(Form):
p = PeopleForm(
{'form-0-name': '', 'form-0-DELETE': 'on', # no name!
'form-TOTAL_FORMS': 1, 'form-INITIAL_FORMS': 1,
- 'form-MAX_NUM_FORMS': 1})
+ 'form-MIN_NUM_FORMS': 0, 'form-MAX_NUM_FORMS': 1})
self.assertTrue(p.is_valid())
self.assertEqual(len(p.deleted_forms), 1)
@@ -470,6 +538,7 @@ def test_formsets_with_ordering(self):
data = {
'choices-TOTAL_FORMS': '3', # the number of forms rendered
'choices-INITIAL_FORMS': '2', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
'choices-0-choice': 'Calexico',
'choices-0-votes': '100',
@@ -502,6 +571,7 @@ def test_empty_ordered_fields(self):
data = {
'choices-TOTAL_FORMS': '4', # the number of forms rendered
'choices-INITIAL_FORMS': '3', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
'choices-0-choice': 'Calexico',
'choices-0-votes': '100',
@@ -538,6 +608,7 @@ def test_ordering_blank_fieldsets(self):
data = {
'choices-TOTAL_FORMS': '3', # the number of forms rendered
'choices-INITIAL_FORMS': '0', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
}
@@ -590,6 +661,7 @@ def test_formset_with_ordering_and_deletion(self):
data = {
'choices-TOTAL_FORMS': '4', # the number of forms rendered
'choices-INITIAL_FORMS': '3', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
'choices-0-choice': 'Calexico',
'choices-0-votes': '100',
@@ -636,6 +708,7 @@ class Person(Form):
'form-0-DELETE': 'on', # no name!
'form-TOTAL_FORMS': 1,
'form-INITIAL_FORMS': 1,
+ 'form-MIN_NUM_FORMS': 0,
'form-MAX_NUM_FORMS': 1
})
@@ -652,6 +725,7 @@ def test_clean_hook(self):
data = {
'drinks-TOTAL_FORMS': '2', # the number of forms rendered
'drinks-INITIAL_FORMS': '0', # the number of forms with initial data
+ 'drinks-MIN_NUM_FORMS': '0', # min number of forms
'drinks-MAX_NUM_FORMS': '0', # max number of forms
'drinks-0-name': 'Gin and Tonic',
'drinks-1-name': 'Gin and Tonic',
@@ -671,6 +745,7 @@ def test_clean_hook(self):
data = {
'drinks-TOTAL_FORMS': '2', # the number of forms rendered
'drinks-INITIAL_FORMS': '0', # the number of forms with initial data
+ 'drinks-MIN_NUM_FORMS': '0', # min number of forms
'drinks-MAX_NUM_FORMS': '0', # max number of forms
'drinks-0-name': 'Gin and Tonic',
'drinks-1-name': 'Bloody Mary',
@@ -823,6 +898,7 @@ def test_regression_6926(self):
data = {
'form-TOTAL_FORMS': '2',
'form-INITIAL_FORMS': '0',
+ 'form-MIN_NUM_FORMS': '0',
'form-MAX_NUM_FORMS': '0',
}
formset = FavoriteDrinksFormSet(data=data)
@@ -837,6 +913,7 @@ def test_regression_12878(self):
data = {
'drinks-TOTAL_FORMS': '2', # the number of forms rendered
'drinks-INITIAL_FORMS': '0', # the number of forms with initial data
+ 'drinks-MIN_NUM_FORMS': '0', # min number of forms
'drinks-MAX_NUM_FORMS': '0', # max number of forms
'drinks-0-name': 'Gin and Tonic',
'drinks-1-name': 'Gin and Tonic',
@@ -926,6 +1003,7 @@ def is_valid(self):
data = {
'choices-TOTAL_FORMS': '1', # number of forms rendered
'choices-INITIAL_FORMS': '0', # number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
'choices-0-choice': 'Calexico',
'choices-0-votes': '100',
@@ -946,6 +1024,7 @@ def test_hard_limit_on_instantiated_forms(self):
{
'choices-TOTAL_FORMS': '4',
'choices-INITIAL_FORMS': '0',
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '4',
'choices-0-choice': 'Zero',
'choices-0-votes': '0',
@@ -977,6 +1056,7 @@ def test_increase_hard_limit(self):
{
'choices-TOTAL_FORMS': '4',
'choices-INITIAL_FORMS': '0',
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '4',
'choices-0-choice': 'Zero',
'choices-0-votes': '0',
@@ -1030,6 +1110,7 @@ class CheckForm(Form):
data = {
'choices-TOTAL_FORMS': '1', # the number of forms rendered
'choices-INITIAL_FORMS': '0', # the number of forms with initial data
+ 'choices-MIN_NUM_FORMS': '0', # min number of forms
'choices-MAX_NUM_FORMS': '0', # max number of forms
'choices-0-choice': 'Calexico',
'choices-0-votes': '100',
@@ -1044,19 +1125,19 @@ class Choice(Form):
class FormsetAsFooTests(TestCase):
def test_as_table(self):
formset = ChoiceFormSet(data, auto_id=False, prefix='choices')
- self.assertHTMLEqual(formset.as_table(),"""<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="0" />
+ self.assertHTMLEqual(formset.as_table(),"""<input type="hidden" name="choices-TOTAL_FORMS" value="1" /><input type="hidden" name="choices-INITIAL_FORMS" value="0" /><input type="hidden" name="choices-MIN_NUM_FORMS" value="0" /><input type="hidden" name="choices-MAX_NUM_FORMS" value="0" />
<tr><th>Choice:</th><td><input type="text" name="choices-0-choice" value="Calexico" /></td></tr>
<tr><th>Votes:</th><td><input type="number" name="choices-0-votes" value="100" /></td></tr>""")
def test_as_p(self):
formset = ChoiceFormSet(data, auto_id=False, prefix='choices')
- self.assertHTMLEqual(formset.as_p(),"""<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="0" />
+ self.assertHTMLEqual(formset.as_p(),"""<input type="hidden" name="choices-TOTAL_FORMS" value="1" /><input type="hidden" name="choices-INITIAL_FORMS" value="0" /><input type="hidden" name="choices-MIN_NUM_FORMS" value="0" /><input type="hidden" name="choices-MAX_NUM_FORMS" value="0" />
<p>Choice: <input type="text" name="choices-0-choice" value="Calexico" /></p>
<p>Votes: <input type="number" name="choices-0-votes" value="100" /></p>""")
def test_as_ul(self):
formset = ChoiceFormSet(data, auto_id=False, prefix='choices')
- self.assertHTMLEqual(formset.as_ul(),"""<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="0" />
+ self.assertHTMLEqual(formset.as_ul(),"""<input type="hidden" name="choices-TOTAL_FORMS" value="1" /><input type="hidden" name="choices-INITIAL_FORMS" value="0" /><input type="hidden" name="choices-MIN_NUM_FORMS" value="0" /><input type="hidden" name="choices-MAX_NUM_FORMS" value="0" />
<li>Choice: <input type="text" name="choices-0-choice" value="Calexico" /></li>
<li>Votes: <input type="number" name="choices-0-votes" value="100" /></li>""")
Something went wrong with that request. Please try again.