From fe538188d5ee478573bd8f4a33084ae49ec97680 Mon Sep 17 00:00:00 2001 From: Martin Castro Laminrs Date: Tue, 26 May 2026 23:54:27 +0200 Subject: [PATCH] fix(api): interpolate %(verbose_name_plural)s in action labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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* that Django interpolates at render time via `model_format_dict(opts)`. The SPA's `actions_payload` surfaced it raw, so the Actions dropdown showed "Delete selected %(verbose_name_plural)s" instead of "Delete selected files". `actions_payload` now runs the label through `raw_label % fmt` where `fmt = model_format_dict(model._meta)`, with a try/except that surfaces the label verbatim for non-format strings / unknown keys (never 500s). Test: `test_delete_selected_label_is_interpolated` — the delete_selected label contains no `%(` placeholder and includes the model plural. Repo-owner report ("what is verbose names?" — the raw placeholder leaked to the UI). Co-Authored-By: Claude Opus 4.7 (1M context) --- django_admin_react/api/views/actions.py | 16 +++++++++++++++- tests/test_actions.py | 13 +++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) 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 # # --------------------------------------------------------------------------- #