summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorArtyom Kotovskiy <artyomkotovskiy@gmail.com>2026-04-10 00:27:14 -0400
committerJacob Walls <jacobtylerwalls@gmail.com>2026-04-22 10:13:58 -0400
commit84db026228413dda4cd195464554d51c0b208e32 (patch)
tree9d5602570a2f242a76e06c97a8bc0215a00920eb
parent512e348bb271878a1e4f1ab6ae187a22dd16222b (diff)
Fixed #15759 -- Excluded fields by per-object permissions for ModelAdmin.list_editable.
Instead of going over all objects in a queryset and filtering by user permissions, added skipping while saving the formset so there is no need to refetch objects again.
-rw-r--r--django/contrib/admin/options.py20
-rw-r--r--tests/admin_views/admin.py12
-rw-r--r--tests/admin_views/tests.py49
3 files changed, 79 insertions, 2 deletions
diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index 2d37f26de1..bb091e4c52 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -2022,10 +2022,27 @@ class ModelAdmin(BaseModelAdmin):
return queryset
return queryset.filter(pk__in=object_pks)
+ def _get_formset_with_permissions(self, request, queryset):
+ """
+ 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)
+
+ for form in formset.forms:
+ if not self.has_change_permission(request, form.instance):
+ for field_name in self.list_editable:
+ form.fields.pop(field_name, None)
+
+ return formset
+
def _save_formset(self, request, formset):
changecount = 0
with transaction.atomic(using=router.db_for_write(self.model)):
for form in formset.forms:
+ if not self.has_change_permission(request, form.instance):
+ continue
if form.has_changed():
obj = self.save_form(request, form, change=True)
if obj._state.adding:
@@ -2145,8 +2162,7 @@ class ModelAdmin(BaseModelAdmin):
# Handle GET -- construct a formset for display.
elif cl.list_editable and self.has_change_permission(request):
- FormSet = self.get_changelist_formset(request)
- cl.formset = FormSet(queryset=cl.result_list)
+ cl.formset = self._get_formset_with_permissions(request, cl.result_list)
# Build the list of media to be used by the formset.
if cl.formset:
diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py
index 10fccca1a1..5e7a055ec3 100644
--- a/tests/admin_views/admin.py
+++ b/tests/admin_views/admin.py
@@ -377,6 +377,17 @@ class ParentWithUUIDPKNoAddAdmin(admin.ModelAdmin):
return False
+class PersonNoChangePermissionsAdmin9(admin.ModelAdmin):
+ list_display = ("name", "gender", "alive")
+ list_editable = ("gender", "alive")
+ ordering = ("id",)
+
+ def has_change_permission(self, request, obj=None):
+ if obj is None:
+ return True
+ return obj.id % 2 == 0
+
+
class FooAccountAdmin(admin.StackedInline):
model = FooAccount
extra = 1
@@ -1491,6 +1502,7 @@ class ActorAdmin9(admin.ModelAdmin):
site9 = admin.AdminSite(name="admin9")
site9.register(Article, ArticleAdmin9)
site9.register(Actor, ActorAdmin9)
+site9.register(Person, PersonNoChangePermissionsAdmin9)
site10 = admin.AdminSite(name="admin10")
site10.final_catch_all_view = False
diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py
index 532f1e1ea0..bebd51b0df 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -5045,6 +5045,55 @@ class AdminViewListEditable(TestCase):
1,
)
+ def test_list_editable_per_object_permissions(self):
+ """
+ List_editable fields are stripped for objects where the user
+ lacks change permissions, and retained for objects where the user has
+ permissions.
+ """
+ self.client.logout()
+ 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"')
+
+ def test_list_editable_per_object_permissions_submission(self):
+ """
+ Form submission updates only objects where the user has
+ change permissions, ignoring changes to unauthorized objects.
+ """
+ self.client.logout()
+ self.client.force_login(self.superuser)
+
+ data = {
+ "form-TOTAL_FORMS": "3",
+ "form-INITIAL_FORMS": "3",
+ "form-MAX_NUM_FORMS": "0",
+ "form-0-gender": "2", # Change per1 (not allowed)
+ "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-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
+ # Check for success message
+ self.assertEqual(len(response.context["messages"]), 1)
+
@override_settings(ROOT_URLCONF="admin_views.urls")
class AdminSearchTest(TestCase):