Skip to content

docs(api-contract): honor AdminSite.get_app_list — surface consumer groupings (refs #138)#140

Merged
MartinCastroAlvarez merged 1 commit into
mainfrom
docs/contract-get-app-list
May 26, 2026
Merged

docs(api-contract): honor AdminSite.get_app_list — surface consumer groupings (refs #138)#140
MartinCastroAlvarez merged 1 commit into
mainfrom
docs/contract-get-app-list

Conversation

@MartinCastroAlvarez
Copy link
Copy Markdown
Owner

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:

PR Tier Surface Status
PR 1 — this PR 5 docs/api-contract.md §2 open
PR 2 3 django_admin_react/api/{views/registry.py, registry.py} + tests to file once this lands
PR 3 4 frontend/packages/models/ consumer of is_group + real_app_label to file after PR 2

What this PR does

Updates docs/api-contract.md §2 (GET /api/v1/registry/) so the contract says the registry endpoint walks admin_site.get_app_list(request) rather than iterating _registry directly. Consumer overrides of get_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 from get_app_list.
  • apps[].is_grouptrue when the group's app_label is synthetic (coined by the consumer's override and not in apps.get_app_configs()), false for the default real-app grouping.
  • apps[].models[].real_app_label — the Django model._meta.app_label, always present. The SPA constructs list / detail URLs from this field, not from the surrounding group's app_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 off app_label + model_name continue to work.

Why this matters

The "drop-in replacement for django.contrib.admin" claim breaks today for any production admin that uses get_app_list to 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_list already filters by has_module_permission + per-model has_view_permission inside 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 on real_app_label, not on the group's app_label. A consumer naming a synthetic group session is fine — the package's own session/ URL wins ahead of any <app_label>/<model_name>/ pattern, and real_app_label for those models still resolves through the real Django app labels.

Tier classification

Tier 5 per docs/agents/autonomy-policy.md §1.5 — touches docs/api-contract.md. Human merge required. Agent reviews are advisory.

Five rules check (CLAUDE.md §2)

Rule Check
1 — ModelAdmin source of truth ✅ extended: now also AdminSite.get_app_list is honoured. Both are first-class admin primitives; this PR removes the gap.
2 — Never Model.objects.all() ✅ n/a — pure metadata.
3 — Writes via get_form() ✅ unaffected.
4 — Staff-only + CSRF get_app_list is per-user; the existing is_admin_user gate remains.
12 — No oracles ✅ permission filtering inside get_app_list already drops unauthorised entries; the response carries only what the user can see.

What this PR does not do

  • No code change. PR 2 (Tier 3, to be filed after this lands) ships the registry.py change.
  • No SPA change. PR 3 (Tier 4) consumes the new shape.
  • No SECURITY.md, no LICENSE, no pyproject.toml, no .github/workflows/.
  • No new settings key. Consumers control grouping entirely via Python code (their AdminSite subclass) — minimum-configuration principle.

Test plan

Documentation-only — no tests.

  • Markdown renders cleanly.
  • JSON examples are syntactically valid (default + synthetic-group case).
  • Cross-refs to RESERVED_APP_LABELS + ARCHITECTURE.md §4.5 resolve.
  • No banned-copy phrases / no token-shaped strings introduced.

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

…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>
Copy link
Copy Markdown
Owner Author

@MartinCastroAlvarez MartinCastroAlvarez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_label additions are additive, so existing SPA consumers don't break on upgrade. ✅
  • Honouring AdminSite.get_app_list(request) (rather than iterating _registry) preserves CLAUDE.md §2 rule 1 ("ModelAdmin / AdminSite is the only source of truth") — consumers who override get_app_list for 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_list contract — this PR + #142 reinforce each other. The N-10 row's "consumer's override of get_app_list automatically 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.md only.
  • No new dep / workflow.
  • No # noqa on 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).

Copy link
Copy Markdown
Owner Author

@MartinCastroAlvarez MartinCastroAlvarez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. No new data exposure. real_app_label equals model._meta.app_label of an already-exposed model. The existing app_label/model_name already disclose the same surface. New fields are descriptive metadata only.
  2. Enumeration posture unchanged. The registry walks admin_site.get_app_list(request), which Django already gates on has_module_permission + has_view_permission. PR text states this explicitly. No unregistered model can leak through.
  3. Consumer get_app_list overrides honoured as-is. A consumer's get_app_list is 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 for django.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.

MartinCastroAlvarez added a commit that referenced this pull request May 26, 2026
…(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>
@MartinCastroAlvarez MartinCastroAlvarez merged commit b8be316 into main May 26, 2026
@MartinCastroAlvarez MartinCastroAlvarez deleted the docs/contract-get-app-list branch May 26, 2026 19:26
MartinCastroAlvarez added a commit that referenced this pull request May 26, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants