Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions django_admin_react/api/dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,7 @@ def build_buckets(
.annotate(_count=Count("pk"))
.order_by("_bucket")
)
return [
{"value": r["_bucket"], "count": r["_count"]}
for r in rows
if r["_bucket"] is not None
]
return [{"value": r["_bucket"], "count": r["_count"]} for r in rows if r["_bucket"] is not None]


def date_hierarchy_payload(
Expand Down
6 changes: 5 additions & 1 deletion django_admin_react/api/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

from __future__ import annotations

import logging
from typing import Any

from django.contrib.admin import SimpleListFilter
Expand All @@ -49,6 +50,8 @@

from django_admin_react.api.serializers import is_sensitive_field_name

logger = logging.getLogger(__name__)

# PM ruling (Q-PM-03): FK filters in v1 surface up to ≤ 25 options
# inline; larger target tables defer to a follow-up that combines
# list_filter with autocomplete (#59). Keep the cap explicit so a
Expand Down Expand Up @@ -273,7 +276,8 @@ def apply_filters(queryset: QuerySet, model_admin: ModelAdmin, request: HttpRequ
if filter_cls is not None and issubclass(filter_cls, SimpleListFilter):
try:
instance = filter_cls(request, request.GET.copy(), model_admin.model, model_admin)
except Exception: # pragma: no cover
except Exception: # pragma: no cover - skip a misbehaving consumer filter
logger.debug("Skipping list_filter %r: instantiation failed", entry, exc_info=True)
continue
try:
narrowed = instance.queryset(request, queryset)
Expand Down
12 changes: 3 additions & 9 deletions django_admin_react/api/inlines.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,7 @@ def _spec_for_inline(

rows: list[dict[str, Any]] = []
if can_view:
rows = _rows_for_inline(
inline, parent, fk_name, visible_fields, request
)
rows = _rows_for_inline(inline, parent, fk_name, visible_fields, request)

return {
"name": fk_name + "_set" if not hasattr(child_model, fk_name + "_set") else fk_name,
Expand Down Expand Up @@ -183,9 +181,7 @@ def _visible_inline_fields(
visible = [
name
for name in declared
if name not in excluded
and name != fk_back
and not is_sensitive_field_name(name)
if name not in excluded and name != fk_back and not is_sensitive_field_name(name)
]
return filter_sensitive(visible)

Expand Down Expand Up @@ -246,7 +242,5 @@ def _rows_for_inline(
fields_payload[name] = [serialize_fk_value(r) for r in related]
else:
fields_payload[name] = serialize_value(value, field=model_field)
rows.append(
{"pk": obj.pk, "label": label_for(obj), "fields": fields_payload}
)
rows.append({"pk": obj.pk, "label": label_for(obj), "fields": fields_payload})
return rows
3 changes: 1 addition & 2 deletions django_admin_react/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,7 @@ def _looks_like_range(value: Any) -> bool:
dependency.
"""
return all(
hasattr(value, attr)
for attr in ("lower", "upper", "lower_inc", "upper_inc", "isempty")
hasattr(value, attr) for attr in ("lower", "upper", "lower_inc", "upper_inc", "isempty")
)


Expand Down
7 changes: 2 additions & 5 deletions django_admin_react/api/views/autocomplete.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,7 @@ def get(

if not model_admin.search_fields:
return bad_request(
"The target admin does not declare search_fields; "
"autocomplete is not available."
"The target admin does not declare search_fields; " "autocomplete is not available."
)

q = (request.GET.get("q") or "").strip()
Expand All @@ -101,9 +100,7 @@ def get(

queryset = model_admin.get_queryset(request)
if q:
queryset, may_have_duplicates = model_admin.get_search_results(
request, queryset, q
)
queryset, may_have_duplicates = model_admin.get_search_results(request, queryset, q)
if may_have_duplicates:
queryset = queryset.distinct()

Expand Down
18 changes: 4 additions & 14 deletions django_admin_react/api/views/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,7 @@ def _components() -> dict[str, Any]:
"type": "array",
"items": {"$ref": "#/components/schemas/ActionSpec"},
},
"date_hierarchy": {
"$ref": "#/components/schemas/DateHierarchy"
},
"date_hierarchy": {"$ref": "#/components/schemas/DateHierarchy"},
"page": {"type": "integer"},
"page_size": {"type": "integer"},
"total": {"type": "integer"},
Expand Down Expand Up @@ -337,9 +335,7 @@ def _components() -> dict[str, Any]:
},
"fields": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/FieldDescriptor"
},
"additionalProperties": {"$ref": "#/components/schemas/FieldDescriptor"},
},
},
},
Expand All @@ -365,11 +361,7 @@ def _components() -> dict[str, Any]:
"responses": {
"Error": {
"description": "Error envelope.",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Error"}
}
},
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}},
}
},
}
Expand Down Expand Up @@ -462,9 +454,7 @@ def _ok_response(schema_ref: str) -> dict[str, Any]:
"200": {
"description": "OK.",
"content": {
"application/json": {
"schema": {"$ref": f"#/components/schemas/{schema_ref}"}
}
"application/json": {"schema": {"$ref": f"#/components/schemas/{schema_ref}"}}
},
},
"403": {"$ref": "#/components/responses/Error"},
Expand Down
21 changes: 10 additions & 11 deletions django_admin_react/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,16 @@ class SpaIndexView(View):
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
# noqa: ARG002 — args/kwargs only present to satisfy CBV signature.
admin_site = get_admin_site()
if not is_admin_user(request, admin_site=admin_site):
# Default: redirect anonymous / unauthorized users to the
# HTML login. When the consumer opts into the React login
# (``DJANGO_ADMIN_REACT["REACT_LOGIN"]``), serve the SPA
# shell instead so the React app renders its own login form
# (which POSTs to ``/api/v1/login/``). The shell holds no
# user data — bundle loader + mount/brand meta only — so
# anonymous access discloses nothing the static assets
# wouldn't, and every data API call still 403s until login.
if not dar_conf.REACT_LOGIN:
return _redirect_to_login(request)
# Default: redirect anonymous / unauthorized users to the HTML
# login. When the consumer opts into the React login
# (``DJANGO_ADMIN_REACT["REACT_LOGIN"]``), serve the SPA shell
# instead so the React app renders its own login form (which
# POSTs to ``/api/v1/login/``). The shell holds no user data —
# bundle loader + mount/brand meta only — so anonymous access
# discloses nothing the static assets wouldn't, and every data
# API call still 403s until login.
if not is_admin_user(request, admin_site=admin_site) and not dar_conf.REACT_LOGIN:
return _redirect_to_login(request)

# Force CSRF cookie so the SPA can read it before any unsafe
# method (the fetch client attaches it as ``X-CSRFToken``). Runs
Expand Down
5 changes: 1 addition & 4 deletions frontend/apps/web/src/pages/DetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,7 @@ function EditForm({ data, onCancel, onSave }: EditFormProps) {
</div>
)}
{data.fieldsets.map((fieldset, idx) => (
<Card
key={`efs-${idx}-${fieldset.title ?? 'default'}`}
title={fieldset.title ?? undefined}
>
<Card key={`efs-${idx}-${fieldset.title ?? 'default'}`} title={fieldset.title ?? undefined}>
<div className="divide-y divide-gray-100">
{fieldset.fields.map((name) => {
const field = data.fields[name];
Expand Down
50 changes: 25 additions & 25 deletions frontend/apps/web/src/pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,31 +44,31 @@ export function HomePage() {
// may be a consumer get_app_list grouping that 404s.
const routeApp = model.real_app_label || app.app_label;
return (
<Link
key={`${routeApp}.${model.model_name}`}
to={`/${routeApp}/${model.model_name}`}
className="block hover:no-underline"
>
<Card title={model.verbose_name_plural || model.model_name}>
<div className="text-xs text-gray-500">
{app.verbose_name} · {model.object_name}
</div>
<div className="mt-2 flex gap-2 text-xs">
{model.permissions.view ? (
<span className="px-2 py-0.5 rounded bg-blue-50 text-blue-700">view</span>
) : null}
{model.permissions.add ? (
<span className="px-2 py-0.5 rounded bg-green-50 text-green-700">add</span>
) : null}
{model.permissions.change ? (
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700">change</span>
) : null}
{model.permissions.delete ? (
<span className="px-2 py-0.5 rounded bg-red-50 text-red-700">delete</span>
) : null}
</div>
</Card>
</Link>
<Link
key={`${routeApp}.${model.model_name}`}
to={`/${routeApp}/${model.model_name}`}
className="block hover:no-underline"
>
<Card title={model.verbose_name_plural || model.model_name}>
<div className="text-xs text-gray-500">
{app.verbose_name} · {model.object_name}
</div>
<div className="mt-2 flex gap-2 text-xs">
{model.permissions.view ? (
<span className="px-2 py-0.5 rounded bg-blue-50 text-blue-700">view</span>
) : null}
{model.permissions.add ? (
<span className="px-2 py-0.5 rounded bg-green-50 text-green-700">add</span>
) : null}
{model.permissions.change ? (
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-700">change</span>
) : null}
{model.permissions.delete ? (
<span className="px-2 py-0.5 rounded bg-red-50 text-red-700">delete</span>
) : null}
</div>
</Card>
</Link>
);
}),
)}
Expand Down
28 changes: 13 additions & 15 deletions tests/test_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from __future__ import annotations

from contextlib import contextmanager
from contextlib import suppress

import pytest
from django.contrib import admin
Expand All @@ -43,10 +44,8 @@ def admin_attr(model_cls, **values):
finally:
for name, original in originals.items():
if original is sentinel:
try:
with suppress(AttributeError):
delattr(model_admin, name)
except AttributeError:
pass
else:
setattr(model_admin, name, original)

Expand Down Expand Up @@ -108,9 +107,7 @@ def test_staff_without_view_permission_returns_404(staff_client: Client) -> None
"""
User = get_user_model()
User.objects.create_user(username="a", password="x") # noqa: S106
with admin_override(
User, has_view_permission=lambda self, request, obj=None: False
):
with admin_override(User, has_view_permission=lambda self, request, obj=None: False):
response = staff_client.post(
ACTIONS_BASE + "delete_selected/",
data='{"pks": [1]}',
Expand Down Expand Up @@ -192,28 +189,29 @@ def test_custom_action_runs_over_narrowed_queryset(superuser_client: Client) ->
def test_action_respects_get_queryset(superuser_client: Client) -> None:
"""Action cannot reach a row the admin's get_queryset excludes."""
User = get_user_model()
visible = User.objects.create_user(username="visible", password="x", is_active=True) # noqa: S106
visible = User.objects.create_user(
username="visible", password="x", is_active=True
) # noqa: S106
hidden = User.objects.create_user(username="hidden", password="x", is_active=True) # noqa: S106

# Pin get_queryset to exclude ``hidden`` by pk.
def _qs(self, request):
return User.objects.exclude(pk=hidden.pk)

with admin_attr(User, actions=[_mark_inactive]):
with admin_override(User, get_queryset=_qs):
response = superuser_client.post(
ACTIONS_BASE + "_mark_inactive/",
data=f'{{"pks": [{visible.pk}, {hidden.pk}]}}',
content_type="application/json",
)
with admin_attr(User, actions=[_mark_inactive]), admin_override(User, get_queryset=_qs):
response = superuser_client.post(
ACTIONS_BASE + "_mark_inactive/",
data=f'{{"pks": [{visible.pk}, {hidden.pk}]}}',
content_type="application/json",
)
assert response.status_code == 200

visible.refresh_from_db()
hidden.refresh_from_db()
# The action ran on `visible`, NOT on `hidden` (despite hidden's
# pk being in the request body).
assert visible.is_active is False
assert hidden.is_active is True # The crucial assertion (Rule 10).
assert hidden.is_active is True # The crucial assertion (Rule 10).


@pytest.mark.django_db
Expand Down
13 changes: 4 additions & 9 deletions tests/test_autocomplete.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from __future__ import annotations

from contextlib import contextmanager
from contextlib import suppress

import pytest
from django.contrib import admin
Expand All @@ -41,10 +42,8 @@ def admin_attr(model_cls, **values):
finally:
for name, original in originals.items():
if original is sentinel:
try:
with suppress(AttributeError):
delattr(model_admin, name)
except AttributeError:
pass
else:
setattr(model_admin, name, original)

Expand All @@ -71,18 +70,14 @@ def test_staff_without_view_permission_returns_404(staff_client: Client) -> None
"""Same posture as the list endpoint — unviewable model is 404, not 403,
so the endpoint doesn't reveal "this model exists but you can't see it"."""
User = get_user_model()
with admin_override(
User, has_view_permission=lambda self, request, obj=None: False
):
with admin_override(User, has_view_permission=lambda self, request, obj=None: False):
response = staff_client.get(AUTOCOMPLETE_URL)
assert response.status_code == 404


@pytest.mark.django_db
def test_unregistered_model_404(superuser_client: Client) -> None:
response = superuser_client.get(
"/admin-react/api/v1/unknown/nothing/autocomplete/"
)
response = superuser_client.get("/admin-react/api/v1/unknown/nothing/autocomplete/")
assert response.status_code == 404


Expand Down
Loading
Loading