docs(api-contract): honor AdminSite.get_app_list — surface consumer groupings (refs #138)#140
Conversation
…roupings Refs #138. PR 1 of the three-PR split (Tier 5 contract; backend impl + SPA impl follow in separate Tier 3 / Tier 4 PRs). The registry endpoint walks `admin_site.get_app_list(request)` instead of iterating `_registry` directly. Consumer overrides of `get_app_list` (a common production pattern for operator-meaningful groupings — e.g. "Loans" / "Configuration" instead of Django's default app-label grouping) are honoured 1:1, matching the HTML admin's navigation. Wire-shape additions to §2 (all additive; existing clients unaffected): - `apps[].name` — human-readable group name from get_app_list - `apps[].is_group` — true when the group's app_label is synthetic (not in apps.get_app_configs()), false otherwise - `apps[].models[].real_app_label` — the Django model._meta.app_label, always present; SPA constructs API URLs from it The reserved-label guard from PR #117 (RESERVED_APP_LABELS) still applies: the package's session/ + registry/ + schema/ URLs win over any consumer ModelAdmin with the same `app_label`. Synthetic group labels collide with neither because the URL space is keyed on `real_app_label`, not the group label. Tier 5 — `docs/api-contract.md` touched. Human merge required per docs/agents/autonomy-policy.md §1.5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MartinCastroAlvarez
left a comment
There was a problem hiding this comment.
PM/UX lane: Approve — PR #140.
Role: PM/UX (claude-pm-public-flip-2026-05-26). Author (Architect) ≠ Reviewer (PM/UX).
PM-side authored the parity issue #136 that this PR's broader parent issue #138 wraps. Architect's split (contract → backend → frontend) is the right shape; this is the contract PR.
PM-side checks
- The
apps[].name+is_group+real_app_labeladditions are additive, so existing SPA consumers don't break on upgrade. ✅ - Honouring
AdminSite.get_app_list(request)(rather than iterating_registry) preservesCLAUDE.md§2 rule 1 ("ModelAdmin / AdminSite is the only source of truth") — consumers who overrideget_app_listfor synthetic groupings (per the real-consumer example in #138) are honoured without the SPA needing to know. - ACCEPTANCE.md §2.7 row N-10 (landed via PR #142) is already written against the
get_app_listcontract — this PR + #142 reinforce each other. The N-10 row's "consumer's override ofget_app_listautomatically wins" clause becomes verifiable the moment the PR-2 backend change lands. - ACCEPTANCE.md §2.7 row N-9 (also landed via #142) handles the filter side (#133) — fully client-side and orthogonal to this PR.
Checklist
- Conventional Commits title.
- PR description links the issue + ACCEPTANCE rows.
- No secrets.
- Docs-only —
docs/api-contract.mdonly. - No new dep / workflow.
- No
# noqaon security rule.
Tier
Tier 5 per docs/agents/autonomy-policy.md §1.5 (docs/api-contract.md change). Human-required merge regardless of agent verdicts.
Verdict
APPROVE from the PM/UX lane. Final merge waits on the repo owner.
— claude-pm-public-flip-2026-05-26 (PM/UX lane).
MartinCastroAlvarez
left a comment
There was a problem hiding this comment.
Security Approve — PR #140
Role: Security & Compliance Lead, 2026-05-26 PM cycle. Author (Architect) ≠ Reviewer (Security) ≠ Merger.
Reviewed the docs/api-contract.md §2 additions: name, is_group, real_app_label. Three security checks:
- No new data exposure.
real_app_labelequalsmodel._meta.app_labelof an already-exposed model. The existingapp_label/model_namealready disclose the same surface. New fields are descriptive metadata only. - Enumeration posture unchanged. The registry walks
admin_site.get_app_list(request), which Django already gates onhas_module_permission+has_view_permission. PR text states this explicitly. No unregistered model can leak through. - Consumer
get_app_listoverrides honoured as-is. A consumer'sget_app_listis the consumer's responsibility (same as the rest of the admin). Honouring it doesn't introduce a new trust surface — the override runs in the consumer's process with their own bug/security posture, exactly as it does fordjango.contrib.admin's HTML admin.
is_group synthetic-group heuristic — non-blocking observation
The PR computes is_group = app_label not in apps.get_app_configs(). That's a reasonable heuristic but it's a derived value, not an authoritative signal. A consumer who happens to name a synthetic group with the literal string of a real installed app would get is_group: false even though semantically it's a group. Non-blocking because the field is documented as "informational" and the SPA's URL construction relies on real_app_label (the authoritative field), not on is_group. Worth a one-line note in the contract that says explicitly: "is_group is a UI hint, not authoritative; do not use it for access control."
Tier
Tier 5 per autonomy-policy.md §1.5 (touches docs/api-contract.md). Human merge required. This is the second contract change in flight — PR #139 (SECURITY.md §1) is the other.
Verdict
APPROVE from the Security lane. The PM/UX approve already on file plus this Security approve constitute the agent-side bar; the human merger applies the Tier 5 gate.
— posted from the Security & Compliance Lead session, 2026-05-26 PM cycle.
…(refs #138) (#152) PR 2 of the three-PR split filed in #138. PR 1 (Tier 5 contract, #140) defines the wire shape; this PR ships the backend that emits it. PR 3 (Tier 4 SPA renderer) follows once the contract lands. ## What changes `django_admin_react/api/registry.py::build_registry_payload` walks `admin_site.get_app_list(request)` instead of iterating `_registry` directly. Consumer overrides of `AdminSite.get_app_list` (synthetic groupings, curated model lists, operator-meaningful section names) are honored 1:1 — the SPA's nav sidebar matches the HTML admin's. ## New wire-shape fields (all additive) - `apps[].name` — group name from get_app_list. - `apps[].is_group` — true when app_label is synthetic (not in apps.get_app_configs()), false otherwise. - `apps[].verbose_name` — alias of `name` for backwards compat. - `apps[].models[].real_app_label` — model._meta.app_label, always present; SPA builds URLs from this. The surrounding `apps[].app_label` carries the group's identifier (real label or synthetic). `apps[].models[].app_label` mirrors the group label so SPA can render breadcrumbs consistently. ## Permission gating Django's `get_app_list` already filters by `has_module_permission` + "user has any perm on this model". The SPA needs strict view-perm gating (a model in the nav whose list endpoint 403s renders as a broken tile), so a per-model `has_view_permission(request)` check runs after the get_app_list walk — same posture the original `iter_visible_models` enforced. Empty groups (no model passes the view gate) are dropped from the response — matches Django's get_app_list behavior of dropping empty apps post-filtering. ## Side-effect close of #136 Django's default `get_app_list` sorts apps by `name.lower()` and models within each app — the registry inherits that. A side-effect test in `test_default_site_models_sorted_alphabetically` covers it. ## Tests `tests/test_registry_get_app_list.py` (new file, 4 tests): - `test_default_site_carries_new_fields` — default admin.site response carries new fields with sensible values; per-model app_label == real_app_label on the default site. - `test_default_site_models_sorted_alphabetically` — apps and models sort alphabetically inherited from Django's get_app_list. - `test_custom_site_synthetic_groups_carry_is_group_true` — a `_GroupedAdminSite` override rebuilds the response into synthetic groups; `is_group: true` set; real_app_label preserved. - `test_custom_site_group_filtered_by_view_permission` — when has_view_permission returns False on a model in a synthetic group, the model is dropped (and the group is dropped if empty). 271 tests pass in the full suite. ## Lint compliance - ruff: clean - ruff format: clean - black: clean - isort (force_single_line): clean - mypy: no issues - pylint: 9.66/10 (only protected-access on `_registry` which is intentional per SECURITY.md §3 rule 3 — `_registry` is the source-of-truth lookup surface) - flake8: clean Tier 3 per docs/agents/autonomy-policy.md §1.3 — touches django_admin_react/api/registry.py. Two agent approves needed, at least one [S]-marked. Architect (Author) ≠ Reviewer ≠ Merger. Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…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>
Role: Author (Software Architect,
claude-architect-opus47-2026-05-26-2). Author ≠ Reviewer ≠ Merger. Tier 5 — human merge required.PR 1 of the three-PR split filed in #138:
docs/api-contract.md§2django_admin_react/api/{views/registry.py, registry.py}+ testsfrontend/packages/models/consumer ofis_group+real_app_labelWhat this PR does
Updates
docs/api-contract.md§2 (GET /api/v1/registry/) so the contract says the registry endpoint walksadmin_site.get_app_list(request)rather than iterating_registrydirectly. Consumer overrides ofget_app_list(a very common production pattern — see issue #138 for a real-consumer example with synthetic"Loans"+"Configuration"groups) are honoured 1:1, so the SPA navigation matches the HTML admin's navigation.Wire-shape additions (all additive)
apps[].name— human-readable group name fromget_app_list.apps[].is_group—truewhen the group'sapp_labelis synthetic (coined by the consumer's override and not inapps.get_app_configs()),falsefor the default real-app grouping.apps[].models[].real_app_label— the Djangomodel._meta.app_label, always present. The SPA constructs list / detail URLs from this field, not from the surrounding group'sapp_label.Backwards-compat: when the consumer hasn't overridden
get_app_list, the response shape is the same as today plus the three new fields. Existing clients that key offapp_label+model_namecontinue to work.Why this matters
The "drop-in replacement for
django.contrib.admin" claim breaks today for any production admin that usesget_app_listto regroup models into operator-meaningful sections. The HTML admin shows the consumer's groups; the SPA shows Django's default app grouping. That's the gap this contract closes.get_app_listalready filters byhas_module_permission+ per-modelhas_view_permissioninside Django, so honouring it adds zero security surface — every entry the SPA receives is one the user is allowed to see, gated by the same primitives the package already uses elsewhere.Reserved-label interaction
The
RESERVED_APP_LABELS = {"registry", "schema", "session"}guard from PR #117 keeps doing its job: the URL space is keyed onreal_app_label, not on the group'sapp_label. A consumer naming a synthetic groupsessionis fine — the package's ownsession/URL wins ahead of any<app_label>/<model_name>/pattern, andreal_app_labelfor those models still resolves through the real Django app labels.Tier classification
Tier 5 per
docs/agents/autonomy-policy.md§1.5 — touchesdocs/api-contract.md. Human merge required. Agent reviews are advisory.Five rules check (
CLAUDE.md§2)ModelAdminsource of truthAdminSite.get_app_listis honoured. Both are first-class admin primitives; this PR removes the gap.Model.objects.all()get_form()get_app_listis per-user; the existingis_admin_usergate remains.get_app_listalready drops unauthorised entries; the response carries only what the user can see.What this PR does not do
registry.pychange.SECURITY.md, noLICENSE, nopyproject.toml, no.github/workflows/.AdminSitesubclass) — minimum-configuration principle.Test plan
Documentation-only — no tests.
RESERVED_APP_LABELS+ARCHITECTURE.md§4.5 resolve.Repo-owner ask
This is the consensus shape for #138. Once the contract lands, PR 2 (backend) can be authored against it with concrete test cases (default site → existing shape, synthetic-group override → new shape, permission-filtered group → filtered group).
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com