Skip to content

Commit 1ed1ecf

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
feat(api): delete-confirmation preview endpoint — Django admin parity (#153 backend) (#164)
Adds GET /api/v1/<app>/<model>/<pk>/delete-preview/ — the cascade / protected / perms-needed preview the legacy admin's delete_view shows before a destructive delete. Without it, a single SPA Delete click can silently cascade-delete related rows the operator never saw. - `api/views/delete_preview.py` (NEW) — `DeletePreviewView`. Reuses `django.contrib.admin.utils.get_deleted_objects([obj], request, admin_site)` (the exact function delete_view uses), so cascade / protected / perms_needed match the HTML admin 1:1. Read-only: never deletes. `can_delete = not protected and not perms_needed`. - `api/urls.py` — `delete-preview/` route before the instance pattern. Gates mirror the DELETE endpoint exactly: staff → resolve_model → object via get_queryset → has_delete_permission. 404 (no oracle) on missing; 403 on lacking delete perm. Wire shape: {object, cascade:[{model,count}], protected:[...], perms_needed:[...], can_delete}. Tests: tests/test_delete_preview.py — full auth matrix + leaf-object shape + read-only assertion (preview never deletes). 7 cases. Full suite 303 → 311. Backend half of #153; the SPA confirm-modal is the frontend slot. Parity: ACCEPTANCE.md §2.9 E-16 (proposed in #153). Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7a5f2d5 commit 1ed1ecf

3 files changed

Lines changed: 190 additions & 0 deletions

File tree

django_admin_react/api/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from django_admin_react.api.views.autocomplete import AutocompleteView
2525
from django_admin_react.api.views.bulk import BulkUpdateView
2626
from django_admin_react.api.views.create import CreateView
27+
from django_admin_react.api.views.delete_preview import DeletePreviewView
2728
from django_admin_react.api.views.destroy import DestroyView
2829
from django_admin_react.api.views.detail import DetailView
2930
from django_admin_react.api.views.history import HistoryView
@@ -121,6 +122,13 @@ def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpRespons
121122
HistoryView.as_view(),
122123
name="history",
123124
),
125+
# Delete-preview sub-resource (#153) — cascade / protected preview
126+
# before the destructive DELETE. Same ordering caveat as above.
127+
path(
128+
"<str:app_label>/<str:model_name>/<str:pk>/delete-preview/",
129+
DeletePreviewView.as_view(),
130+
name="delete_preview",
131+
),
124132
path(
125133
"<str:app_label>/<str:model_name>/<str:pk>/",
126134
InstanceView.as_view(),
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""``GET /api/v1/<app>/<model>/<pk>/delete-preview/`` — cascade preview.
2+
3+
Wire contract: ``docs/api-contract.md`` §5.3 (delete preview sub-resource).
4+
5+
Django's HTML admin shows a confirmation interstitial before a delete:
6+
what cascades, what's protected, what extra permissions are needed. The
7+
SPA's Delete button should open the same preview before invoking the
8+
DELETE endpoint — otherwise a single click can silently cascade-delete
9+
related rows the operator never saw. Parity (#153).
10+
11+
Reuses ``django.contrib.admin.utils.get_deleted_objects`` — the exact
12+
function the HTML admin's ``delete_view`` uses — so the cascade,
13+
protected, and perms-needed sets match the legacy admin 1:1.
14+
15+
Hard rules (`SECURITY.md` §3):
16+
17+
- Rule 1: Staff + ``AdminSite.has_permission`` gate.
18+
- Rule 3: Model resolved through ``admin.site._registry`` (B-7).
19+
- Rule 5: Per-object ``has_delete_permission`` gate.
20+
- Rule 10: Object loaded through ``ModelAdmin.get_queryset(request)``.
21+
- CSRF: GET is safe; this endpoint never deletes — it only previews.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
from typing import Any
27+
28+
from django.contrib.admin.utils import get_deleted_objects
29+
from django.http import HttpRequest
30+
from django.http import HttpResponse
31+
from django.http import JsonResponse
32+
from django.views.generic import View
33+
34+
from django_admin_react.api.permissions import forbidden_response
35+
from django_admin_react.api.permissions import is_admin_user
36+
from django_admin_react.api.registry import get_admin_site
37+
from django_admin_react.api.registry import resolve_model
38+
from django_admin_react.api.serializers import label_for
39+
from django_admin_react.api.writes import load_object_or_none
40+
from django_admin_react.api.writes import not_found_response
41+
42+
43+
class DeletePreviewView(View):
44+
"""``GET /api/v1/<app_label>/<model_name>/<pk>/delete-preview/``."""
45+
46+
http_method_names = ["get"]
47+
48+
def get(
49+
self,
50+
request: HttpRequest,
51+
app_label: str,
52+
model_name: str,
53+
pk: str,
54+
*args: Any,
55+
**kwargs: Any,
56+
) -> HttpResponse:
57+
"""Return the cascade / protected / perms-needed preview.
58+
59+
Gates mirror the DELETE endpoint exactly (``has_delete_permission``)
60+
so the preview never reveals cascade structure for an object the
61+
user couldn't delete anyway. 404 (no oracle) on missing /
62+
unviewable; 403 on lacking delete permission.
63+
64+
This endpoint **never** mutates — it only computes the preview.
65+
The actual delete still goes through ``DELETE`` →
66+
``ModelAdmin.delete_model`` (rule 7).
67+
"""
68+
admin_site = get_admin_site()
69+
if not is_admin_user(request, admin_site=admin_site):
70+
return forbidden_response(request)
71+
72+
resolved = resolve_model(admin_site, request, app_label, model_name)
73+
if resolved is None:
74+
return not_found_response()
75+
model, model_admin = resolved
76+
77+
obj = load_object_or_none(model, model_admin, request, pk)
78+
if obj is None:
79+
return not_found_response()
80+
81+
if not model_admin.has_delete_permission(request, obj):
82+
return forbidden_response(request)
83+
84+
# Django's own delete_view machinery. Returns:
85+
# deletable — nested list of str reprs (display tree)
86+
# model_count— {verbose_name_plural: count}
87+
# perms_needed — set of verbose_names the user can't delete
88+
# protected — list of str reprs blocking the delete (PROTECT)
89+
_deletable, model_count, perms_needed, protected = get_deleted_objects(
90+
[obj], request, admin_site
91+
)
92+
93+
body = {
94+
"object": {"pk": obj.pk, "label": label_for(obj)},
95+
"cascade": [
96+
{"model": str(model_label), "count": int(count)}
97+
for model_label, count in model_count.items()
98+
],
99+
"protected": [str(p) for p in protected],
100+
"perms_needed": sorted(str(p) for p in perms_needed),
101+
# The delete proceeds only when nothing is PROTECT-blocked and
102+
# the user holds delete permission on every cascading model.
103+
"can_delete": not protected and not perms_needed,
104+
}
105+
response = JsonResponse(body, status=200)
106+
response["Cache-Control"] = "no-store"
107+
return response

tests/test_delete_preview.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Tests for ``GET /api/v1/<app>/<model>/<pk>/delete-preview/`` (#153).
2+
3+
The endpoint mirrors the legacy admin's delete-confirmation interstitial:
4+
cascade counts, protected objects, perms-needed, and a ``can_delete``
5+
verdict — without performing the delete.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import pytest
11+
from django.contrib.auth.models import Group
12+
from django.test import Client
13+
14+
from tests.helpers import admin_override
15+
16+
COLLECTION_URL = "/admin-react/api/v1/auth/group/"
17+
18+
19+
def _url(pk: int) -> str:
20+
return f"{COLLECTION_URL}{pk}/delete-preview/"
21+
22+
23+
@pytest.mark.django_db
24+
def test_anonymous_unauthorized(anon_client: Client) -> None:
25+
g = Group.objects.create(name="g")
26+
assert anon_client.get(_url(g.pk)).status_code in (302, 403)
27+
28+
29+
@pytest.mark.django_db
30+
def test_non_staff_forbidden(user_client: Client) -> None:
31+
g = Group.objects.create(name="g")
32+
assert user_client.get(_url(g.pk)).status_code == 403
33+
34+
35+
@pytest.mark.django_db
36+
def test_unregistered_model_not_found(superuser_client: Client) -> None:
37+
assert (
38+
superuser_client.get("/admin-react/api/v1/auth/nope/1/delete-preview/").status_code == 404
39+
)
40+
41+
42+
@pytest.mark.django_db
43+
def test_bogus_pk_not_found(superuser_client: Client) -> None:
44+
assert superuser_client.get(_url(999999)).status_code == 404
45+
46+
47+
@pytest.mark.django_db
48+
def test_without_delete_permission_forbidden(superuser_client: Client) -> None:
49+
g = Group.objects.create(name="g")
50+
with admin_override(Group, has_delete_permission=lambda self, request, obj=None: False):
51+
assert superuser_client.get(_url(g.pk)).status_code == 403
52+
53+
54+
@pytest.mark.django_db
55+
def test_preview_shape_for_leaf_object(superuser_client: Client) -> None:
56+
g = Group.objects.create(name="leaf")
57+
response = superuser_client.get(_url(g.pk))
58+
assert response.status_code == 200
59+
body = response.json()
60+
assert body["object"] == {"pk": g.pk, "label": "leaf"}
61+
assert isinstance(body["cascade"], list)
62+
# The object's own model appears in the cascade count.
63+
assert any("group" in c["model"].lower() for c in body["cascade"])
64+
assert body["protected"] == []
65+
assert body["perms_needed"] == []
66+
assert body["can_delete"] is True
67+
assert response["Cache-Control"] == "no-store"
68+
69+
70+
@pytest.mark.django_db
71+
def test_preview_does_not_delete(superuser_client: Client) -> None:
72+
g = Group.objects.create(name="survivor")
73+
superuser_client.get(_url(g.pk))
74+
# Preview is read-only — the object must still exist.
75+
assert Group.objects.filter(pk=g.pk).exists()

0 commit comments

Comments
 (0)