浏览代码

Fixed #17642 -- Added min_num support to modelformsets, inlines, and the admin.

Thanks Stephen Burrows for work on the patch as well.

Forwardport of 2914f66983a92fcae55673c517dd8d01e8c238c4 from stable/1.7.x
Anders Steinlein 11 年之前
父节点
当前提交
4ef10f245a

+ 12 - 1
django/contrib/admin/checks.py

@@ -850,6 +850,7 @@ class InlineModelAdminChecks(BaseModelAdminChecks):
         errors.extend(self._check_exclude_of_parent_model(cls, parent_model))
         errors.extend(self._check_exclude_of_parent_model(cls, parent_model))
         errors.extend(self._check_extra(cls))
         errors.extend(self._check_extra(cls))
         errors.extend(self._check_max_num(cls))
         errors.extend(self._check_max_num(cls))
+        errors.extend(self._check_min_num(cls))
         errors.extend(self._check_formset(cls))
         errors.extend(self._check_formset(cls))
         return errors
         return errors
 
 
@@ -909,12 +910,22 @@ class InlineModelAdminChecks(BaseModelAdminChecks):
         else:
         else:
             return []
             return []
 
 
+    def _check_min_num(self, cls):
+        """ Check that min_num is an integer. """
+
+        if cls.min_num is None:
+            return []
+        elif not isinstance(cls.min_num, int):
+            return must_be('an integer', option='min_num', obj=cls, id='admin.E205')
+        else:
+            return []
+
     def _check_formset(self, cls):
     def _check_formset(self, cls):
         """ Check formset is a subclass of BaseModelFormSet. """
         """ Check formset is a subclass of BaseModelFormSet. """
 
 
         if not issubclass(cls.formset, BaseModelFormSet):
         if not issubclass(cls.formset, BaseModelFormSet):
             return must_inherit_from(parent='BaseModelFormSet', option='formset',
             return must_inherit_from(parent='BaseModelFormSet', option='formset',
-                                     obj=cls, id='admin.E205')
+                                     obj=cls, id='admin.E206')
         else:
         else:
             return []
             return []
 
 

+ 6 - 0
django/contrib/admin/options.py

@@ -1706,6 +1706,7 @@ class InlineModelAdmin(BaseModelAdmin):
     fk_name = None
     fk_name = None
     formset = BaseInlineFormSet
     formset = BaseInlineFormSet
     extra = 3
     extra = 3
+    min_num = None
     max_num = None
     max_num = None
     template = None
     template = None
     verbose_name = None
     verbose_name = None
@@ -1738,6 +1739,10 @@ class InlineModelAdmin(BaseModelAdmin):
         """Hook for customizing the number of extra inline forms."""
         """Hook for customizing the number of extra inline forms."""
         return self.extra
         return self.extra
 
 
+    def get_min_num(self, request, obj=None, **kwargs):
+        """Hook for customizing the min number of inline forms."""
+        return self.min_num
+
     def get_max_num(self, request, obj=None, **kwargs):
     def get_max_num(self, request, obj=None, **kwargs):
         """Hook for customizing the max number of extra inline forms."""
         """Hook for customizing the max number of extra inline forms."""
         return self.max_num
         return self.max_num
@@ -1769,6 +1774,7 @@ class InlineModelAdmin(BaseModelAdmin):
             "exclude": exclude,
             "exclude": exclude,
             "formfield_callback": partial(self.formfield_for_dbfield, request=request),
             "formfield_callback": partial(self.formfield_for_dbfield, request=request),
             "extra": self.get_extra(request, obj, **kwargs),
             "extra": self.get_extra(request, obj, **kwargs),
+            "min_num": self.get_min_num(request, obj, **kwargs),
             "max_num": self.get_max_num(request, obj, **kwargs),
             "max_num": self.get_max_num(request, obj, **kwargs),
             "can_delete": can_delete,
             "can_delete": can_delete,
         }
         }

+ 1 - 0
django/contrib/contenttypes/admin.py

@@ -119,6 +119,7 @@ class GenericInlineModelAdmin(InlineModelAdmin):
             "can_delete": can_delete,
             "can_delete": can_delete,
             "can_order": False,
             "can_order": False,
             "fields": fields,
             "fields": fields,
+            "min_num": self.min_num,
             "max_num": self.max_num,
             "max_num": self.max_num,
             "exclude": exclude
             "exclude": exclude
         }
         }

+ 5 - 4
django/contrib/contenttypes/forms.py

@@ -56,9 +56,9 @@ def generic_inlineformset_factory(model, form=ModelForm,
                                   ct_field="content_type", fk_field="object_id",
                                   ct_field="content_type", fk_field="object_id",
                                   fields=None, exclude=None,
                                   fields=None, exclude=None,
                                   extra=3, can_order=False, can_delete=True,
                                   extra=3, can_order=False, can_delete=True,
-                                  max_num=None,
-                                  formfield_callback=None, validate_max=False,
-                                  for_concrete_model=True):
+                                  max_num=None, formfield_callback=None,
+                                  validate_max=False, for_concrete_model=True,
+                                  min_num=None, validate_min=False):
     """
     """
     Returns a ``GenericInlineFormSet`` for the given kwargs.
     Returns a ``GenericInlineFormSet`` for the given kwargs.
 
 
@@ -81,7 +81,8 @@ def generic_inlineformset_factory(model, form=ModelForm,
                                    formset=formset,
                                    formset=formset,
                                    extra=extra, can_delete=can_delete, can_order=can_order,
                                    extra=extra, can_delete=can_delete, can_order=can_order,
                                    fields=fields, exclude=exclude, max_num=max_num,
                                    fields=fields, exclude=exclude, max_num=max_num,
-                                   validate_max=validate_max)
+                                   validate_max=validate_max, min_num=min_num,
+                                   validate_min=validate_min)
     FormSet.ct_field = ct_field
     FormSet.ct_field = ct_field
     FormSet.ct_fk_field = fk_field
     FormSet.ct_fk_field = fk_field
     FormSet.for_concrete_model = for_concrete_model
     FormSet.for_concrete_model = for_concrete_model

+ 8 - 4
django/forms/models.py

@@ -805,7 +805,8 @@ def modelformset_factory(model, form=ModelForm, formfield_callback=None,
                          formset=BaseModelFormSet, extra=1, can_delete=False,
                          formset=BaseModelFormSet, extra=1, can_delete=False,
                          can_order=False, max_num=None, fields=None, exclude=None,
                          can_order=False, max_num=None, fields=None, exclude=None,
                          widgets=None, validate_max=False, localized_fields=None,
                          widgets=None, validate_max=False, localized_fields=None,
-                         labels=None, help_texts=None, error_messages=None):
+                         labels=None, help_texts=None, error_messages=None,
+                         min_num=None, validate_min=False):
     """
     """
     Returns a FormSet class for the given Django model class.
     Returns a FormSet class for the given Django model class.
     """
     """
@@ -823,9 +824,9 @@ def modelformset_factory(model, form=ModelForm, formfield_callback=None,
                              formfield_callback=formfield_callback,
                              formfield_callback=formfield_callback,
                              widgets=widgets, localized_fields=localized_fields,
                              widgets=widgets, localized_fields=localized_fields,
                              labels=labels, help_texts=help_texts, error_messages=error_messages)
                              labels=labels, help_texts=help_texts, error_messages=error_messages)
-    FormSet = formset_factory(form, formset, extra=extra, max_num=max_num,
+    FormSet = formset_factory(form, formset, extra=extra, min_num=min_num, max_num=max_num,
                               can_order=can_order, can_delete=can_delete,
                               can_order=can_order, can_delete=can_delete,
-                              validate_max=validate_max)
+                              validate_min=validate_min, validate_max=validate_max)
     FormSet.model = model
     FormSet.model = model
     return FormSet
     return FormSet
 
 
@@ -969,7 +970,8 @@ def inlineformset_factory(parent_model, model, form=ModelForm,
                           fields=None, exclude=None, extra=3, can_order=False,
                           fields=None, exclude=None, extra=3, can_order=False,
                           can_delete=True, max_num=None, formfield_callback=None,
                           can_delete=True, max_num=None, formfield_callback=None,
                           widgets=None, validate_max=False, localized_fields=None,
                           widgets=None, validate_max=False, localized_fields=None,
-                          labels=None, help_texts=None, error_messages=None):
+                          labels=None, help_texts=None, error_messages=None,
+                          min_num=None, validate_min=False):
     """
     """
     Returns an ``InlineFormSet`` for the given kwargs.
     Returns an ``InlineFormSet`` for the given kwargs.
 
 
@@ -989,8 +991,10 @@ def inlineformset_factory(parent_model, model, form=ModelForm,
         'can_order': can_order,
         'can_order': can_order,
         'fields': fields,
         'fields': fields,
         'exclude': exclude,
         'exclude': exclude,
+        'min_num': min_num,
         'max_num': max_num,
         'max_num': max_num,
         'widgets': widgets,
         'widgets': widgets,
+        'validate_min': validate_min,
         'validate_max': validate_max,
         'validate_max': validate_max,
         'localized_fields': localized_fields,
         'localized_fields': localized_fields,
         'labels': labels,
         'labels': labels,

+ 2 - 1
docs/ref/checks.txt

@@ -205,7 +205,8 @@ inline on a :class:`~django.contrib.admin.ModelAdmin`.
 * **admin.E202**: ``<model>`` has no ForeignKey to ``<parent model>``./``<model>`` has more than one ForeignKey to ``<parent model>``.
 * **admin.E202**: ``<model>`` has no ForeignKey to ``<parent model>``./``<model>`` has more than one ForeignKey to ``<parent model>``.
 * **admin.E203**: The value of ``extra`` must be an integer.
 * **admin.E203**: The value of ``extra`` must be an integer.
 * **admin.E204**: The value of ``max_num`` must be an integer.
 * **admin.E204**: The value of ``max_num`` must be an integer.
-* **admin.E205**: The value of ``formset`` must inherit from ``BaseModelFormSet``.
+* **admin.E205**: The value of ``min_num`` must be an integer.
+* **admin.E206**: The value of ``formset`` must inherit from ``BaseModelFormSet``.
 
 
 GenericInlineModelAdmin
 GenericInlineModelAdmin
 ~~~~~~~~~~~~~~~~~~~~~~~
 ~~~~~~~~~~~~~~~~~~~~~~~

+ 20 - 0
docs/ref/contrib/admin/index.txt

@@ -1966,6 +1966,16 @@ The ``InlineModelAdmin`` class adds:
     :meth:`InlineModelAdmin.get_max_num` also allows you to customize the
     :meth:`InlineModelAdmin.get_max_num` also allows you to customize the
     maximum number of extra forms.
     maximum number of extra forms.
 
 
+.. attribute:: InlineModelAdmin.min_num
+
+    .. versionadded:: 1.7
+
+    This controls the minimum number of forms to show in the inline.
+    See :func:`~django.forms.models.modelformset_factory` for more information.
+
+    :meth:`InlineModelAdmin.get_min_num` also allows you to customize the
+    minimum number of displayed forms.
+
 .. attribute:: InlineModelAdmin.raw_id_fields
 .. attribute:: InlineModelAdmin.raw_id_fields
 
 
     By default, Django's admin uses a select-box interface (<select>) for
     By default, Django's admin uses a select-box interface (<select>) for
@@ -2042,6 +2052,16 @@ The ``InlineModelAdmin`` class adds:
                     return max_num - 5
                     return max_num - 5
                 return max_num
                 return max_num
 
 
+.. method:: InlineModelAdmin.get_min_num(request, obj=None, **kwargs)
+
+    .. versionadded:: 1.7
+
+    Returns the minimum number of inline forms to use. By default,
+    returns the :attr:`InlineModelAdmin.min_num` attribute.
+
+    Override this method to programmatically determine the minimum number of
+    inline forms. For example, this may be based on the model instance
+    (passed as the keyword argument ``obj``).
 
 
 Working with a model with two or more foreign keys to the same parent model
 Working with a model with two or more foreign keys to the same parent model
 ---------------------------------------------------------------------------
 ---------------------------------------------------------------------------

+ 5 - 1
docs/ref/contrib/contenttypes.txt

@@ -495,7 +495,7 @@ The :mod:`django.contrib.contenttypes.forms` module provides:
 
 
         This class used to be defined in ``django.contrib.contenttypes.generic``.
         This class used to be defined in ``django.contrib.contenttypes.generic``.
 
 
-.. function:: generic_inlineformset_factory(model, form=ModelForm, formset=BaseGenericInlineFormSet, ct_field="content_type", fk_field="object_id", fields=None, exclude=None, extra=3, can_order=False, can_delete=True, max_num=None, formfield_callback=None, validate_max=False, for_concrete_model=True)
+.. function:: generic_inlineformset_factory(model, form=ModelForm, formset=BaseGenericInlineFormSet, ct_field="content_type", fk_field="object_id", fields=None, exclude=None, extra=3, can_order=False, can_delete=True, max_num=None, formfield_callback=None, validate_max=False, for_concrete_model=True, min_num=None, validate_min=False)
 
 
     Returns a ``GenericInlineFormSet`` using
     Returns a ``GenericInlineFormSet`` using
     :func:`~django.forms.models.modelformset_factory`.
     :func:`~django.forms.models.modelformset_factory`.
@@ -514,6 +514,10 @@ The :mod:`django.contrib.contenttypes.forms` module provides:
 
 
         This function used to be defined in ``django.contrib.contenttypes.generic``.
         This function used to be defined in ``django.contrib.contenttypes.generic``.
 
 
+    .. versionchanged:: 1.7
+
+        ``min_num`` and ``validate_min`` were added.
+
 
 
 .. module:: django.contrib.contenttypes.admin
 .. module:: django.contrib.contenttypes.admin
 
 

+ 2 - 2
docs/ref/forms/models.txt

@@ -45,7 +45,7 @@ Model Form Functions
         Previously, omitting the list of fields was allowed and resulted in
         Previously, omitting the list of fields was allowed and resulted in
         a form with all fields of the model.
         a form with all fields of the model.
 
 
-.. function:: modelformset_factory(model, form=ModelForm, formfield_callback=None, formset=BaseModelFormSet, extra=1, can_delete=False, can_order=False, max_num=None, fields=None, exclude=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None)
+.. function:: modelformset_factory(model, form=ModelForm, formfield_callback=None, formset=BaseModelFormSet, extra=1, can_delete=False, can_order=False, max_num=None, fields=None, exclude=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False)
 
 
     Returns a ``FormSet`` class for the given ``model`` class.
     Returns a ``FormSet`` class for the given ``model`` class.
 
 
@@ -61,7 +61,7 @@ Model Form Functions
 
 
     See :ref:`model-formsets` for example usage.
     See :ref:`model-formsets` for example usage.
 
 
-.. function:: inlineformset_factory(parent_model, model, form=ModelForm, formset=BaseInlineFormSet, fk_name=None, fields=None, exclude=None, extra=3, can_order=False, can_delete=True, max_num=None, formfield_callback=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None)
+.. function:: inlineformset_factory(parent_model, model, form=ModelForm, formset=BaseInlineFormSet, fk_name=None, fields=None, exclude=None, extra=3, can_order=False, can_delete=True, max_num=None, formfield_callback=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False)
 
 
     Returns an ``InlineFormSet`` using :func:`modelformset_factory` with
     Returns an ``InlineFormSet`` using :func:`modelformset_factory` with
     defaults of ``formset=``:class:`~django.forms.models.BaseInlineFormSet`,
     defaults of ``formset=``:class:`~django.forms.models.BaseInlineFormSet`,

+ 60 - 2
tests/admin_inlines/tests.py

@@ -1,13 +1,14 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
+from django.contrib.admin import TabularInline, ModelAdmin
 from django.contrib.admin.tests import AdminSeleniumWebDriverTestCase
 from django.contrib.admin.tests import AdminSeleniumWebDriverTestCase
 from django.contrib.admin.helpers import InlineAdminForm
 from django.contrib.admin.helpers import InlineAdminForm
 from django.contrib.auth.models import User, Permission
 from django.contrib.auth.models import User, Permission
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.test import TestCase, override_settings
+from django.test import TestCase, override_settings, RequestFactory
 
 
 # local test models
 # local test models
-from .admin import InnerInline
+from .admin import InnerInline, site as admin_site
 from .models import (Holder, Inner, Holder2, Inner2, Holder3, Inner3, Person,
 from .models import (Holder, Inner, Holder2, Inner2, Holder3, Inner3, Person,
     OutfitItem, Fashionista, Teacher, Parent, Child, Author, Book, Profile,
     OutfitItem, Fashionista, Teacher, Parent, Child, Author, Book, Profile,
     ProfileCollection, ParentModelWithCustomPk, ChildModel1, ChildModel2,
     ProfileCollection, ParentModelWithCustomPk, ChildModel1, ChildModel2,
@@ -28,6 +29,7 @@ class TestInline(TestCase):
 
 
         result = self.client.login(username='super', password='secret')
         result = self.client.login(username='super', password='secret')
         self.assertEqual(result, True)
         self.assertEqual(result, True)
+        self.factory = RequestFactory()
 
 
     def tearDown(self):
     def tearDown(self):
         self.client.logout()
         self.client.logout()
@@ -221,6 +223,62 @@ class TestInline(TestCase):
         self.assertContains(response, max_forms_input % 2)
         self.assertContains(response, max_forms_input % 2)
         self.assertContains(response, total_forms_hidden)
         self.assertContains(response, total_forms_hidden)
 
 
+    def test_min_num(self):
+        """
+        Ensure that min_num and extra determine number of forms.
+        """
+        class MinNumInline(TabularInline):
+            model = BinaryTree
+            min_num = 2
+            extra = 3
+
+        modeladmin = ModelAdmin(BinaryTree, admin_site)
+        modeladmin.inlines = [MinNumInline]
+
+        min_forms = '<input id="id_binarytree_set-MIN_NUM_FORMS" name="binarytree_set-MIN_NUM_FORMS" type="hidden" value="2" />'
+        total_forms = '<input id="id_binarytree_set-TOTAL_FORMS" name="binarytree_set-TOTAL_FORMS" type="hidden" value="5" />'
+
+        request = self.factory.get('/admin/admin_inlines/binarytree/add/')
+        request.user = User(username='super', is_superuser=True)
+        response = modeladmin.changeform_view(request)
+        self.assertContains(response, min_forms)
+        self.assertContains(response, total_forms)
+
+    def test_custom_min_num(self):
+        """
+        Ensure that get_min_num is called and used correctly.
+        See #22628 - this will change when that's fixed.
+        """
+        bt_head = BinaryTree.objects.create(name="Tree Head")
+        BinaryTree.objects.create(name="First Child", parent=bt_head)
+
+        class MinNumInline(TabularInline):
+            model = BinaryTree
+            extra = 3
+
+            def get_min_num(self, request, obj=None, **kwargs):
+                if obj:
+                    return 5
+                return 2
+
+        modeladmin = ModelAdmin(BinaryTree, admin_site)
+        modeladmin.inlines = [MinNumInline]
+
+        min_forms = '<input id="id_binarytree_set-MIN_NUM_FORMS" name="binarytree_set-MIN_NUM_FORMS" type="hidden" value="%d" />'
+        total_forms = '<input id="id_binarytree_set-TOTAL_FORMS" name="binarytree_set-TOTAL_FORMS" type="hidden" value="%d" />'
+
+        request = self.factory.get('/admin/admin_inlines/binarytree/add/')
+        request.user = User(username='super', is_superuser=True)
+        response = modeladmin.changeform_view(request)
+        self.assertContains(response, min_forms % 2)
+        self.assertContains(response, total_forms % 5)
+
+        request = self.factory.get("/admin/admin_inlines/binarytree/%d/" % bt_head.id)
+        request.user = User(username='super', is_superuser=True)
+        response = modeladmin.changeform_view(request, object_id=str(bt_head.id))
+        self.assertContains(response, min_forms % 5)
+        self.assertContains(response, total_forms % 9)
+
     def test_inline_nonauto_noneditable_pk(self):
     def test_inline_nonauto_noneditable_pk(self):
         response = self.client.get('/admin/admin_inlines/author/add/')
         response = self.client.get('/admin/admin_inlines/author/add/')
         self.assertContains(response,
         self.assertContains(response,

+ 2 - 15
tests/generic_inline_admin/admin.py

@@ -1,8 +1,8 @@
 from django.contrib import admin
 from django.contrib import admin
 from django.contrib.contenttypes.admin import GenericTabularInline
 from django.contrib.contenttypes.admin import GenericTabularInline
 
 
-from .models import (Media, PhoneNumber, Episode, EpisodeExtra, Contact,
-    Category, EpisodePermanent, EpisodeMaxNum)
+from .models import (Media, PhoneNumber, Episode, Contact,
+    Category, EpisodePermanent)
 
 
 
 
 site = admin.AdminSite(name="admin")
 site = admin.AdminSite(name="admin")
@@ -18,17 +18,6 @@ class EpisodeAdmin(admin.ModelAdmin):
     ]
     ]
 
 
 
 
-class MediaExtraInline(GenericTabularInline):
-    model = Media
-    extra = 0
-
-
-class MediaMaxNumInline(GenericTabularInline):
-    model = Media
-    extra = 5
-    max_num = 2
-
-
 class PhoneNumberInline(GenericTabularInline):
 class PhoneNumberInline(GenericTabularInline):
     model = PhoneNumber
     model = PhoneNumber
 
 
@@ -39,8 +28,6 @@ class MediaPermanentInline(GenericTabularInline):
 
 
 
 
 site.register(Episode, EpisodeAdmin)
 site.register(Episode, EpisodeAdmin)
-site.register(EpisodeExtra, inlines=[MediaExtraInline])
-site.register(EpisodeMaxNum, inlines=[MediaMaxNumInline])
 site.register(Contact, inlines=[PhoneNumberInline])
 site.register(Contact, inlines=[PhoneNumberInline])
 site.register(Category)
 site.register(Category)
 site.register(EpisodePermanent, inlines=[MediaPermanentInline])
 site.register(EpisodePermanent, inlines=[MediaPermanentInline])

+ 0 - 20
tests/generic_inline_admin/models.py

@@ -27,26 +27,6 @@ class Media(models.Model):
     def __str__(self):
     def __str__(self):
         return self.url
         return self.url
 
 
-#
-# These models let us test the different GenericInline settings at
-# different urls in the admin site.
-#
-
-#
-# Generic inline with extra = 0
-#
-
-
-class EpisodeExtra(Episode):
-    pass
-
-
-#
-# Generic inline with extra and max_num
-#
-class EpisodeMaxNum(Episode):
-    pass
-
 
 
 #
 #
 # Generic inline with unique_together
 # Generic inline with unique_together

+ 51 - 10
tests/generic_inline_admin/tests.py

@@ -4,17 +4,17 @@ import warnings
 
 
 from django.contrib import admin
 from django.contrib import admin
 from django.contrib.admin.sites import AdminSite
 from django.contrib.admin.sites import AdminSite
+from django.contrib.auth.models import User
 from django.contrib.contenttypes.admin import GenericTabularInline
 from django.contrib.contenttypes.admin import GenericTabularInline
 from django.contrib.contenttypes.forms import generic_inlineformset_factory
 from django.contrib.contenttypes.forms import generic_inlineformset_factory
 from django.forms.formsets import DEFAULT_MAX_NUM
 from django.forms.formsets import DEFAULT_MAX_NUM
 from django.forms.models import ModelForm
 from django.forms.models import ModelForm
-from django.test import TestCase, override_settings
+from django.test import TestCase, override_settings, RequestFactory
 from django.utils.deprecation import RemovedInDjango19Warning
 from django.utils.deprecation import RemovedInDjango19Warning
 
 
 # local test models
 # local test models
-from .admin import MediaInline, MediaPermanentInline
-from .models import (Episode, EpisodeExtra, EpisodeMaxNum, Media,
-    EpisodePermanent, Category)
+from .admin import MediaInline, MediaPermanentInline, site as admin_site
+from .models import Episode, Media, EpisodePermanent, Category
 
 
 
 
 @override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.SHA1PasswordHasher',),
 @override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.SHA1PasswordHasher',),
@@ -137,6 +137,7 @@ class GenericInlineAdminParametersTest(TestCase):
 
 
     def setUp(self):
     def setUp(self):
         self.client.login(username='super', password='secret')
         self.client.login(username='super', password='secret')
+        self.factory = RequestFactory()
 
 
     def tearDown(self):
     def tearDown(self):
         self.client.logout()
         self.client.logout()
@@ -166,9 +167,18 @@ class GenericInlineAdminParametersTest(TestCase):
         """
         """
         With extra=0, there should be one form.
         With extra=0, there should be one form.
         """
         """
-        e = self._create_object(EpisodeExtra)
-        response = self.client.get('/generic_inline_admin/admin/generic_inline_admin/episodeextra/%s/' % e.pk)
-        formset = response.context['inline_admin_formsets'][0].formset
+        class ExtraInline(GenericTabularInline):
+            model = Media
+            extra = 0
+
+        modeladmin = admin.ModelAdmin(Episode, admin_site)
+        modeladmin.inlines = [ExtraInline]
+
+        e = self._create_object(Episode)
+        request = self.factory.get('/generic_inline_admin/admin/generic_inline_admin/episode/%s/' % e.pk)
+        request.user = User(username='super', is_superuser=True)
+        response = modeladmin.changeform_view(request, object_id=str(e.pk))
+        formset = response.context_data['inline_admin_formsets'][0].formset
         self.assertEqual(formset.total_form_count(), 1)
         self.assertEqual(formset.total_form_count(), 1)
         self.assertEqual(formset.initial_form_count(), 1)
         self.assertEqual(formset.initial_form_count(), 1)
 
 
@@ -176,12 +186,43 @@ class GenericInlineAdminParametersTest(TestCase):
         """
         """
         With extra=5 and max_num=2, there should be only 2 forms.
         With extra=5 and max_num=2, there should be only 2 forms.
         """
         """
-        e = self._create_object(EpisodeMaxNum)
-        response = self.client.get('/generic_inline_admin/admin/generic_inline_admin/episodemaxnum/%s/' % e.pk)
-        formset = response.context['inline_admin_formsets'][0].formset
+        class MaxNumInline(GenericTabularInline):
+            model = Media
+            extra = 5
+            max_num = 2
+
+        modeladmin = admin.ModelAdmin(Episode, admin_site)
+        modeladmin.inlines = [MaxNumInline]
+
+        e = self._create_object(Episode)
+        request = self.factory.get('/generic_inline_admin/admin/generic_inline_admin/episode/%s/' % e.pk)
+        request.user = User(username='super', is_superuser=True)
+        response = modeladmin.changeform_view(request, object_id=str(e.pk))
+        formset = response.context_data['inline_admin_formsets'][0].formset
         self.assertEqual(formset.total_form_count(), 2)
         self.assertEqual(formset.total_form_count(), 2)
         self.assertEqual(formset.initial_form_count(), 1)
         self.assertEqual(formset.initial_form_count(), 1)
 
 
+    def testMinNumParam(self):
+        """
+        With extra=3 and min_num=2, there should be six forms.
+        See #22628 - this will change when that's fixed.
+        """
+        class MinNumInline(GenericTabularInline):
+            model = Media
+            extra = 3
+            min_num = 2
+
+        modeladmin = admin.ModelAdmin(Episode, admin_site)
+        modeladmin.inlines = [MinNumInline]
+
+        e = self._create_object(Episode)
+        request = self.factory.get('/generic_inline_admin/admin/generic_inline_admin/episode/%s/' % e.pk)
+        request.user = User(username='super', is_superuser=True)
+        response = modeladmin.changeform_view(request, object_id=str(e.pk))
+        formset = response.context_data['inline_admin_formsets'][0].formset
+        self.assertEqual(formset.total_form_count(), 6)
+        self.assertEqual(formset.initial_form_count(), 1)
+
 
 
 @override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.SHA1PasswordHasher',),
 @override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.SHA1PasswordHasher',),
                    ROOT_URLCONF="generic_inline_admin.urls")
                    ROOT_URLCONF="generic_inline_admin.urls")

+ 27 - 0
tests/model_formsets/tests.py

@@ -376,6 +376,33 @@ class ModelFormsetTest(TestCase):
             '<Author: Walt Whitman>',
             '<Author: Walt Whitman>',
         ])
         ])
 
 
+    def test_min_num(self):
+        # Test the behavior of min_num with model formsets. It should be
+        # added to extra.
+        qs = Author.objects.none()
+
+        AuthorFormSet = modelformset_factory(Author, fields="__all__", extra=0)
+        formset = AuthorFormSet(queryset=qs)
+        self.assertEqual(len(formset.forms), 0)
+
+        AuthorFormSet = modelformset_factory(Author, fields="__all__", min_num=1, extra=0)
+        formset = AuthorFormSet(queryset=qs)
+        self.assertEqual(len(formset.forms), 1)
+
+        AuthorFormSet = modelformset_factory(Author, fields="__all__", min_num=1, extra=1)
+        formset = AuthorFormSet(queryset=qs)
+        self.assertEqual(len(formset.forms), 2)
+
+    def test_min_num_with_existing(self):
+        # Test the behavior of min_num with existing objects.
+        # See #22628 - this will change when that's fixed.
+        Author.objects.create(name='Charles Baudelaire')
+        qs = Author.objects.all()
+
+        AuthorFormSet = modelformset_factory(Author, fields="__all__", extra=0, min_num=1)
+        formset = AuthorFormSet(queryset=qs)
+        self.assertEqual(len(formset.forms), 2)
+
     def test_custom_save_method(self):
     def test_custom_save_method(self):
         class PoetForm(forms.ModelForm):
         class PoetForm(forms.ModelForm):
             def save(self, commit=True):
             def save(self, commit=True):

+ 28 - 1
tests/modeladmin/tests.py

@@ -1444,6 +1444,33 @@ class MaxNumCheckTests(CheckTestCase):
         self.assertIsValid(ValidationTestModelAdmin, ValidationTestModel)
         self.assertIsValid(ValidationTestModelAdmin, ValidationTestModel)
 
 
 
 
+class MinNumCheckTests(CheckTestCase):
+
+    def test_not_integer(self):
+        class ValidationTestInline(TabularInline):
+            model = ValidationTestInlineModel
+            min_num = "hello"
+
+        class ValidationTestModelAdmin(ModelAdmin):
+            inlines = [ValidationTestInline]
+
+        self.assertIsInvalid(
+            ValidationTestModelAdmin, ValidationTestModel,
+            "The value of 'min_num' must be an integer.",
+            'admin.E205',
+            invalid_obj=ValidationTestInline)
+
+    def test_valid_case(self):
+        class ValidationTestInline(TabularInline):
+            model = ValidationTestInlineModel
+            min_num = 2
+
+        class ValidationTestModelAdmin(ModelAdmin):
+            inlines = [ValidationTestInline]
+
+        self.assertIsValid(ValidationTestModelAdmin, ValidationTestModel)
+
+
 class FormsetCheckTests(CheckTestCase):
 class FormsetCheckTests(CheckTestCase):
 
 
     def test_invalid_type(self):
     def test_invalid_type(self):
@@ -1460,7 +1487,7 @@ class FormsetCheckTests(CheckTestCase):
         self.assertIsInvalid(
         self.assertIsInvalid(
             ValidationTestModelAdmin, ValidationTestModel,
             ValidationTestModelAdmin, ValidationTestModel,
             "The value of 'formset' must inherit from 'BaseModelFormSet'.",
             "The value of 'formset' must inherit from 'BaseModelFormSet'.",
-            'admin.E205',
+            'admin.E206',
             invalid_obj=ValidationTestInline)
             invalid_obj=ValidationTestInline)
 
 
     def test_valid_case(self):
     def test_valid_case(self):