diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index d0948359ab..ddaf130584 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -1948,7 +1948,24 @@ class ModelAdmin(BaseModelAdmin): 'files': request.FILES, 'save_as_new': '_saveasnew' in request.POST }) - formsets.append(FormSet(**formset_params)) + formset = FormSet(**formset_params) + + def user_deleted_form(request, obj, formset, index): + """Return whether or not the user deleted the form.""" + return ( + inline.has_delete_permission(request, obj) and + '{}-{}-DELETE'.format(formset.prefix, index) in request.POST + ) + + # Bypass validation of each view-only inline form (since the form's + # data won't be in request.POST), unless the form was deleted. + if not inline.has_change_permission(request, obj): + for index, form in enumerate(formset.initial_forms): + if user_deleted_form(request, obj, formset, index): + continue + form._errors = {} + form.cleaned_data = form.initial + formsets.append(formset) inline_instances.append(inline) return formsets, inline_instances diff --git a/docs/releases/2.1.4.txt b/docs/releases/2.1.4.txt index 8ba457846e..6d0c3dce46 100644 --- a/docs/releases/2.1.4.txt +++ b/docs/releases/2.1.4.txt @@ -22,3 +22,7 @@ Bugfixes * Fixed admin view-only change form crash when using ``ModelAdmin.prepopulated_fields`` (:ticket:`29929`). + +* Fixed "Please correct the errors below" error message when editing an object + in the admin if the user only has the "view" permission on inlines + (:ticket:`29930`). diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index 2d1e58aa26..a94bbbccfc 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -1918,6 +1918,96 @@ class AdminViewPermissionsTest(TestCase): new_article = Article.objects.latest('id') self.assertRedirects(post, reverse('admin:admin_views_article_change', args=(new_article.pk,))) + def test_change_view_with_view_only_inlines(self): + """ + User with change permission to a section but view-only for inlines. + """ + self.viewuser.user_permissions.add(get_perm(Section, get_permission_codename('change', Section._meta))) + self.client.force_login(self.viewuser) + # GET shows inlines. + response = self.client.get(reverse('admin:admin_views_section_change', args=(self.s1.pk,))) + self.assertEqual(len(response.context['inline_admin_formsets']), 1) + formset = response.context['inline_admin_formsets'][0] + self.assertEqual(len(formset.forms), 3) + # Valid POST changes the name. + data = { + 'name': 'Can edit name with view-only inlines', + 'article_set-TOTAL_FORMS': 3, + 'article_set-INITIAL_FORMS': 3 + } + response = self.client.post(reverse('admin:admin_views_section_change', args=(self.s1.pk,)), data) + self.assertRedirects(response, reverse('admin:admin_views_section_changelist')) + self.assertEqual(Section.objects.get(pk=self.s1.pk).name, data['name']) + # Invalid POST reshows inlines. + del data['name'] + response = self.client.post(reverse('admin:admin_views_section_change', args=(self.s1.pk,)), data) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.context['inline_admin_formsets']), 1) + formset = response.context['inline_admin_formsets'][0] + self.assertEqual(len(formset.forms), 3) + + def test_change_view_with_view_and_add_inlines(self): + """User has view and add permissions on the inline model.""" + self.viewuser.user_permissions.add(get_perm(Section, get_permission_codename('change', Section._meta))) + self.viewuser.user_permissions.add(get_perm(Article, get_permission_codename('add', Article._meta))) + self.client.force_login(self.viewuser) + # GET shows inlines. + response = self.client.get(reverse('admin:admin_views_section_change', args=(self.s1.pk,))) + self.assertEqual(len(response.context['inline_admin_formsets']), 1) + formset = response.context['inline_admin_formsets'][0] + self.assertEqual(len(formset.forms), 6) + # Valid POST creates a new article. + data = { + 'name': 'Can edit name with view-only inlines', + 'article_set-TOTAL_FORMS': 6, + 'article_set-INITIAL_FORMS': 3, + 'article_set-3-id': [''], + 'article_set-3-title': ['A title'], + 'article_set-3-content': ['Added content'], + 'article_set-3-date_0': ['2008-3-18'], + 'article_set-3-date_1': ['11:54:58'], + 'article_set-3-section': [str(self.s1.pk)], + } + response = self.client.post(reverse('admin:admin_views_section_change', args=(self.s1.pk,)), data) + self.assertRedirects(response, reverse('admin:admin_views_section_changelist')) + self.assertEqual(Section.objects.get(pk=self.s1.pk).name, data['name']) + self.assertEqual(Article.objects.count(), 4) + # Invalid POST reshows inlines. + del data['name'] + response = self.client.post(reverse('admin:admin_views_section_change', args=(self.s1.pk,)), data) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.context['inline_admin_formsets']), 1) + formset = response.context['inline_admin_formsets'][0] + self.assertEqual(len(formset.forms), 6) + + def test_change_view_with_view_and_delete_inlines(self): + """User has view and delete permissions on the inline model.""" + self.viewuser.user_permissions.add(get_perm(Section, get_permission_codename('change', Section._meta))) + self.client.force_login(self.viewuser) + data = { + 'name': 'Name is required.', + 'article_set-TOTAL_FORMS': 6, + 'article_set-INITIAL_FORMS': 3, + 'article_set-0-id': [str(self.a1.pk)], + 'article_set-0-DELETE': ['on'], + } + # Inline POST details are ignored without delete permission. + response = self.client.post(reverse('admin:admin_views_section_change', args=(self.s1.pk,)), data) + self.assertRedirects(response, reverse('admin:admin_views_section_changelist')) + self.assertEqual(Article.objects.count(), 3) + # Deletion successful when delete permission is added. + self.viewuser.user_permissions.add(get_perm(Article, get_permission_codename('delete', Article._meta))) + data = { + 'name': 'Name is required.', + 'article_set-TOTAL_FORMS': 6, + 'article_set-INITIAL_FORMS': 3, + 'article_set-0-id': [str(self.a1.pk)], + 'article_set-0-DELETE': ['on'], + } + response = self.client.post(reverse('admin:admin_views_section_change', args=(self.s1.pk,)), data) + self.assertRedirects(response, reverse('admin:admin_views_section_changelist')) + self.assertEqual(Article.objects.count(), 2) + def test_delete_view(self): """Delete view should restrict access and actually delete items.""" delete_dict = {'post': 'yes'} diff --git a/tests/modeladmin/test_has_add_permission_obj_deprecation.py b/tests/modeladmin/test_has_add_permission_obj_deprecation.py index f5489a41db..c85b80da5f 100644 --- a/tests/modeladmin/test_has_add_permission_obj_deprecation.py +++ b/tests/modeladmin/test_has_add_permission_obj_deprecation.py @@ -86,6 +86,10 @@ class ModelAdminTests(TestCase): self.song = Song.objects.create(name='test', band=self.band) self.site = AdminSite() self.request = MockRequest() + self.request.POST = { + 'song_set-TOTAL_FORMS': 4, + 'song_set-INITIAL_FORMS': 1, + } self.request.user = self.MockAddUser() self.ma = BandAdmin(Band, self.site)