diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index ee4ff97..f40074a 100644
a
|
b
|
class InlineModelAdmin(BaseModelAdmin):
|
1356 | 1356 | formset = BaseInlineFormSet |
1357 | 1357 | extra = 3 |
1358 | 1358 | max_num = None |
| 1359 | min_num = None |
1359 | 1360 | template = None |
1360 | 1361 | verbose_name = None |
1361 | 1362 | verbose_name_plural = None |
… |
… |
class InlineModelAdmin(BaseModelAdmin):
|
1409 | 1410 | "formfield_callback": partial(self.formfield_for_dbfield, request=request), |
1410 | 1411 | "extra": self.extra, |
1411 | 1412 | "max_num": self.max_num, |
| 1413 | "min_num": self.min_num, |
1412 | 1414 | "can_delete": can_delete, |
1413 | 1415 | } |
1414 | 1416 | defaults.update(kwargs) |
diff --git a/django/contrib/contenttypes/generic.py b/django/contrib/contenttypes/generic.py
index c513787..619f50d 100644
a
|
b
|
def generic_inlineformset_factory(model, form=ModelForm,
|
424 | 424 | ct_field="content_type", fk_field="object_id", |
425 | 425 | fields=None, exclude=None, |
426 | 426 | extra=3, can_order=False, can_delete=True, |
427 | | max_num=None, |
| 427 | max_num=None, min_num=None, |
428 | 428 | formfield_callback=None): |
429 | 429 | """ |
430 | 430 | Returns an ``GenericInlineFormSet`` for the given kwargs. |
… |
… |
def generic_inlineformset_factory(model, form=ModelForm,
|
449 | 449 | formfield_callback=formfield_callback, |
450 | 450 | formset=formset, |
451 | 451 | extra=extra, can_delete=can_delete, can_order=can_order, |
452 | | fields=fields, exclude=exclude, max_num=max_num) |
| 452 | fields=fields, exclude=exclude, max_num=max_num, min_num=min_num) |
453 | 453 | FormSet.ct_field = ct_field |
454 | 454 | FormSet.ct_fk_field = fk_field |
455 | 455 | return FormSet |
… |
… |
class GenericInlineModelAdmin(InlineModelAdmin):
|
486 | 486 | "can_order": False, |
487 | 487 | "fields": fields, |
488 | 488 | "max_num": self.max_num, |
| 489 | "min_num": self.min_num, |
489 | 490 | "exclude": exclude |
490 | 491 | } |
491 | 492 | defaults.update(kwargs) |
diff --git a/django/forms/models.py b/django/forms/models.py
index cd8f027..12cef23 100644
a
|
b
|
class BaseModelFormSet(BaseFormSet):
|
664 | 664 | def modelformset_factory(model, form=ModelForm, formfield_callback=None, |
665 | 665 | formset=BaseModelFormSet, |
666 | 666 | extra=1, can_delete=False, can_order=False, |
667 | | max_num=None, fields=None, exclude=None): |
| 667 | max_num=None, min_num=None, fields=None, exclude=None): |
668 | 668 | """ |
669 | 669 | Returns a FormSet class for the given Django model class. |
670 | 670 | """ |
671 | 671 | form = modelform_factory(model, form=form, fields=fields, exclude=exclude, |
672 | 672 | formfield_callback=formfield_callback) |
673 | | FormSet = formset_factory(form, formset, extra=extra, max_num=max_num, |
| 673 | FormSet = formset_factory(form, formset, extra=extra, max_num=max_num, min_num=min_num, |
674 | 674 | can_order=can_order, can_delete=can_delete) |
675 | 675 | FormSet.model = model |
676 | 676 | return FormSet |
… |
… |
def _get_foreign_key(parent_model, model, fk_name=None, can_fail=False):
|
806 | 806 | def inlineformset_factory(parent_model, model, form=ModelForm, |
807 | 807 | formset=BaseInlineFormSet, fk_name=None, |
808 | 808 | fields=None, exclude=None, |
809 | | extra=3, can_order=False, can_delete=True, max_num=None, |
| 809 | extra=3, can_order=False, can_delete=True, max_num=None, min_num=None, |
810 | 810 | formfield_callback=None): |
811 | 811 | """ |
812 | 812 | Returns an ``InlineFormSet`` for the given kwargs. |
… |
… |
def inlineformset_factory(parent_model, model, form=ModelForm,
|
828 | 828 | 'fields': fields, |
829 | 829 | 'exclude': exclude, |
830 | 830 | 'max_num': max_num, |
| 831 | 'min_num': min_num, |
831 | 832 | } |
832 | 833 | FormSet = modelformset_factory(model, **kwargs) |
833 | 834 | FormSet.fk = fk |
diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt
index 76ea780..ebd9ae3 100644
a
|
b
|
The ``InlineModelAdmin`` class adds:
|
1465 | 1465 | doesn't directly correlate to the number of objects, but can if the value |
1466 | 1466 | is small enough. See :ref:`model-formsets-max-num` for more information. |
1467 | 1467 | |
| 1468 | .. attribute:: InlineModelAdmin.min_num |
| 1469 | |
| 1470 | .. versionadded:: 1.4 |
| 1471 | |
| 1472 | This controls the minumum number of forms to show in the inline. |
| 1473 | See :ref:`formsets-min-num` for more information. |
| 1474 | |
| 1475 | .. _ref-contrib-admin-inline-min-num: |
| 1476 | |
1468 | 1477 | .. attribute:: InlineModelAdmin.raw_id_fields |
1469 | 1478 | |
1470 | 1479 | By default, Django's admin uses a select-box interface (<select>) for |
diff --git a/docs/topics/forms/formsets.txt b/docs/topics/forms/formsets.txt
index 9a91182..0646c60 100644
a
|
b
|
from ``0`` to ``None`` in version 1.2 to allow ``0`` as a valid value.
|
117 | 117 | Ensuring the minimum number of forms |
118 | 118 | ------------------------------------ |
119 | 119 | |
| 120 | .. versionadded:: 1.4 |
| 121 | |
120 | 122 | The ``min_num`` parameter to ``formset_factory`` gives you the ability to make |
121 | 123 | sure that at least a certain number of forms will be displayed:: |
122 | 124 | |
… |
… |
sure that at least a certain number of forms will be displayed::
|
133 | 135 | <tr><th><label for="id_form-1-title">Title:</label></th><td><input type="text" name="form-1-title" id="id_form-1-title" /></td></tr> |
134 | 136 | <tr><th><label for="id_form-1-pub_date">Pub date:</label></th><td><input type="text" name="form-1-pub_date" id="id_form-1-pub_date" /></td></tr> |
135 | 137 | |
136 | | .. versionchanged:: 1.4 |
137 | 138 | |
138 | 139 | If the value of ``min_num`` is greater than the number of elements that would |
139 | 140 | be displayed normally (including extra forms), additional blank forms will be |
diff --git a/tests/regressiontests/generic_inline_admin/admin.py b/tests/regressiontests/generic_inline_admin/admin.py
index 73cac7f..9b12c70 100644
a
|
b
|
from django.contrib import admin
|
4 | 4 | from django.contrib.contenttypes import generic |
5 | 5 | |
6 | 6 | from .models import (Media, PhoneNumber, Episode, EpisodeExtra, Contact, |
7 | | Category, EpisodePermanent, EpisodeMaxNum) |
| 7 | Category, EpisodePermanent, EpisodeMaxNum, EpisodeMinNum) |
8 | 8 | |
9 | 9 | |
10 | 10 | site = admin.AdminSite(name="admin") |
… |
… |
class MediaMaxNumInline(generic.GenericTabularInline):
|
29 | 29 | extra = 5 |
30 | 30 | max_num = 2 |
31 | 31 | |
| 32 | class MediaMinNumInline(generic.GenericTabularInline): |
| 33 | model = Media |
| 34 | extra = 1 |
| 35 | min_num = 3 |
32 | 36 | |
33 | 37 | class PhoneNumberInline(generic.GenericTabularInline): |
34 | 38 | model = PhoneNumber |
… |
… |
class MediaPermanentInline(generic.GenericTabularInline):
|
42 | 46 | site.register(Episode, EpisodeAdmin) |
43 | 47 | site.register(EpisodeExtra, inlines=[MediaExtraInline]) |
44 | 48 | site.register(EpisodeMaxNum, inlines=[MediaMaxNumInline]) |
| 49 | site.register(EpisodeMinNum, inlines=[MediaMinNumInline]) |
45 | 50 | site.register(Contact, inlines=[PhoneNumberInline]) |
46 | 51 | site.register(Category) |
47 | 52 | site.register(EpisodePermanent, inlines=[MediaPermanentInline]) |
diff --git a/tests/regressiontests/generic_inline_admin/models.py b/tests/regressiontests/generic_inline_admin/models.py
index b9426b4..65d9df9 100644
a
|
b
|
class EpisodeExtra(Episode):
|
42 | 42 | class EpisodeMaxNum(Episode): |
43 | 43 | pass |
44 | 44 | |
| 45 | # |
| 46 | # Generic inline with extra and min_num |
| 47 | # |
| 48 | class EpisodeMinNum(Episode): |
| 49 | pass |
45 | 50 | |
46 | 51 | # |
47 | 52 | # Generic inline with unique_together |
diff --git a/tests/regressiontests/generic_inline_admin/tests.py b/tests/regressiontests/generic_inline_admin/tests.py
index db81eec..829a793 100644
a
|
b
|
from django.test import TestCase
|
12 | 12 | |
13 | 13 | # local test models |
14 | 14 | from .admin import MediaInline, MediaPermanentInline |
15 | | from .models import (Episode, EpisodeExtra, EpisodeMaxNum, Media, |
| 15 | from .models import (Episode, EpisodeExtra, EpisodeMaxNum, EpisodeMinNum, Media, |
16 | 16 | EpisodePermanent, Category) |
17 | 17 | |
18 | 18 | |
… |
… |
class GenericInlineAdminParametersTest(TestCase):
|
174 | 174 | With extra=5 and max_num=2, there should be only 2 forms. |
175 | 175 | """ |
176 | 176 | e = self._create_object(EpisodeMaxNum) |
177 | | inline_form_data = '<input type="hidden" name="generic_inline_admin-media-content_type-object_id-TOTAL_FORMS" value="2" id="id_generic_inline_admin-media-content_type-object_id-TOTAL_FORMS" /><input type="hidden" name="generic_inline_admin-media-content_type-object_id-INITIAL_FORMS" value="1" id="id_generic_inline_admin-media-content_type-object_id-INITIAL_FORMS" />' |
| 177 | inline_form_data = """<input id="id_generic_inline_admin-media-content_type-object_id-TOTAL_FORMS" name="generic_inline_admin-media-content_type-object_id-TOTAL_FORMS" type="hidden" value="2" /><input id="id_generic_inline_admin-media-content_type-object_id-INITIAL_FORMS" name="generic_inline_admin-media-content_type-object_id-INITIAL_FORMS" type="hidden" value="1" /><input id="id_generic_inline_admin-media-content_type-object_id-MAX_NUM_FORMS" name="generic_inline_admin-media-content_type-object_id-MAX_NUM_FORMS" type="hidden" value="2" /><input id="id_generic_inline_admin-media-content_type-object_id-MIN_NUM_FORMS" name="generic_inline_admin-media-content_type-object_id-MIN_NUM_FORMS" type="hidden" />""" |
| 178 | |
178 | 179 | response = self.client.get('/generic_inline_admin/admin/generic_inline_admin/episodemaxnum/%s/' % e.pk) |
179 | 180 | formset = response.context['inline_admin_formsets'][0].formset |
| 181 | |
| 182 | self.assertHTMLEqual( str(formset.management_form), inline_form_data ) |
180 | 183 | self.assertEqual(formset.total_form_count(), 2) |
181 | 184 | self.assertEqual(formset.initial_form_count(), 1) |
182 | 185 | |
| 186 | def testMinNumParam(self): |
| 187 | """ |
| 188 | With extra=1 and min_num=3, there should be exactly 3 forms, one filled and 2 blank. |
| 189 | """ |
| 190 | e = self._create_object(EpisodeMinNum) |
| 191 | inline_form_data = """<input type="hidden" name="generic_inline_admin-media-content_type-object_id-TOTAL_FORMS" value="3" id="id_generic_inline_admin-media-content_type-object_id-TOTAL_FORMS" /><input type="hidden" name="generic_inline_admin-media-content_type-object_id-INITIAL_FORMS" value="1" id="id_generic_inline_admin-media-content_type-object_id-INITIAL_FORMS" /><input type="hidden" name="generic_inline_admin-media-content_type-object_id-MAX_NUM_FORMS" id="id_generic_inline_admin-media-content_type-object_id-MAX_NUM_FORMS" /><input type="hidden" name="generic_inline_admin-media-content_type-object_id-MIN_NUM_FORMS" value="3" id="id_generic_inline_admin-media-content_type-object_id-MIN_NUM_FORMS" />""" |
| 192 | |
| 193 | response = self.client.get('/generic_inline_admin/admin/generic_inline_admin/episodeminnum/%s/' % e.pk) |
| 194 | formset = response.context['inline_admin_formsets'][0].formset |
| 195 | |
| 196 | self.assertHTMLEqual( str(formset.management_form), inline_form_data ) |
| 197 | self.assertEqual(formset.total_form_count(), 3) |
| 198 | self.assertEqual(formset.initial_form_count(), 1) |
183 | 199 | |
184 | 200 | class GenericInlineAdminWithUniqueTogetherTest(TestCase): |
185 | 201 | urls = "regressiontests.generic_inline_admin.urls" |