Skip to content

Commit c640a98

Browse files
committed
[fix] Allow cascade deletions in ReadOnlyAdmin
ReadOnlyAdmin.has_delete_permission previously returned False unconditionally, which blocked cascade deletions from parent models (like Organization). Updated the method to check request.resolver_match.url_name. It now correctly blocks direct deletes on the model's own views while delegating to the standard Django permission check for cascade deletes.
1 parent 782c8d9 commit c640a98

2 files changed

Lines changed: 44 additions & 1 deletion

File tree

openwisp_utils/admin.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,16 @@ def has_add_permission(self, request):
3434
return False
3535

3636
def has_delete_permission(self, request, obj=None):
37-
return False
37+
opts = self.model._meta
38+
resolver_match = getattr(request, "resolver_match", None)
39+
url_name = getattr(resolver_match, "url_name", None)
40+
if url_name and url_name in (
41+
f"{opts.app_label}_{opts.model_name}_delete",
42+
f"{opts.app_label}_{opts.model_name}_change",
43+
f"{opts.app_label}_{opts.model_name}_changelist",
44+
):
45+
return False
46+
return super().has_delete_permission(request, obj)
3847

3948
def save_model(self, request, obj, form, change): # pragma: nocover
4049
pass

tests/test_project/tests/test_admin.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,40 @@ class TestReadOnlyAdmin(ReadOnlyAdmin):
119119
["id", "session_id", "username", "start_time", "stop_time"],
120120
)
121121

122+
def test_readonlyadmin_has_delete_permission(self):
123+
modeladmin = ReadOnlyAdmin(RadiusAccounting, AdminSite())
124+
125+
with self.subTest("changelist URL returns False"):
126+
request = self.client.get(
127+
reverse("admin:test_project_radiusaccounting_changelist")
128+
).wsgi_request
129+
self.assertFalse(modeladmin.has_delete_permission(request))
130+
131+
with self.subTest("change URL returns False"):
132+
obj = self._create_radius_accounting(username="test", session_id="1")
133+
request = self.client.get(
134+
reverse("admin:test_project_radiusaccounting_change", args=[obj.pk])
135+
).wsgi_request
136+
self.assertFalse(modeladmin.has_delete_permission(request))
137+
138+
with self.subTest("cascade delete from unrelated URL returns True"):
139+
# Simulate being called from a parent model's delete
140+
# confirmation (cascade), not from the model's own views.
141+
request = self.client.get(
142+
reverse("admin:test_project_radiusaccounting_changelist")
143+
).wsgi_request
144+
mock_resolver = MagicMock()
145+
mock_resolver.url_name = "index"
146+
request.resolver_match = mock_resolver
147+
self.assertTrue(modeladmin.has_delete_permission(request))
148+
149+
with self.subTest("no resolver_match returns True"):
150+
request = self.client.get(
151+
reverse("admin:test_project_radiusaccounting_changelist")
152+
).wsgi_request
153+
request.resolver_match = None
154+
self.assertTrue(modeladmin.has_delete_permission(request))
155+
122156
def test_context_processor(self):
123157
url = reverse("admin:index")
124158
response = self.client.get(url)

0 commit comments

Comments
 (0)