Skip to content

Commit 7a5f2d5

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
fix(spa): route by real_app_label, not the get_app_list group label (#163)
Regression from the get_app_list honoring work (#140/#152/#158). The registry endpoint correctly emits, per model: - app_label — the *display* label (the consumer's custom get_app_list group name, e.g. "financial_ institutions"), and - real_app_label — the model's true _meta.app_label (e.g. "bank"). The backend was designed for this: real_app_label is the routing key that round-trips through resolve_model. But the SPA frontend was never updated to use it — Layout.tsx, HomePage.tsx, and the RegistryModelEntry type all still built model URLs from app_label (the group). For any consumer whose AdminSite.get_app_list returns custom groupings (very common — laminr does), every sidebar/card link pointed at /<group_label>/<model>/, which resolve_model can't resolve → 404 on every model. The SPA presented this as "Couldn't load the list / HTTP 500"-style empty/error states. Fix (frontend-only — the backend was already correct): - contract.ts — add `real_app_label` to RegistryModelEntry and `is_group?` to RegistryAppEntry, with doc comments stating that app_label is display-only and real_app_label is the routing key. - Layout.tsx + HomePage.tsx — build model links from `model.real_app_label || app.app_label` (falls back to app_label for the default ungrouped get_app_list case). DetailPage/ListPage already use the URL param (which is the real label by the time the user is on the page), so no change needed there. Backend round-trip regression test (tests/test_registry_get_app_list.py:: test_grouped_model_resolves_by_real_app_label_not_group_label): a model grouped under a synthetic "accounts" group resolves 200 at /api/v1/auth/user/ and 404 at /api/v1/accounts/user/ — codifying the contract the SPA fix depends on. Found by a full real-HTTP sweep of all 86 registered models in the live consumer pilot: after the get_app_list change, 67/86 models 404'd on navigation because the SPA routed by the group label. `pnpm --filter @dar/web typecheck` clean; 5/5 get_app_list tests pass. Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c58fdc0 commit 7a5f2d5

5 files changed

Lines changed: 90 additions & 14 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,6 @@ django_admin_react/static/admin_react/assets/
7272
django_admin_react/static/admin_react/index.html
7373

7474
.claude/
75+
76+
# Local audit run artifacts (not for VCS)
77+
.audit-*.json

frontend/apps/web/src/Layout.tsx

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,25 @@ export function Layout({ children }: PropsWithChildren) {
5050
{app.verbose_name}
5151
</div>
5252
<ul className="space-y-1">
53-
{app.models.map((model) => (
54-
<li key={`${app.app_label}.${model.model_name}`}>
55-
<Link
56-
to={`/${app.app_label}/${model.model_name}`}
57-
className="block text-sm px-2 py-1 rounded hover:bg-gray-800"
58-
>
59-
{model.verbose_name_plural || model.model_name}
60-
</Link>
61-
</li>
62-
))}
53+
{app.models.map((model) => {
54+
// Route by real_app_label — `app.app_label` may be a
55+
// consumer `get_app_list` grouping (e.g. "financial_
56+
// institutions") that does NOT round-trip through the
57+
// list/detail endpoints (`resolve_model` resolves by
58+
// the model's true `_meta.app_label`). Falls back to
59+
// `app.app_label` for the default (ungrouped) case.
60+
const routeApp = model.real_app_label || app.app_label;
61+
return (
62+
<li key={`${routeApp}.${model.model_name}`}>
63+
<Link
64+
to={`/${routeApp}/${model.model_name}`}
65+
className="block text-sm px-2 py-1 rounded hover:bg-gray-800"
66+
>
67+
{model.verbose_name_plural || model.model_name}
68+
</Link>
69+
</li>
70+
);
71+
})}
6372
</ul>
6473
</div>
6574
))}

frontend/apps/web/src/pages/HomePage.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,14 @@ export function HomePage() {
3939
</header>
4040
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
4141
{data.apps.flatMap((app) =>
42-
app.models.map((model) => (
42+
app.models.map((model) => {
43+
// Route by real_app_label (see Layout.tsx) — app.app_label
44+
// may be a consumer get_app_list grouping that 404s.
45+
const routeApp = model.real_app_label || app.app_label;
46+
return (
4347
<Link
44-
key={`${app.app_label}.${model.model_name}`}
45-
to={`/${app.app_label}/${model.model_name}`}
48+
key={`${routeApp}.${model.model_name}`}
49+
to={`/${routeApp}/${model.model_name}`}
4650
className="block hover:no-underline"
4751
>
4852
<Card title={model.verbose_name_plural || model.model_name}>
@@ -65,7 +69,8 @@ export function HomePage() {
6569
</div>
6670
</Card>
6771
</Link>
68-
)),
72+
);
73+
}),
6974
)}
7075
</div>
7176
</div>

frontend/packages/api/src/contract.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,20 @@ export interface RegistryUser {
3838
}
3939

4040
export interface RegistryModelEntry {
41+
/**
42+
* The *display* app label — equals the group label when the
43+
* consumer's `AdminSite.get_app_list` returns custom groupings.
44+
* NOT safe for routing; use `real_app_label`.
45+
*/
4146
app_label: string;
47+
/**
48+
* The model's true `_meta.app_label`. This is the only value that
49+
* round-trips through the list/detail endpoints (`resolve_model`).
50+
* Always build URLs as `<mount>/api/v1/<real_app_label>/<model_name>/`.
51+
* Falls back to `app_label` when the consumer uses the default
52+
* (ungrouped) `get_app_list`.
53+
*/
54+
real_app_label: string;
4255
model_name: string;
4356
object_name: string;
4457
verbose_name: string;
@@ -49,6 +62,11 @@ export interface RegistryModelEntry {
4962
export interface RegistryAppEntry {
5063
app_label: string;
5164
verbose_name: string;
65+
/**
66+
* True when `app_label` is a consumer-defined `get_app_list`
67+
* grouping rather than a real Django app label. Display-only.
68+
*/
69+
is_group?: boolean;
5270
models: RegistryModelEntry[];
5371
}
5472

tests/test_registry_get_app_list.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,3 +269,44 @@ def deny(self, request, obj=None) -> bool: # noqa: ARG001
269269
assert (
270270
"User" not in object_names
271271
), "User must be hidden when has_view_permission returns False"
272+
273+
274+
@pytest.mark.django_db
275+
def test_grouped_model_resolves_by_real_app_label_not_group_label(
276+
superuser_client: Client, grouped_admin_site_settings
277+
) -> None:
278+
"""The list/detail round-trip contract the SPA depends on.
279+
280+
When a custom ``get_app_list`` regroups ``auth.User`` under a
281+
synthetic group ``accounts``, the registry surfaces it with
282+
``app_label="accounts"`` (display) **and** ``real_app_label="auth"``
283+
(routing). The list endpoint must:
284+
285+
- **200** at ``/api/v1/auth/user/`` (the real app label), and
286+
- **404** at ``/api/v1/accounts/user/`` (the synthetic group label).
287+
288+
The SPA builds its sidebar / card links from ``real_app_label`` for
289+
exactly this reason. A regression here (SPA routing by the group
290+
label) 404s every model under a renamed group — which is what a
291+
real consumer using custom admin groupings hit in the pilot.
292+
"""
293+
registry = superuser_client.get(REGISTRY_URL).json()
294+
accounts_group = next(a for a in registry["apps"] if a["app_label"] == "accounts")
295+
user_entry = next(m for m in accounts_group["models"] if m["object_name"] == "User")
296+
297+
real = user_entry["real_app_label"]
298+
group = user_entry["app_label"]
299+
model_name = user_entry["model_name"]
300+
assert real == "auth"
301+
assert group == "accounts"
302+
303+
# Round-trips at the real app label.
304+
ok = superuser_client.get(f"/admin-react/api/v1/{real}/{model_name}/")
305+
assert ok.status_code == 200, "list endpoint must resolve by real_app_label"
306+
307+
# Does NOT resolve at the display group label (no oracle, clean 404).
308+
bad = superuser_client.get(f"/admin-react/api/v1/{group}/{model_name}/")
309+
assert bad.status_code == 404, (
310+
"the synthetic group label must not resolve a model — the SPA must "
311+
"route by real_app_label, never the get_app_list group label"
312+
)

0 commit comments

Comments
 (0)