Skip to content

Commit 2d388f3

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
fix(api): interpolate %(verbose_name_plural)s in action labels (#204)
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: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 80dbe32 commit 2d388f3

2 files changed

Lines changed: 28 additions & 1 deletion

File tree

django_admin_react/api/views/actions.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
from typing import Any
3333

34+
from django.contrib.admin.utils import model_format_dict
3435
from django.db import transaction
3536
from django.http import HttpRequest
3637
from django.http import HttpResponse
@@ -138,9 +139,22 @@ def actions_payload(model_admin: Any, request: HttpRequest) -> list[dict[str, An
138139
confirmation step regardless — this hint is a UX optimisation.
139140
"""
140141
raw = model_admin.get_actions(request) or {}
142+
# Django's built-in `delete_selected` (and any action whose
143+
# `short_description` uses the admin's `%(verbose_name)s` /
144+
# `%(verbose_name_plural)s` placeholders) ships a *format string*,
145+
# not a finished label — Django interpolates it at render time via
146+
# `model_format_dict(opts)`. Do the same here so the SPA shows
147+
# "Delete selected files", never the raw "%(verbose_name_plural)s".
148+
fmt = model_format_dict(model_admin.model._meta)
141149
out: list[dict[str, Any]] = []
142150
for name, (_callable, _resolved_name, description) in raw.items():
143-
label = str(description) if description else name
151+
raw_label = str(description) if description else name
152+
try:
153+
label = raw_label % fmt
154+
except (KeyError, ValueError, TypeError):
155+
# Not a %-format string, or references a key we don't
156+
# provide — surface the label verbatim rather than crashing.
157+
label = raw_label
144158
requires_conf = "delete" in (label.lower() + " " + name.lower())
145159
out.append(
146160
{

tests/test_actions.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,19 @@ def test_actions_include_default_delete(superuser_client: Client) -> None:
7474
assert action["requires_confirmation"] is True
7575

7676

77+
@pytest.mark.django_db
78+
def test_delete_selected_label_is_interpolated(superuser_client: Client) -> None:
79+
"""``delete_selected``'s ``%(verbose_name_plural)s`` placeholder is
80+
interpolated with the model's plural — never shown raw to the SPA."""
81+
response = superuser_client.get(LIST_URL)
82+
delete = next(a for a in response.json()["actions"] if a["name"] == "delete_selected")
83+
# The raw Django short_description is "Delete selected
84+
# %(verbose_name_plural)s"; the SPA must receive the finished label.
85+
assert "%(" not in delete["label"]
86+
assert "verbose_name_plural" not in delete["label"]
87+
assert "users" in delete["label"].lower() # auth.User → "users"
88+
89+
7790
# --------------------------------------------------------------------------- #
7891
# §6 mandatory matrix on the runner #
7992
# --------------------------------------------------------------------------- #

0 commit comments

Comments
 (0)