Skip to content

Commit c9af1fd

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
fix(api): resolve via ModelAdmin.get_object (honour consumer overrides) (#187)
`load_object_or_none` (used by detail GET, update PATCH, bulk PATCH, delete) resolved the target via `get_queryset().get(pk=pk)`. Django's own change view resolves via `ModelAdmin.get_object(request, object_id)` — and `get_object` is a documented extension point consumers override. Django's default `get_object` *is* `get_queryset().get(...)`, so the default security posture is unchanged. But a consumer that overrides `get_object` to bypass a list-only filter (so an individual record stays openable even when hidden from the list) was not honoured: the SPA 404'd a row the legacy admin opens. Observed in the laminr pilot: `LoanPackageAdmin.get_queryset` excludes test-tenant packages (list scoping), but its `get_object` deliberately bypasses that filter for the change view. The SPA detail 404'd those packages; the legacy admin opens them. Fix: `load_object_or_none` now calls `model_admin.get_object(request, str(pk))`. The views still gate the returned object on `has_view_permission` / `has_change_permission` / `has_delete_permission`, so using `get_object` does NOT widen access — it only fixes *which object resolves*, consistent with Django. Defensive except still collapses DoesNotExist / ValidationError / ValueError / TypeError to None → 404 (never 500). Test (test_detail.py::test_detail_resolves_via_get_object_not_get_queryset): get_queryset returns none() while get_object returns the row; detail must 200. 52/52 detail+update+bulk+delete 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 b7aae8d commit c9af1fd

2 files changed

Lines changed: 59 additions & 16 deletions

File tree

django_admin_react/api/writes.py

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from typing import Any
3939

4040
from django.contrib.admin.options import ModelAdmin
41+
from django.core.exceptions import ValidationError
4142
from django.db.models import ForeignKey
4243
from django.db.models import ManyToManyField
4344
from django.db.models import Model
@@ -138,24 +139,34 @@ def load_object_or_none(
138139
request: HttpRequest,
139140
pk: Any,
140141
) -> Model | None:
141-
"""Fetch one row through the admin's queryset, or ``None`` on miss.
142-
143-
Three lookup failures collapse to ``None`` (callers convert to
144-
404, never to 500):
145-
146-
- ``DoesNotExist`` — pk valid but no matching row, or the row was
147-
filtered out by ``ModelAdmin.get_queryset(request)``.
148-
- ``ValueError`` — pk could not be parsed for the field's type
149-
(e.g., ``"abc"`` against an ``IntegerField``).
150-
- ``TypeError`` — similar parse failure for a custom pk type.
151-
152-
Centralizing this here means every "load or 404" call site has
153-
the same exception surface and the same security posture
154-
(queryset never bypassed, parse errors never leak).
142+
"""Fetch one row through ``ModelAdmin.get_object``, or ``None`` on miss.
143+
144+
Uses ``ModelAdmin.get_object(request, pk)`` — exactly what Django's
145+
own change view calls — rather than ``get_queryset().get(pk=pk)``.
146+
Django's default ``get_object`` *is* ``get_queryset().get(...)``, so
147+
the default security posture is unchanged. But a consumer that
148+
overrides ``get_object`` (a documented Django extension point) gets
149+
that override honoured here too — matching the legacy admin. A real
150+
example: an admin whose ``get_queryset`` hides rows for list
151+
performance / scoping but whose ``get_object`` deliberately bypasses
152+
that filter so an individual record is still openable. Resolving
153+
detail via ``get_queryset`` would 404 such a row even though the
154+
legacy admin opens it.
155+
156+
The view still gates the returned object on ``has_view_permission``
157+
(see ``detail.py`` / ``update.py`` / ``destroy.py``), so using
158+
``get_object`` does not widen access — it only fixes *which object
159+
resolves*, consistent with Django.
160+
161+
Failures collapse to ``None`` (callers convert to 404, never 500):
162+
``DoesNotExist`` (no row / filtered out), ``ValidationError`` /
163+
``ValueError`` / ``TypeError`` (pk unparseable for the field type).
164+
Django's stock ``get_object`` already returns ``None`` on these, but
165+
a consumer override might raise, so we stay defensive.
155166
"""
156167
try:
157-
return model_admin.get_queryset(request).get(pk=pk)
158-
except (model.DoesNotExist, ValueError, TypeError):
168+
return model_admin.get_object(request, str(pk))
169+
except (model.DoesNotExist, ValidationError, ValueError, TypeError):
159170
return None
160171

161172

tests/test_detail.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,38 @@ def _url(pk: object) -> str:
1818
return f"/admin-react/api/v1/auth/group/{pk}/"
1919

2020

21+
@pytest.mark.django_db
22+
def test_detail_resolves_via_get_object_not_get_queryset(superuser_client: Client) -> None:
23+
"""The detail view must resolve the object through
24+
``ModelAdmin.get_object`` (what Django's change view uses), not
25+
``get_queryset().get()``.
26+
27+
A consumer may override ``get_object`` to bypass a filter that
28+
``get_queryset`` applies for list-view scoping/performance — so an
29+
individual record stays openable even though it's hidden from the
30+
list. Observed in the laminr pilot: ``LoanPackageAdmin.get_queryset``
31+
excludes test-tenant packages, but its ``get_object`` deliberately
32+
bypasses that so the change view still opens them. Resolving detail
33+
via ``get_queryset`` 404'd such rows.
34+
35+
Here: ``get_queryset`` returns ``none()`` (hides everything) while
36+
``get_object`` returns the real row. The detail view must 200.
37+
"""
38+
g = Group.objects.create(name="hidden-from-list")
39+
40+
with admin_override(
41+
Group,
42+
get_queryset=lambda self, request: Group.objects.none(),
43+
get_object=lambda self, request, object_id, from_field=None: Group.objects.filter(
44+
pk=object_id
45+
).first(),
46+
):
47+
response = superuser_client.get(_url(g.pk))
48+
49+
assert response.status_code == 200, response.content
50+
assert response.json()["pk"] == g.pk
51+
52+
2153
@pytest.mark.django_db
2254
def test_detail_calls_get_form_with_change_true(superuser_client: Client) -> None:
2355
"""Regression: the detail view must call ``get_form(..., change=True)``

0 commit comments

Comments
 (0)