diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 8ce7ffa94227..060c9a091772 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -2646,6 +2646,12 @@ def _create_formsets(self, request, obj, change): if prefixes[prefix] != 1 or not prefix: prefix = "%s-%s" % (prefix, prefixes[prefix]) formset_params = self.get_formset_kwargs(request, obj, inline, prefix) + if formset_params.get("save_as_new") and not inline.has_add_permission( + request, None + ): + formset_params["data"] = formset_params["data"].copy() + formset_params["data"]["%s-TOTAL_FORMS" % prefix] = "0" + formset_params["data"]["%s-INITIAL_FORMS" % prefix] = "0" formset = FormSet(**formset_params) def user_deleted_form(request, obj, formset, index, inline): diff --git a/docs/topics/testing/tools.txt b/docs/topics/testing/tools.txt index 976aa4c93373..944701cdef16 100644 --- a/docs/topics/testing/tools.txt +++ b/docs/topics/testing/tools.txt @@ -824,7 +824,9 @@ A subclass of :class:`unittest.TestCase` that adds this functionality: =========================================== ====================================== * The ability to run tests with :ref:`modified settings `. -* Using the :attr:`~SimpleTestCase.client` :class:`~django.test.Client`. +* Access to the synchronous and asynchronous test clients via the + :attr:`~SimpleTestCase.client` and :attr:`~SimpleTestCase.async_client` + attributes. If your tests make any database queries, use subclasses :class:`~django.test.TransactionTestCase` or :class:`~django.test.TestCase`. @@ -1127,13 +1129,14 @@ Default test client ------------------- .. attribute:: SimpleTestCase.client +.. attribute:: SimpleTestCase.async_client -Every test case in a ``django.test.*TestCase`` instance has access to an -instance of a Django test client. This client can be accessed as -``self.client``. This client is recreated for each test, so you don't have to +Every ``django.test.*TestCase`` instance has access to Django's synchronous +and asynchronous test clients as ``self.client`` and ``self.async_client``, +respectively. Both clients are recreated for each test, so you don't have to worry about state (such as cookies) carrying over from one test to another. -This means, instead of instantiating a ``Client`` in each test:: +For example, rather than instantiating a ``Client`` in each test:: import unittest from django.test import Client @@ -1168,10 +1171,14 @@ Customizing the test client --------------------------- .. attribute:: SimpleTestCase.client_class +.. attribute:: SimpleTestCase.async_client_class -If you want to use a different ``Client`` class (for example, a subclass -with customized behavior), use the :attr:`~SimpleTestCase.client_class` class -attribute:: +If you want to use a different :class:`Client` or :class:`AsyncClient` class +(for example, a subclass with customized behavior), use the +:attr:`~SimpleTestCase.client_class` or +:attr:`~SimpleTestCase.async_client_class` class attributes. + +For example, to customize the synchronous test client:: from django.test import Client, TestCase @@ -1188,6 +1195,9 @@ attribute:: # Here self.client is an instance of MyTestClient... call_some_test_code() +The same pattern applies to the asynchronous test client by subclassing +:class:`AsyncClient` and setting :attr:`~SimpleTestCase.async_client_class`. + .. _topics-testing-fixtures: Fixture loading diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index b3be31380f8e..aa5d8b47b12a 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -3141,6 +3141,41 @@ def test_change_view_with_view_only_inlines(self): formset = response.context["inline_admin_formsets"][0] self.assertEqual(len(formset.forms), 3) + def test_save_as_new_with_view_only_inlines(self): + self.viewuser.user_permissions.add( + get_perm(Section, get_permission_codename("add", Section._meta)) + ) + self.client.force_login(self.viewuser) + model_admin = site._registry[Section] + get_formset_kwargs = model_admin.get_formset_kwargs + + def get_immutable_formset_kwargs(request, obj, inline, prefix): + kwargs = get_formset_kwargs(request, obj, inline, prefix) + kwargs["data"] = request.POST + return kwargs + + # Simulate immutable formset data to ensure save_as_new copies it + # before omitting the inline. + with mock.patch.object( + model_admin, + "get_formset_kwargs", + side_effect=get_immutable_formset_kwargs, + ): + response = self.client.post( + reverse("admin:admin_views_section_change", args=(self.s1.pk,)), + { + "_saveasnew": "Save as new", + "name": "", + "article_set-TOTAL_FORMS": 1, + "article_set-INITIAL_FORMS": 1, + }, + ) + self.assertContains(response, "Please correct the error below.") + inline_formset = response.context["inline_admin_formsets"][0] + self.assertEqual(inline_formset.formset.total_form_count(), 0) + self.assertEqual(inline_formset.formset.initial_form_count(), 0) + self.assertNotContains(response, self.a1.content) + def test_change_view_with_view_only_last_inline(self): self.viewuser.user_permissions.add( get_perm(Section, get_permission_codename("view", Section._meta))