Skip to content

Commit c9402ce

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 c9402ce

File tree

2 files changed

+46
-1
lines changed

2 files changed

+46
-1
lines changed

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: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,42 @@ 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(
135+
"admin:test_project_radiusaccounting_change", args=[obj.pk]
136+
)
137+
).wsgi_request
138+
self.assertFalse(modeladmin.has_delete_permission(request))
139+
140+
with self.subTest("cascade delete from unrelated URL returns True"):
141+
# Simulate being called from a parent model's delete
142+
# confirmation (cascade), not from the model's own views.
143+
request = self.client.get(
144+
reverse("admin:test_project_radiusaccounting_changelist")
145+
).wsgi_request
146+
mock_resolver = MagicMock()
147+
mock_resolver.url_name = "index"
148+
request.resolver_match = mock_resolver
149+
self.assertTrue(modeladmin.has_delete_permission(request))
150+
151+
with self.subTest("no resolver_match returns True"):
152+
request = self.client.get(
153+
reverse("admin:test_project_radiusaccounting_changelist")
154+
).wsgi_request
155+
request.resolver_match = None
156+
self.assertTrue(modeladmin.has_delete_permission(request))
157+
122158
def test_context_processor(self):
123159
url = reverse("admin:index")
124160
response = self.client.get(url)

0 commit comments

Comments
 (0)