diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 1aa56f2c91..8ba543d4a7 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -699,6 +699,7 @@ class InlineModelAdmin(BaseModelAdmin): fk_name = None formset = BaseInlineFormset extra = 3 + max_num = 0 template = None verbose_name = None verbose_name_plural = None @@ -722,7 +723,7 @@ class InlineModelAdmin(BaseModelAdmin): return inlineformset_factory(self.parent_model, self.model, form=self.form, formset=self.formset, fk_name=self.fk_name, fields=fields, formfield_callback=self.formfield_for_dbfield, - extra=self.extra) + extra=self.extra, max_num=self.max_num) def get_fieldsets(self, request, obj=None): if self.declared_fieldsets: diff --git a/django/newforms/formsets.py b/django/newforms/formsets.py index 2edc673a32..03f5278c30 100644 --- a/django/newforms/formsets.py +++ b/django/newforms/formsets.py @@ -10,6 +10,7 @@ __all__ = ('BaseFormSet', 'all_valid') # special field names TOTAL_FORM_COUNT = 'TOTAL_FORMS' INITIAL_FORM_COUNT = 'INITIAL_FORMS' +MAX_FORM_COUNT = 'MAX_FORMS' ORDERING_FIELD_NAME = 'ORDER' DELETION_FIELD_NAME = 'DELETE' @@ -22,6 +23,7 @@ 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) + self.base_fields[MAX_FORM_COUNT] = IntegerField(widget=HiddenInput) super(ManagementForm, self).__init__(*args, **kwargs) class BaseFormSet(StrAndUnicode): @@ -29,7 +31,7 @@ class BaseFormSet(StrAndUnicode): A collection of instances of the same Form class. """ def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None, - initial=None, error_class=ErrorList): + initial=None, error_class=ErrorList): self.is_bound = data is not None or files is not None self.prefix = prefix or 'form' self.auto_id = auto_id @@ -45,18 +47,25 @@ class BaseFormSet(StrAndUnicode): if self.management_form.is_valid(): self._total_form_count = self.management_form.cleaned_data[TOTAL_FORM_COUNT] self._initial_form_count = self.management_form.cleaned_data[INITIAL_FORM_COUNT] + self._max_form_count = self.management_form.cleaned_data[MAX_FORM_COUNT] else: raise ValidationError('ManagementForm data is missing or has been tampered with') else: if initial: self._initial_form_count = len(initial) + if self._initial_form_count > self._max_form_count and self._max_form_count > 0: + self._initial_form_count = self._max_form_count self._total_form_count = self._initial_form_count + self.extra else: self._initial_form_count = 0 self._total_form_count = self.extra - initial = {TOTAL_FORM_COUNT: self._total_form_count, INITIAL_FORM_COUNT: self._initial_form_count} + if self._total_form_count > self._max_form_count and self._max_form_count > 0: + self._total_form_count = self._max_form_count + initial = {TOTAL_FORM_COUNT: self._total_form_count, + INITIAL_FORM_COUNT: self._initial_form_count, + MAX_FORM_COUNT: self._max_form_count} self.management_form = ManagementForm(initial=initial, auto_id=self.auto_id, prefix=self.prefix) - + # construct the forms in the formset self._construct_forms() @@ -266,9 +275,11 @@ class BaseFormSet(StrAndUnicode): return mark_safe(u'\n'.join([unicode(self.management_form), forms])) def formset_factory(form, formset=BaseFormSet, extra=1, can_order=False, - can_delete=False): + can_delete=False, max_num=0): """Return a FormSet for the given form class.""" - attrs = {'form': form, 'extra': extra, 'can_order': can_order, 'can_delete': can_delete} + attrs = {'form': form, 'extra': extra, + 'can_order': can_order, 'can_delete': can_delete, + '_max_form_count': max_num} return type(form.__name__ + 'FormSet', (formset,), attrs) def all_valid(formsets): diff --git a/django/newforms/models.py b/django/newforms/models.py index b9baced609..a60c1793a7 100644 --- a/django/newforms/models.py +++ b/django/newforms/models.py @@ -305,7 +305,11 @@ class BaseModelFormSet(BaseFormSet): queryset=None, **kwargs): self.queryset = queryset defaults = {'data': data, 'files': files, 'auto_id': auto_id, 'prefix': prefix} - defaults['initial'] = [model_to_dict(obj) for obj in self.get_queryset()] + if self._max_form_count > 0: + qs = self.get_queryset()[:self._max_form_count] + else: + qs = self.get_queryset() + defaults['initial'] = [model_to_dict(obj) for obj in qs] defaults.update(kwargs) super(BaseModelFormSet, self).__init__(**defaults) @@ -369,15 +373,16 @@ class BaseModelFormSet(BaseFormSet): super(BaseModelFormSet, self).add_fields(form, index) def modelformset_factory(model, form=ModelForm, formfield_callback=lambda f: f.formfield(), - formset=BaseModelFormSet, - extra=1, can_delete=False, can_order=False, - fields=None, exclude=None): + formset=BaseModelFormSet, + extra=1, can_delete=False, can_order=False, + max_num=0, fields=None, exclude=None): """ Returns a FormSet class for the given Django model class. """ form = modelform_factory(model, form=form, fields=fields, exclude=exclude, - formfield_callback=formfield_callback) - FormSet = formset_factory(form, formset, extra=extra, can_order=can_order, can_delete=can_delete) + formfield_callback=formfield_callback) + FormSet = formset_factory(form, formset, extra=extra, max_num=max_num, + can_order=can_order, can_delete=can_delete) FormSet.model = model return FormSet @@ -395,9 +400,8 @@ class BaseInlineFormset(BaseModelFormSet): super(BaseInlineFormset, self).__init__(data, files, prefix=self.rel_name) def _construct_forms(self): - from django.newforms.formsets import INITIAL_FORM_COUNT if self.save_as_new: - self._total_form_count = self.management_form.cleaned_data[INITIAL_FORM_COUNT] + self._total_form_count = self._initial_form_count self._initial_form_count = 0 super(BaseInlineFormset, self)._construct_forms() @@ -443,10 +447,10 @@ def _get_foreign_key(parent_model, model, fk_name=None): def inlineformset_factory(parent_model, model, form=ModelForm, - formset=BaseInlineFormset, fk_name=None, - fields=None, exclude=None, - extra=3, can_order=False, can_delete=True, - formfield_callback=lambda f: f.formfield()): + formset=BaseInlineFormset, fk_name=None, + fields=None, exclude=None, + extra=3, can_order=False, can_delete=True, max_num=0, + formfield_callback=lambda f: f.formfield()): """ Returns an ``InlineFormset`` for the given kwargs. @@ -464,7 +468,7 @@ def inlineformset_factory(parent_model, model, form=ModelForm, formfield_callback=formfield_callback, formset=formset, extra=extra, can_delete=can_delete, can_order=can_order, - fields=fields, exclude=exclude) + fields=fields, exclude=exclude, max_num=max_num) FormSet.fk = fk return FormSet diff --git a/docs/modelforms.txt b/docs/modelforms.txt index b9332bc59e..f2222c1fc0 100644 --- a/docs/modelforms.txt +++ b/docs/modelforms.txt @@ -449,6 +449,34 @@ model instances without any database interaction:: This gives you the ability to attach data to the instances before saving them to the database. +Limiting the number of objects editable +--------------------------------------- + +Similar to regular formsets you can use the ``max_num`` parameter to +``modelformset_factory`` to limit the number of forms displayed. With +model formsets this will properly limit the query to only select the maximum +number of objects needed:: + + >>> Author.objects.order_by('name') + [, , ] + + >>> AuthorFormSet = modelformset_factory(Author, max_num=2, extra=1) + >>> formset = AuthorFormSet(queryset=Author.objects.order_by('name')) + >>> formset.initial + [{'id': 1, 'name': u'Charles Baudelaire'}, {'id': 3, 'name': u'Paul Verlaine'}] + +If the value of ``max_num`` is less than the total objects returned it will +fill the rest with extra forms:: + + >>> AuthorFormSet = modelformset_factory(Author, max_num=4, extra=1) + >>> formset = AuthorFormSet(queryset=Author.objects.order_by('name')) + >>> for form in formset.forms: + ... print form.as_table() + + + + + Using ``inlineformset_factory`` ------------------------------- diff --git a/docs/newforms.txt b/docs/newforms.txt index ad3f81cb6c..f81fcfc8dc 100644 --- a/docs/newforms.txt +++ b/docs/newforms.txt @@ -2230,6 +2230,22 @@ There are now a total of three forms showing above. One for the initial data that was passed in and two extra forms. Also note that we are passing in a list of dictionaries as the initial data. +Limiting the maximum number of forms +------------------------------------ + +The ``max_num`` parameter to ``formset_factory`` gives you the ability to +force the maximum number of forms the formset will display:: + + >>> ArticleFormSet = formset_factory(ArticleForm, extra=2, max_num=1) + >>> formset = ArticleFormset() + >>> for form in formset.forms: + ... print form.as_table() + + + +The default value of ``max_num`` is ``0`` which is the same as saying put no +limit on the number forms displayed. + Formset validation ------------------ diff --git a/tests/modeltests/model_formsets/models.py b/tests/modeltests/model_formsets/models.py index 93bc80ee72..93519a4421 100644 --- a/tests/modeltests/model_formsets/models.py +++ b/tests/modeltests/model_formsets/models.py @@ -31,6 +31,7 @@ __test__ = {'API_TESTS': """ >>> data = { ... 'form-TOTAL_FORMS': '3', # the number of forms rendered ... 'form-INITIAL_FORMS': '0', # the number of forms with initial data +... 'form-MAX_FORMS': '0', # the max number of forms ... 'form-0-name': 'Charles Baudelaire', ... 'form-1-name': 'Arthur Rimbaud', ... 'form-2-name': '', @@ -68,6 +69,7 @@ them in alphabetical order by name. >>> data = { ... 'form-TOTAL_FORMS': '3', # the number of forms rendered ... 'form-INITIAL_FORMS': '2', # the number of forms with initial data +... 'form-MAX_FORMS': '0', # the max number of forms ... 'form-0-id': '2', ... 'form-0-name': 'Arthur Rimbaud', ... 'form-1-id': '1', @@ -111,6 +113,7 @@ deltetion, make sure we don't save that form. >>> data = { ... 'form-TOTAL_FORMS': '4', # the number of forms rendered ... 'form-INITIAL_FORMS': '3', # the number of forms with initial data +... 'form-MAX_FORMS': '0', # the max number of forms ... 'form-0-id': '2', ... 'form-0-name': 'Arthur Rimbaud', ... 'form-1-id': '1', @@ -140,6 +143,7 @@ Let's edit a record to ensure save only returns that one record. >>> data = { ... 'form-TOTAL_FORMS': '4', # the number of forms rendered ... 'form-INITIAL_FORMS': '3', # the number of forms with initial data +... 'form-MAX_FORMS': '0', # the max number of forms ... 'form-0-id': '2', ... 'form-0-name': 'Walt Whitman', ... 'form-1-id': '1', @@ -158,6 +162,22 @@ True >>> formset.save() [] +Test the behavior of max_num with model formsets. It should properly limit +the queryset to reduce the amount of objects being pulled in when not being +used. + +>>> qs = Author.objects.order_by('name') + +>>> AuthorFormSet = modelformset_factory(Author, max_num=2) +>>> formset = AuthorFormSet(queryset=qs) +>>> formset.initial +[{'id': 1, 'name': u'Charles Baudelaire'}, {'id': 3, 'name': u'Paul Verlaine'}] + +>>> AuthorFormSet = modelformset_factory(Author, max_num=3) +>>> formset = AuthorFormSet(queryset=qs) +>>> formset.initial +[{'id': 1, 'name': u'Charles Baudelaire'}, {'id': 3, 'name': u'Paul Verlaine'}, {'id': 2, 'name': u'Walt Whitman'}] + # Inline Formsets ############################################################ We can also create a formset that is tied to a parent model. This is how the @@ -178,6 +198,7 @@ admin system's edit inline functionality works. >>> data = { ... 'book_set-TOTAL_FORMS': '3', # the number of forms rendered ... 'book_set-INITIAL_FORMS': '0', # the number of forms with initial data +... 'book_set-MAX_FORMS': '0', # the max number of forms ... 'book_set-0-title': 'Les Fleurs du Mal', ... 'book_set-1-title': '', ... 'book_set-2-title': '', @@ -212,6 +233,7 @@ book. >>> data = { ... 'book_set-TOTAL_FORMS': '3', # the number of forms rendered ... 'book_set-INITIAL_FORMS': '1', # the number of forms with initial data +... 'book_set-MAX_FORMS': '0', # the max number of forms ... 'book_set-0-id': '1', ... 'book_set-0-title': 'Les Fleurs du Mal', ... 'book_set-1-title': 'Le Spleen de Paris', @@ -238,6 +260,7 @@ This is used in the admin for save_as functionality. >>> data = { ... 'book_set-TOTAL_FORMS': '3', # the number of forms rendered ... 'book_set-INITIAL_FORMS': '2', # the number of forms with initial data +... 'book_set-MAX_FORMS': '0', # the max number of forms ... 'book_set-0-id': '1', ... 'book_set-0-title': 'Les Fleurs du Mal', ... 'book_set-1-id': '2', diff --git a/tests/regressiontests/forms/formsets.py b/tests/regressiontests/forms/formsets.py index 4cccb10d09..dedc0a8e52 100644 --- a/tests/regressiontests/forms/formsets.py +++ b/tests/regressiontests/forms/formsets.py @@ -20,7 +20,7 @@ but we'll look at how to do so later. >>> formset = ChoiceFormSet(auto_id=False, prefix='choices') >>> print formset - + Choice: Votes: @@ -34,6 +34,7 @@ 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_FORMS': '0', # the max number of forms ... 'choices-0-choice': 'Calexico', ... 'choices-0-votes': '100', ... } @@ -60,6 +61,7 @@ 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_FORMS': '0', # the max number of forms ... 'choices-0-choice': 'Calexico', ... 'choices-0-votes': '', ... } @@ -90,6 +92,7 @@ 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_FORMS': '0', # the max number of forms ... 'choices-0-choice': 'Calexico', ... 'choices-0-votes': '100', ... 'choices-1-choice': '', @@ -111,6 +114,7 @@ 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_FORMS': '0', # the max number of forms ... 'choices-0-choice': 'Calexico', ... 'choices-0-votes': '100', ... 'choices-1-choice': 'The Decemberists', @@ -130,6 +134,7 @@ 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_FORMS': '0', # the max number of forms ... 'choices-0-choice': '', # deleted value ... 'choices-0-votes': '', # deleted value ... 'choices-1-choice': '', @@ -167,6 +172,7 @@ number of forms to be completed. >>> data = { ... 'choices-TOTAL_FORMS': '3', # the number of forms rendered ... 'choices-INITIAL_FORMS': '0', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms ... 'choices-0-choice': '', ... 'choices-0-votes': '', ... 'choices-1-choice': '', @@ -187,6 +193,7 @@ 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-MAX_FORMS': '0', # the max number of forms ... 'choices-0-choice': 'Calexico', ... 'choices-0-votes': '100', ... 'choices-1-choice': '', @@ -207,6 +214,7 @@ 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-MAX_FORMS': '0', # the max number of forms ... 'choices-0-choice': 'Calexico', ... 'choices-0-votes': '100', ... 'choices-1-choice': 'The Decemberists', @@ -267,6 +275,7 @@ To delete something, we just need to set that form's special delete field to >>> data = { ... 'choices-TOTAL_FORMS': '3', # the number of forms rendered ... 'choices-INITIAL_FORMS': '2', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms ... 'choices-0-choice': 'Calexico', ... 'choices-0-votes': '100', ... 'choices-0-DELETE': '', @@ -316,6 +325,7 @@ something at the front of the list, you'd need to set it's order to 0. >>> data = { ... 'choices-TOTAL_FORMS': '3', # the number of forms rendered ... 'choices-INITIAL_FORMS': '2', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms ... 'choices-0-choice': 'Calexico', ... 'choices-0-votes': '100', ... 'choices-0-ORDER': '1', @@ -342,6 +352,7 @@ they will be sorted below everything else. >>> data = { ... 'choices-TOTAL_FORMS': '4', # the number of forms rendered ... 'choices-INITIAL_FORMS': '3', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms ... 'choices-0-choice': 'Calexico', ... 'choices-0-votes': '100', ... 'choices-0-ORDER': '1', @@ -403,6 +414,7 @@ Let's delete Fergie, and put The Decemberists ahead of Calexico. >>> data = { ... 'choices-TOTAL_FORMS': '4', # the number of forms rendered ... 'choices-INITIAL_FORMS': '3', # the number of forms with initial data +... 'choices-MAX_FORMS': '0', # the max number of forms ... 'choices-0-choice': 'Calexico', ... 'choices-0-votes': '100', ... 'choices-0-ORDER': '1', @@ -444,12 +456,7 @@ error if there are any duplicates. ... name = CharField() ... ->>> class FavoriteDrinksFormSet(BaseFormSet): -... form = FavoriteDrinkForm -... extra = 2 -... can_order = False -... can_delete = False -... +>>> class BaseFavoriteDrinksFormSet(BaseFormSet): ... def clean(self): ... seen_drinks = [] ... for drink in self.cleaned_data: @@ -458,11 +465,15 @@ error if there are any duplicates. ... seen_drinks.append(drink['name']) ... +>>> FavoriteDrinksFormSet = formset_factory(FavoriteDrinkForm, +... formset=BaseFavoriteDrinksFormSet, extra=3) + We start out with a some duplicate data. >>> data = { ... 'drinks-TOTAL_FORMS': '2', # the number of forms rendered ... 'drinks-INITIAL_FORMS': '0', # the number of forms with initial data +... 'drinks-MAX_FORMS': '0', # the max number of forms ... 'drinks-0-name': 'Gin and Tonic', ... 'drinks-1-name': 'Gin and Tonic', ... } @@ -484,6 +495,7 @@ Make sure we didn't break the valid case. >>> data = { ... 'drinks-TOTAL_FORMS': '2', # the number of forms rendered ... 'drinks-INITIAL_FORMS': '0', # the number of forms with initial data +... 'drinks-MAX_FORMS': '0', # the max number of forms ... 'drinks-0-name': 'Gin and Tonic', ... 'drinks-1-name': 'Bloody Mary', ... } @@ -494,6 +506,55 @@ True >>> for error in formset.non_form_errors(): ... print error +# Limiting the maximum number of forms ######################################## + +# Base case for max_num. + +>>> LimitedFavoriteDrinkFormSet = formset_factory(FavoriteDrinkForm, extra=5, max_num=2) +>>> formset = LimitedFavoriteDrinkFormSet() +>>> for form in formset.forms: +... print form + + + +# Ensure the that max_num has no affect when extra is less than max_forms. + +>>> LimitedFavoriteDrinkFormSet = formset_factory(FavoriteDrinkForm, extra=1, max_num=2) +>>> formset = LimitedFavoriteDrinkFormSet() +>>> for form in formset.forms: +... print form + + +# max_num with initial data + +# More initial forms than max_num will result in only the first max_num of +# them to be displayed with no extra forms. + +>>> initial = [ +... {'name': 'Gin Tonic'}, +... {'name': 'Bloody Mary'}, +... {'name': 'Jack and Coke'}, +... ] +>>> LimitedFavoriteDrinkFormSet = formset_factory(FavoriteDrinkForm, extra=1, max_num=2) +>>> formset = LimitedFavoriteDrinkFormSet(initial=initial) +>>> for form in formset.forms: +... print form + + + +# One form from initial and extra=3 with max_num=2 should result in the one +# initial form and one extra. + +>>> initial = [ +... {'name': 'Gin Tonic'}, +... ] +>>> LimitedFavoriteDrinkFormSet = formset_factory(FavoriteDrinkForm, extra=3, max_num=2) +>>> formset = LimitedFavoriteDrinkFormSet(initial=initial) +>>> for form in formset.forms: +... print form + + + # Regression test for #6926 ##################################################