Skip to content

Commit b8be316

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
docs(api-contract): honor AdminSite.get_app_list — surface consumer groupings (#140)
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: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 90a6013 commit b8be316

1 file changed

Lines changed: 76 additions & 4 deletions

File tree

docs/api-contract.md

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ permission gate for the path; specific operations check their own
3838

3939
## 2. `GET /api/v1/registry/`
4040

41+
The registry endpoint walks `admin_site.get_app_list(request)` to build
42+
the navigation tree. Django's default implementation groups by the real
43+
`app_label`; a consumer that subclasses `AdminSite` and overrides
44+
`get_app_list` to regroup or curate models (a very common pattern in
45+
production admins) has that grouping honoured by the SPA. This means
46+
the SPA navigation matches the HTML admin's navigation 1:1.
47+
4148
Response 200:
4249

4350
```json
@@ -52,11 +59,14 @@ Response 200:
5259
},
5360
"apps": [
5461
{
62+
"name": "Fintech",
5563
"app_label": "fintech",
5664
"verbose_name": "Fintech",
65+
"is_group": false,
5766
"models": [
5867
{
5968
"app_label": "fintech",
69+
"real_app_label": "fintech",
6070
"model_name": "account",
6171
"verbose_name": "account",
6272
"verbose_name_plural": "accounts",
@@ -74,15 +84,77 @@ Response 200:
7484
}
7585
```
7686

87+
When the consumer's `AdminSite.get_app_list` returns synthetic groups
88+
(e.g. `"Loans"` containing `packages.LoanPackage` + `documents.Document` +
89+
`statements.BankStatement`), the `apps[]` entry surfaces the group name
90+
and a synthetic `app_label`, while each model entry keeps the **real**
91+
`(real_app_label, model_name)` the SPA needs to construct API URLs:
92+
93+
```json
94+
{
95+
"apps": [
96+
{
97+
"name": "Loans",
98+
"app_label": "loans",
99+
"verbose_name": "Loans",
100+
"is_group": true,
101+
"models": [
102+
{
103+
"app_label": "loans",
104+
"real_app_label": "packages",
105+
"model_name": "loanpackage",
106+
"object_name": "LoanPackage",
107+
"verbose_name": "loan package",
108+
"verbose_name_plural": "loan packages",
109+
"permissions": { "view": true, "add": true, "change": true, "delete": false }
110+
}
111+
]
112+
}
113+
]
114+
}
115+
```
116+
77117
Rules:
78118

79-
- Only models registered in the configured admin site are included.
80-
- A model is included for a user only if
81-
`ModelAdmin.has_module_permission(request)` and
82-
`ModelAdmin.has_view_permission(request)` both return truthy.
119+
- The registry walks `admin_site.get_app_list(request)`. Consumer overrides
120+
of that method are honoured as-is — both the grouping shape and the
121+
per-model filtering Django already performs inside `get_app_list`
122+
(`has_module_permission` + `has_view_permission`).
123+
- Only models registered in the configured admin site appear (Django's
124+
`get_app_list` already enforces this — `_registry` is the only model
125+
source).
126+
- `app_label` on an `apps[]` entry is the group's identifier — Django's
127+
real app label when grouping is the default, or the consumer's synthetic
128+
label when `get_app_list` was overridden.
129+
- `name` is the human-readable group name from `get_app_list` (Django's
130+
default returns `apps.get_app_config(app_label).verbose_name`).
131+
- `is_group` is `true` when the entry's `app_label` does **not** appear in
132+
`apps.get_app_configs()` — i.e., the consumer coined it inside their
133+
`get_app_list` override. `false` otherwise. The SPA may use this hint to
134+
style synthetic groups differently if it wants; functionally `is_group`
135+
is informational.
136+
- Each model entry carries `real_app_label`, which is **always** the
137+
`model._meta.app_label` of the underlying Django model. The SPA builds
138+
list / detail URLs as `<mount>/api/v1/<real_app_label>/<model_name>/`.
139+
In the default (no `get_app_list` override) case, `real_app_label ==`
140+
the surrounding `app_label`; in synthetic-group cases they differ.
83141
- `mount` is the absolute URL path at which the package is mounted, so the
84142
SPA can construct links without hardcoding.
85143

144+
Backwards compatibility: when the consumer has not overridden
145+
`get_app_list`, the only shape changes from earlier `0.1.0a*` versions are
146+
the new fields (`name`, `is_group`, `real_app_label`). All existing
147+
clients that key off `app_label` + `model_name` continue to work; the
148+
new fields are additive.
149+
150+
Reserved-label note: synthetic groups whose `app_label` collides with
151+
`RESERVED_APP_LABELS` (`registry`, `schema`, `session`) are surfaced
152+
unchanged in the registry response but their per-model `real_app_label`
153+
must still resolve via the real Django app label. A consumer naming a
154+
synthetic group `session` does **not** collide with the optional session
155+
endpoints because the package's URL resolver matches `session/` to its
156+
own view before any `<app_label>/<model_name>/` pattern.
157+
86158
---
87159

88160
## 3. `GET /api/v1/{app_label}/{model_name}/`

0 commit comments

Comments
 (0)