diff --git a/django_admin_react/api/views/actions.py b/django_admin_react/api/views/actions.py index df1f7d6..34c20aa 100644 --- a/django_admin_react/api/views/actions.py +++ b/django_admin_react/api/views/actions.py @@ -31,6 +31,7 @@ from typing import Any +from django.contrib.admin.utils import model_format_dict from django.db import transaction from django.http import HttpRequest from django.http import HttpResponse @@ -138,9 +139,22 @@ def actions_payload(model_admin: Any, request: HttpRequest) -> list[dict[str, An confirmation step regardless — this hint is a UX optimisation. """ raw = model_admin.get_actions(request) or {} + # Django's built-in `delete_selected` (and any action whose + # `short_description` uses the admin's `%(verbose_name)s` / + # `%(verbose_name_plural)s` placeholders) ships a *format string*, + # not a finished label — Django interpolates it at render time via + # `model_format_dict(opts)`. Do the same here so the SPA shows + # "Delete selected files", never the raw "%(verbose_name_plural)s". + fmt = model_format_dict(model_admin.model._meta) out: list[dict[str, Any]] = [] for name, (_callable, _resolved_name, description) in raw.items(): - label = str(description) if description else name + raw_label = str(description) if description else name + try: + label = raw_label % fmt + except (KeyError, ValueError, TypeError): + # Not a %-format string, or references a key we don't + # provide — surface the label verbatim rather than crashing. + label = raw_label requires_conf = "delete" in (label.lower() + " " + name.lower()) out.append( { diff --git a/tests/test_actions.py b/tests/test_actions.py index f658e1a..a8d44d0 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -74,6 +74,19 @@ def test_actions_include_default_delete(superuser_client: Client) -> None: assert action["requires_confirmation"] is True +@pytest.mark.django_db +def test_delete_selected_label_is_interpolated(superuser_client: Client) -> None: + """``delete_selected``'s ``%(verbose_name_plural)s`` placeholder is + interpolated with the model's plural — never shown raw to the SPA.""" + response = superuser_client.get(LIST_URL) + delete = next(a for a in response.json()["actions"] if a["name"] == "delete_selected") + # The raw Django short_description is "Delete selected + # %(verbose_name_plural)s"; the SPA must receive the finished label. + assert "%(" not in delete["label"] + assert "verbose_name_plural" not in delete["label"] + assert "users" in delete["label"].lower() # auth.User → "users" + + # --------------------------------------------------------------------------- # # §6 mandatory matrix on the runner # # --------------------------------------------------------------------------- #