From 5b3cfce51770f46c6dc100e9be7f199a37176762 Mon Sep 17 00:00:00 2001 From: Artyom Kotovskiy Date: Sat, 25 Apr 2026 00:00:31 -0400 Subject: Refs #15759 -- Fixed ModelAdmin.list_editable form submission for non-editable instances. Added formset that excludes objects for which user has no permission for POST formset as well. Fixed regression test: the test was not simulating real behaviour properly. By providing full form data for the post request we skipped the part where the user was actually limited in permissions and only modified some of the rows. Improved tests by getting rid of obj.id % 2 approach for granting permissions per object for users, since it is not the safest. Instead granting permissions simply by 'alive' parameter, which is simpler and more stable. Bug in 84db026228413dda4cd195464554d51c0b208e32. --- django/contrib/admin/options.py | 13 ++++++++++--- tests/admin_views/admin.py | 2 +- tests/admin_views/tests.py | 36 +++++++++++++++++++----------------- 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index e5502c42d5..e05881b16a 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -2026,13 +2026,16 @@ class ModelAdmin(BaseModelAdmin): return queryset return queryset.filter(pk__in=object_pks) - def _get_formset_with_permissions(self, request, queryset): + def _get_formset_with_permissions(self, request, queryset, for_save=False): """ Construct a changelist formset, and remove list_editable fields for objects the user cannot change. """ FormSet = self.get_changelist_formset(request) - formset = FormSet(queryset=queryset) + if for_save: + formset = FormSet(data=request.POST, files=request.FILES, queryset=queryset) + else: + formset = FormSet(queryset=queryset) for form in formset.forms: if not self.has_change_permission(request, form.instance): @@ -2158,7 +2161,11 @@ class ModelAdmin(BaseModelAdmin): modified_objects = self._get_list_editable_queryset( request, FormSet.get_default_prefix() ) - cl.formset = FormSet(request.POST, request.FILES, queryset=modified_objects) + cl.formset = self._get_formset_with_permissions( + request, + queryset=modified_objects, + for_save=True, + ) if cl.formset.is_valid(): self._save_formset(request, cl.formset) diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py index 5e7a055ec3..d0448a1b64 100644 --- a/tests/admin_views/admin.py +++ b/tests/admin_views/admin.py @@ -385,7 +385,7 @@ class PersonNoChangePermissionsAdmin9(admin.ModelAdmin): def has_change_permission(self, request, obj=None): if obj is None: return True - return obj.id % 2 == 0 + return obj.alive class FooAccountAdmin(admin.StackedInline): diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index 1fdb90822c..3dba13d185 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -5076,14 +5076,14 @@ class AdminViewListEditable(TestCase): self.client.force_login(self.superuser) response = self.client.get(reverse("admin9:admin_views_person_changelist")) - # Editable fields present - self.assertContains(response, 'name="form-1-gender"') - self.assertContains(response, 'name="form-1-alive"') - # Non-editable fields should NOT have inputs - self.assertNotContains(response, 'name="form-0-gender"') - self.assertNotContains(response, 'name="form-0-alive"') - self.assertNotContains(response, 'name="form-2-gender"') - self.assertNotContains(response, 'name="form-2-alive"') + # Non-editable fields should NOT have inputs. + self.assertNotContains(response, 'name="form-1-gender"') + self.assertNotContains(response, 'name="form-1-alive"') + # Editable fields are present. + self.assertContains(response, 'name="form-0-gender"') + self.assertContains(response, 'name="form-0-alive"') + self.assertContains(response, 'name="form-2-gender"') + self.assertContains(response, 'name="form-2-alive"') def test_list_editable_per_object_permissions_submission(self): """ @@ -5092,26 +5092,28 @@ class AdminViewListEditable(TestCase): """ self.client.logout() self.client.force_login(self.superuser) - + # Skip the instance lacking edit permission (include only its id). data = { "form-TOTAL_FORMS": "3", "form-INITIAL_FORMS": "3", "form-MAX_NUM_FORMS": "0", - "form-0-gender": "2", # Change per1 (not allowed) + "form-0-gender": "2", + "form-0-alive": "checked", "form-0-id": str(self.per1.pk), - "form-1-gender": "2", # Change per2 (allowed) - "form-1-id": str(self.per2.pk), - "form-2-gender": "2", # Change per3 (not allowed) + "form-1-id": str(self.per2.pk), # not editable + "form-2-gender": "2", + "form-2-alive": "checked", "form-2-id": str(self.per3.pk), "_save": "Save", } response = self.client.post( reverse("admin9:admin_views_person_changelist"), data, follow=True ) - # per2 and per3 were updated, but per1 was not - self.assertEqual(Person.objects.get(pk=self.per1.pk).gender, 1) # Unchanged - self.assertEqual(Person.objects.get(pk=self.per2.pk).gender, 2) - self.assertEqual(Person.objects.get(pk=self.per3.pk).gender, 1) # Unchanged + # per1 and per3 were updated, but per2 was not. + self.assertEqual(Person.objects.get(pk=self.per1.pk).gender, 2) + self.assertEqual(Person.objects.get(pk=self.per2.pk).gender, 1) # Unchanged + self.assertEqual(Person.objects.get(pk=self.per3.pk).gender, 2) + # Check for success message self.assertEqual(len(response.context["messages"]), 1) -- cgit v1.3