django-admin-react 0.2.0a6__tar.gz → 0.2.0a7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/PKG-INFO +51 -1
  2. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/README.md +50 -0
  3. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/filters.py +56 -2
  4. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/registry.py +6 -0
  5. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/actions.py +12 -2
  6. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/create_form.py +91 -1
  7. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/detail.py +20 -2
  8. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/list.py +41 -11
  9. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/static/admin_react/.vite/manifest.json +2 -2
  10. django_admin_react-0.2.0a7/django_admin_react/static/admin_react/assets/index-BK8mlqvA.js +8 -0
  11. django_admin_react-0.2.0a7/django_admin_react/static/admin_react/assets/index-BVqO3W_r.css +1 -0
  12. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/static/admin_react/index.html +2 -2
  13. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/pyproject.toml +1 -1
  14. django_admin_react-0.2.0a6/django_admin_react/static/admin_react/assets/index-Beowap5h.css +0 -1
  15. django_admin_react-0.2.0a6/django_admin_react/static/admin_react/assets/index-CgWOpEY8.js +0 -8
  16. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/LICENSE +0 -0
  17. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/README.md +0 -0
  18. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/__init__.py +0 -0
  19. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/README.md +0 -0
  20. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/__init__.py +0 -0
  21. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/dates.py +0 -0
  22. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/inlines.py +0 -0
  23. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/inlines_write.py +0 -0
  24. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/panels.py +0 -0
  25. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/permissions.py +0 -0
  26. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/serializers.py +0 -0
  27. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/urls.py +0 -0
  28. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/README.md +0 -0
  29. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/__init__.py +0 -0
  30. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/auth.py +0 -0
  31. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/autocomplete.py +0 -0
  32. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/bulk.py +0 -0
  33. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/create.py +0 -0
  34. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/delete_preview.py +0 -0
  35. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/destroy.py +0 -0
  36. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/history.py +0 -0
  37. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/password.py +0 -0
  38. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/registry.py +0 -0
  39. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/schema.py +0 -0
  40. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/update.py +0 -0
  41. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/writes.py +0 -0
  42. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/apps.py +0 -0
  43. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/audit.py +0 -0
  44. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/conf.py +0 -0
  45. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/pwa.py +0 -0
  46. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/templates/README.md +0 -0
  47. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/templates/admin_react/README.md +0 -0
  48. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/templates/admin_react/index.html +0 -0
  49. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/templates/admin_react/login.html +0 -0
  50. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/templates/admin_react/sw.js +0 -0
  51. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/urls.py +0 -0
  52. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-admin-react
3
- Version: 0.2.0a6
3
+ Version: 0.2.0a7
4
4
  Summary: A drop-in React single-page admin for Django, driven entirely by ModelAdmin.
5
5
  License: MIT
6
6
  Keywords: django,admin,react,spa,tailwind
@@ -152,6 +152,10 @@ DJANGO_ADMIN_REACT = {
152
152
  "BRAND_LOGO_URL": None, # str | None — used as the favicon and
153
153
  # the sidebar logo. Absolute URL or a
154
154
  # path under your STATIC_URL.
155
+ "PRIMARY_COLOR": "#2563eb", # accent for primary buttons, links, and
156
+ # active states. Hex only (validated);
157
+ # injected as the --dar-primary CSS var, so
158
+ # rebranding needs no React rebuild.
155
159
  }
156
160
  ```
157
161
 
@@ -193,6 +197,52 @@ brand. No flash of the package's defaults.
193
197
  `AUTH_USER_MODEL`, custom `AUTHENTICATION_BACKENDS`, and custom
194
198
  `AdminSite.has_permission`.
195
199
 
200
+ ### Production: static files (and media for file uploads)
201
+
202
+ The wheel ships the pre-built bundle under the package's `static/` and
203
+ serves it through `{% static %}`. With `DEBUG = True`, Django's
204
+ staticfiles app serves it automatically — nothing to do. **In
205
+ production** you collect + serve static files like any Django app:
206
+
207
+ ```python
208
+ # settings.py
209
+ STATIC_URL = "/static/"
210
+ STATIC_ROOT = BASE_DIR / "staticfiles" # where collectstatic gathers files
211
+ ```
212
+
213
+ ```bash
214
+ python manage.py collectstatic --no-input
215
+ ```
216
+
217
+ Then serve `STATIC_ROOT` from your web server / CDN — or let
218
+ [WhiteNoise](https://whitenoise.readthedocs.io/) do it:
219
+
220
+ ```python
221
+ MIDDLEWARE = [
222
+ "django.middleware.security.SecurityMiddleware",
223
+ "whitenoise.middleware.WhiteNoiseMiddleware", # right after SecurityMiddleware
224
+ # ...
225
+ ]
226
+ ```
227
+
228
+ > If the SPA shell loads but its JS/CSS 404 (blank page, console errors),
229
+ > this `collectstatic` step is what's missing.
230
+
231
+ **File / image fields.** Editing `FileField` / `ImageField` needs
232
+ Django's media settings:
233
+
234
+ ```python
235
+ # settings.py
236
+ MEDIA_URL = "/media/"
237
+ MEDIA_ROOT = BASE_DIR / "media"
238
+ ```
239
+
240
+ Uploads go through your configured file storage
241
+ (`STORAGES["default"]` / `DEFAULT_FILE_STORAGE`); in production serve
242
+ `MEDIA_ROOT` from your web server or object storage as usual.
243
+
244
+ > ⚠️ **Serving user-uploaded media has security implications** (access-gating, stored-file XSS). See [`SECURITY.md` §9](SECURITY.md) before exposing `MEDIA_URL` in production — `FileField`/`ImageField` are writable.
245
+
196
246
  ### Running side-by-side with the legacy admin
197
247
 
198
248
  A common rollout: keep `/admin/` on the legacy HTML admin, mount the
@@ -121,6 +121,10 @@ DJANGO_ADMIN_REACT = {
121
121
  "BRAND_LOGO_URL": None, # str | None — used as the favicon and
122
122
  # the sidebar logo. Absolute URL or a
123
123
  # path under your STATIC_URL.
124
+ "PRIMARY_COLOR": "#2563eb", # accent for primary buttons, links, and
125
+ # active states. Hex only (validated);
126
+ # injected as the --dar-primary CSS var, so
127
+ # rebranding needs no React rebuild.
124
128
  }
125
129
  ```
126
130
 
@@ -162,6 +166,52 @@ brand. No flash of the package's defaults.
162
166
  `AUTH_USER_MODEL`, custom `AUTHENTICATION_BACKENDS`, and custom
163
167
  `AdminSite.has_permission`.
164
168
 
169
+ ### Production: static files (and media for file uploads)
170
+
171
+ The wheel ships the pre-built bundle under the package's `static/` and
172
+ serves it through `{% static %}`. With `DEBUG = True`, Django's
173
+ staticfiles app serves it automatically — nothing to do. **In
174
+ production** you collect + serve static files like any Django app:
175
+
176
+ ```python
177
+ # settings.py
178
+ STATIC_URL = "/static/"
179
+ STATIC_ROOT = BASE_DIR / "staticfiles" # where collectstatic gathers files
180
+ ```
181
+
182
+ ```bash
183
+ python manage.py collectstatic --no-input
184
+ ```
185
+
186
+ Then serve `STATIC_ROOT` from your web server / CDN — or let
187
+ [WhiteNoise](https://whitenoise.readthedocs.io/) do it:
188
+
189
+ ```python
190
+ MIDDLEWARE = [
191
+ "django.middleware.security.SecurityMiddleware",
192
+ "whitenoise.middleware.WhiteNoiseMiddleware", # right after SecurityMiddleware
193
+ # ...
194
+ ]
195
+ ```
196
+
197
+ > If the SPA shell loads but its JS/CSS 404 (blank page, console errors),
198
+ > this `collectstatic` step is what's missing.
199
+
200
+ **File / image fields.** Editing `FileField` / `ImageField` needs
201
+ Django's media settings:
202
+
203
+ ```python
204
+ # settings.py
205
+ MEDIA_URL = "/media/"
206
+ MEDIA_ROOT = BASE_DIR / "media"
207
+ ```
208
+
209
+ Uploads go through your configured file storage
210
+ (`STORAGES["default"]` / `DEFAULT_FILE_STORAGE`); in production serve
211
+ `MEDIA_ROOT` from your web server or object storage as usual.
212
+
213
+ > ⚠️ **Serving user-uploaded media has security implications** (access-gating, stored-file XSS). See [`SECURITY.md` §9](SECURITY.md) before exposing `MEDIA_URL` in production — `FileField`/`ImageField` are writable.
214
+
165
215
  ### Running side-by-side with the legacy admin
166
216
 
167
217
  A common rollout: keep `/admin/` on the legacy HTML admin, mount the
@@ -92,6 +92,38 @@ def _safe_get_field(model: type[Model], name: str) -> Field | None:
92
92
  return field if isinstance(field, Field) else None
93
93
 
94
94
 
95
+ def _resolve_field_path(model: type[Model], path: str) -> Field | None:
96
+ """Resolve a ``list_filter`` entry to its leaf model ``Field``.
97
+
98
+ Handles a plain field name (``"status"``) and a **related-field path**
99
+ that spans relations (``"author__is_active"`` / ``"order__customer__country"``):
100
+ each non-final segment must be a relation we can traverse, and the
101
+ final segment is the leaf field whose *type* drives the descriptor and
102
+ whose value the ORM filters on (Django applies ``filter(path=value)``
103
+ natively). Transform lookups (``__year`` / ``__gte`` / ``__icontains``)
104
+ are not fields and resolve to ``None`` — a separate follow-up (#440).
105
+ Reverse / generic relations collapse to ``None``, like ``_safe_get_field``.
106
+ """
107
+ parts = path.split("__")
108
+ current: type[Model] = model
109
+ field: Field | None = None
110
+ for index, part in enumerate(parts):
111
+ try:
112
+ candidate = current._meta.get_field(part)
113
+ except Exception:
114
+ return None
115
+ if not isinstance(candidate, Field):
116
+ return None
117
+ field = candidate
118
+ if index < len(parts) - 1:
119
+ # Non-final segment must be a relation we can step into.
120
+ related = getattr(candidate, "related_model", None)
121
+ if related is None or isinstance(related, str):
122
+ return None
123
+ current = related
124
+ return field
125
+
126
+
95
127
  def _spec_for_boolean(field_name: str, field: Any) -> dict[str, Any]:
96
128
  return {
97
129
  "name": field_name,
@@ -169,6 +201,15 @@ def _spec_for_fk(
169
201
  payload["choices"] = [
170
202
  {"value": obj.pk, "label": label_for(obj)} for obj in base_qs[:_FK_FILTER_MAX_OPTIONS]
171
203
  ]
204
+ elif admin_site is not None:
205
+ # High-cardinality target (#282): don't inline; hint the SPA to use
206
+ # the autocomplete endpoint for this filter — but only when the
207
+ # target admin declares ``search_fields`` (autocomplete 400s
208
+ # otherwise). The endpoint is already staff-gated and runs the
209
+ # target's own ``get_search_results``; this is purely a UI hint.
210
+ target_admin = admin_site._registry.get(related)
211
+ if target_admin is not None and getattr(target_admin, "search_fields", None):
212
+ payload["autocomplete"] = True
172
213
  return payload
173
214
 
174
215
 
@@ -267,9 +308,17 @@ def filters_payload(
267
308
  if is_sensitive_field_name(field_name):
268
309
  continue
269
310
 
270
- field = _safe_get_field(model, field_name)
311
+ # Resolve a plain field OR a related-field path (#440). The
312
+ # descriptor `name` stays the full path so the SPA round-trips
313
+ # `?<path>=<value>` and the ORM filters natively.
314
+ field = _resolve_field_path(model, field_name)
271
315
  if field is None:
272
316
  continue
317
+ # Defense-in-depth: a path can end in a sensitive leaf
318
+ # (`author__password`) even when the path string itself didn't trip
319
+ # the denylist — drop it.
320
+ if is_sensitive_field_name(field.name):
321
+ continue
273
322
  if isinstance(field, BooleanField):
274
323
  out.append(_spec_for_boolean(field_name, field))
275
324
  elif isinstance(field, ForeignKey):
@@ -327,9 +376,14 @@ def apply_filters(queryset: QuerySet, model_admin: ModelAdmin, request: HttpRequ
327
376
  if raw_value is None or raw_value == "":
328
377
  continue
329
378
 
330
- field = _safe_get_field(model, field_name)
379
+ # Resolve a plain field OR a related-field path (#440); the leaf
380
+ # field's type picks the coercion below, while the full path is the
381
+ # lookup the ORM applies (`filter(author__is_active=True)`).
382
+ field = _resolve_field_path(model, field_name)
331
383
  if field is None:
332
384
  continue
385
+ if is_sensitive_field_name(field.name):
386
+ continue
333
387
 
334
388
  try:
335
389
  if isinstance(field, BooleanField):
@@ -319,6 +319,10 @@ def save_options(
319
319
  flag (default True): after a "Save as new", whether the SPA
320
320
  lands on the new object's change view (True) or the changelist
321
321
  (False).
322
+ - ``save_on_top`` — the raw ``ModelAdmin.save_on_top`` flag (default
323
+ False): when True, the SPA mirrors the save-button row at the top
324
+ of the form too, matching Django's change-form layout (#251).
325
+ Purely presentational — button visibility is unchanged.
322
326
 
323
327
  ``has_editable_inline_admin_formsets`` is **not** factored in here
324
328
  (the package's inline write-half is tracked under #54). Until that
@@ -330,6 +334,7 @@ def save_options(
330
334
  is_add = not is_change
331
335
  save_as = bool(getattr(model_admin, "save_as", False))
332
336
  save_as_continue = bool(getattr(model_admin, "save_as_continue", True))
337
+ save_on_top = bool(getattr(model_admin, "save_on_top", False))
333
338
 
334
339
  has_add = bool(model_admin.has_add_permission(request))
335
340
  has_change = bool(model_admin.has_change_permission(request, obj))
@@ -351,6 +356,7 @@ def save_options(
351
356
  "show_save_as_new": show_save_as_new,
352
357
  "save_as": save_as,
353
358
  "save_as_continue": save_as_continue,
359
+ "save_on_top": save_on_top,
354
360
  }
355
361
 
356
362
 
@@ -32,6 +32,7 @@ from __future__ import annotations
32
32
  from typing import Any
33
33
 
34
34
  from django.contrib.admin.utils import model_format_dict
35
+ from django.contrib.messages import get_messages
35
36
  from django.db import transaction
36
37
  from django.http import HttpRequest
37
38
  from django.http import HttpResponse
@@ -131,17 +132,26 @@ class ActionView(View):
131
132
  with transaction.atomic():
132
133
  result = action_callable(model_admin, request, queryset)
133
134
 
135
+ # Surface any messages the action queued via
136
+ # ``ModelAdmin.message_user`` (#442) so the SPA can toast them —
137
+ # iterating ``get_messages`` consumes them, so they don't also leak
138
+ # into the session for the next page render. ``level_tag`` is
139
+ # Django's "success" / "info" / "warning" / "error" / "debug".
140
+ messages = [
141
+ {"level": m.level_tag or "info", "message": str(m)} for m in get_messages(request)
142
+ ]
143
+
134
144
  # Django admin's action contract: the callable may return an
135
145
  # ``HttpResponse`` (typically a redirect to a confirmation
136
146
  # page) — we surface that as a JSON envelope so the SPA can
137
147
  # follow it without parsing HTML.
138
148
  if isinstance(result, HttpResponse):
139
149
  body: dict[str, Any] = {"redirect": result["Location"]} if "Location" in result else {}
140
- body.update({"executed": True, "action": action_name})
150
+ body.update({"executed": True, "action": action_name, "messages": messages})
141
151
  response = JsonResponse(body, status=200)
142
152
  else:
143
153
  response = JsonResponse(
144
- {"executed": True, "action": action_name, "pks": list(pks)},
154
+ {"executed": True, "action": action_name, "pks": list(pks), "messages": messages},
145
155
  status=200,
146
156
  )
147
157
  response["Cache-Control"] = "no-store"
@@ -19,6 +19,11 @@ from __future__ import annotations
19
19
 
20
20
  from typing import Any
21
21
 
22
+ from django.core.exceptions import ValidationError
23
+ from django.db.models import FileField
24
+ from django.db.models import ForeignKey
25
+ from django.db.models import ManyToManyField
26
+ from django.db.models import Model
22
27
  from django.http import HttpRequest
23
28
  from django.http import HttpResponse
24
29
  from django.http import JsonResponse
@@ -30,6 +35,9 @@ from django_admin_react.api.registry import get_admin_site
30
35
  from django_admin_react.api.registry import model_permissions
31
36
  from django_admin_react.api.registry import resolve_model
32
37
  from django_admin_react.api.registry import save_options
38
+ from django_admin_react.api.serializers import safe_get_field
39
+ from django_admin_react.api.serializers import serialize_fk_value
40
+ from django_admin_react.api.serializers import serialize_value
33
41
  from django_admin_react.api.views.detail import _descriptor_for
34
42
  from django_admin_react.api.views.detail import _fieldsets_payload
35
43
  from django_admin_react.api.views.detail import _visible_field_names
@@ -69,10 +77,17 @@ class AddFormView(View):
69
77
 
70
78
  visible_names = _visible_field_names(model_admin, request, None)
71
79
  readonly = set(model_admin.get_readonly_fields(request, None) or ())
80
+ # Initial overlay (#444): Django's add view seeds the form with
81
+ # ``get_changeform_initial_data(request)`` — which, by default,
82
+ # reflects ``request.GET`` so a link like ``/add/?status=open``
83
+ # (and the "save and add another" prefill) lands pre-filled, and
84
+ # which a ModelAdmin may override. Build the form with that
85
+ # initial, exactly how ``_changeform_view`` does.
86
+ initial = _changeform_initial(model_admin, request)
72
87
  # The ADD form — change=False, obj=None — exactly how Django's
73
88
  # add view constructs it (``ModelAdmin._changeform_view`` with
74
89
  # add=True passes change=False).
75
- form = model_admin.get_form(request, obj=None, change=False)()
90
+ form = model_admin.get_form(request, obj=None, change=False)(initial=initial)
76
91
 
77
92
  fields: dict[str, dict[str, Any]] = {}
78
93
  for name in visible_names:
@@ -87,6 +102,14 @@ class AddFormView(View):
87
102
  request=request,
88
103
  )
89
104
 
105
+ # Overlay the initial values onto the descriptors. Done as a
106
+ # second pass (rather than mutating ``obj``) so the shared
107
+ # descriptor builder stays untouched and a bad initial can never
108
+ # 500 the form: each value is coerced through the add-form's own
109
+ # field, so FKs resolve against the admin-scoped ``ModelChoiceField``
110
+ # queryset (rule 2) and an invalid initial is simply ignored.
111
+ _overlay_initial(fields, model, form, initial, admin_site, request)
112
+
90
113
  payload = {
91
114
  "app_label": model._meta.app_label,
92
115
  "model_name": model._meta.model_name,
@@ -110,6 +133,73 @@ class AddFormView(View):
110
133
  return response
111
134
 
112
135
 
136
+ def _changeform_initial(model_admin: Any, request: HttpRequest) -> dict[str, Any]:
137
+ """Return ``get_changeform_initial_data(request)`` as a safe dict (#444).
138
+
139
+ Django's default reads ``request.GET`` (so ``?field=value`` links
140
+ prefill) and an admin may override it to inject defaults. A buggy
141
+ override must not 500 the form, so a non-dict or a raised exception
142
+ degrades to "no prefill".
143
+ """
144
+ try:
145
+ data = model_admin.get_changeform_initial_data(request)
146
+ except Exception: # pragma: no cover — admin author error
147
+ return {}
148
+ return data if isinstance(data, dict) else {}
149
+
150
+
151
+ def _overlay_initial(
152
+ fields: dict[str, dict[str, Any]],
153
+ model: type[Model],
154
+ form: Any,
155
+ initial: dict[str, Any],
156
+ admin_site: Any,
157
+ request: HttpRequest,
158
+ ) -> None:
159
+ """Overlay add-form initial values onto the field descriptors (#444).
160
+
161
+ Only fields that are both rendered (in ``fields``) and present in the
162
+ add form are touched — an initial for an excluded/sensitive field is
163
+ ignored, since that field isn't in the payload to begin with. Each
164
+ value is coerced through the form field's ``to_python`` so it matches
165
+ exactly what Django would render into the widget:
166
+
167
+ - FK → the form's ``ModelChoiceField.queryset`` resolves the pk to an
168
+ instance (admin-scoped, rule 2), serialized as the ``{id, label}``
169
+ envelope; an unknown/invalid pk raises and is ignored (no 500).
170
+ - scalar / choice / bool / date → coerced and re-serialized in place.
171
+ - M2M / File → skipped: neither is meaningfully settable on the
172
+ unsaved add instance, and GET-param prefill of them is not a thing
173
+ Django's add view does either.
174
+
175
+ Any coercion error leaves the field's default value untouched.
176
+ """
177
+ for name, raw in initial.items():
178
+ descriptor = fields.get(name)
179
+ if descriptor is None:
180
+ continue
181
+ field = safe_get_field(model, name)
182
+ form_field = form.fields.get(name)
183
+ if field is None or form_field is None:
184
+ continue
185
+ if isinstance(field, ManyToManyField | FileField):
186
+ continue
187
+ # Coercion failures (a bad FK pk, an unparseable date) are the
188
+ # expected outcome of a hand-crafted prefill URL — narrow the
189
+ # catch to what ``to_python`` raises so a real bug still surfaces,
190
+ # and leave the field's default value in place.
191
+ try:
192
+ if isinstance(field, ForeignKey):
193
+ related = form_field.to_python(raw)
194
+ descriptor["value"] = serialize_fk_value(
195
+ related, admin_site=admin_site, request=request
196
+ )
197
+ else:
198
+ descriptor["value"] = serialize_value(form_field.to_python(raw), field=field)
199
+ except (ValidationError, ValueError, TypeError):
200
+ continue
201
+
202
+
113
203
  def _prepopulated_payload(
114
204
  model_admin: Any,
115
205
  request: HttpRequest,
@@ -131,6 +131,11 @@ def _build_payload(
131
131
  "fields": _fields_payload(model, model_admin, obj, request, visible_names, admin_site),
132
132
  "inlines": inlines_payload(model_admin, obj, request, admin_site),
133
133
  "view_on_site_url": _view_on_site_url(model_admin, obj),
134
+ # empty_value_display (#251): the admin's configured placeholder for
135
+ # empty/null values (ModelAdmin override → AdminSite default "-"), so
136
+ # the SPA renders it instead of a hardcoded em-dash. ``str()`` keeps
137
+ # it a plain string on the wire (it's a SafeString in Django).
138
+ "empty_value_display": str(model_admin.get_empty_value_display()),
134
139
  }
135
140
 
136
141
 
@@ -207,7 +212,9 @@ def _fieldsets_payload(
207
212
  except Exception:
208
213
  raw = ()
209
214
  if not raw:
210
- return [{"title": None, "fields": visible_names, "field_rows": [[n] for n in visible_names]}]
215
+ return [
216
+ {"title": None, "fields": visible_names, "field_rows": [[n] for n in visible_names]}
217
+ ]
211
218
 
212
219
  visible_set = set(visible_names)
213
220
  payload: list[dict[str, Any]] = []
@@ -333,7 +340,7 @@ def _descriptor_for(
333
340
  form_field.help_text if form_field is not None else ""
334
341
  )
335
342
 
336
- return field_metadata(
343
+ descriptor = field_metadata(
337
344
  model_field,
338
345
  label=_field_label(model_admin, model, name),
339
346
  required=required,
@@ -341,6 +348,17 @@ def _descriptor_for(
341
348
  help_text=str(help_text),
342
349
  value=value,
343
350
  )
351
+ # radio_fields (#251): when the admin lists this choice/FK field in
352
+ # ``radio_fields``, hint the SPA to render radios instead of a select.
353
+ # Presentational only — no permission/value change.
354
+ if name in (getattr(model_admin, "radio_fields", None) or {}):
355
+ descriptor["widget"] = "radio"
356
+ # raw_id_fields (#251): FK/M2M fields the admin lists here render as a
357
+ # pk input + lookup instead of a full select (for high-cardinality
358
+ # relations). ``elif`` so ``radio_fields`` wins if a field is in both.
359
+ elif name in (getattr(model_admin, "raw_id_fields", None) or ()):
360
+ descriptor["widget"] = "raw_id"
361
+ return descriptor
344
362
 
345
363
 
346
364
  def _readonly_callable_descriptor(
@@ -39,6 +39,7 @@ from django_admin_react.api.permissions import is_admin_user
39
39
  from django_admin_react.api.registry import get_admin_site
40
40
  from django_admin_react.api.registry import model_permissions
41
41
  from django_admin_react.api.registry import resolve_model
42
+ from django_admin_react.api.serializers import field_type_for
42
43
  from django_admin_react.api.serializers import label_for
43
44
  from django_admin_react.api.serializers import safe_get_field
44
45
  from django_admin_react.api.serializers import serialize_fk_value
@@ -191,7 +192,21 @@ class ListView(View):
191
192
  "pk_field": model._meta.pk.name,
192
193
  "permissions": model_permissions(model_admin, request),
193
194
  "columns": columns,
195
+ # list_display_links (#251): the column name(s) that link to the
196
+ # detail page — ``ModelAdmin.get_list_display_links`` (defaults to
197
+ # the first column; ``[]`` when the admin set
198
+ # ``list_display_links = None`` to disable linking). The SPA links
199
+ # exactly these columns. Callable list_display entries are dropped
200
+ # (only string column names round-trip).
201
+ "list_display_links": [
202
+ name
203
+ for name in (model_admin.get_list_display_links(request, list_display) or ())
204
+ if isinstance(name, str)
205
+ ],
194
206
  "search_fields": list(model_admin.search_fields or ()),
207
+ # ModelAdmin.search_help_text (#445): shown under the search box,
208
+ # matching Django's changelist. Empty string when unset.
209
+ "search_help_text": str(getattr(model_admin, "search_help_text", "") or ""),
195
210
  "filters": filters_payload(model_admin, request, admin_site=admin_site),
196
211
  "actions": actions_payload(model_admin, request),
197
212
  "page": page,
@@ -205,6 +220,10 @@ class ListView(View):
205
220
  # "Show all N" control only when ``total`` is at/below this cap,
206
221
  # matching Django's changelist (#385).
207
222
  "list_max_show_all": list_max_show_all,
223
+ # empty_value_display (#251): the admin's placeholder for empty
224
+ # cells (ModelAdmin override → AdminSite default "-"), so the SPA
225
+ # renders it instead of a hardcoded em-dash.
226
+ "empty_value_display": str(model_admin.get_empty_value_display()),
208
227
  "results": results,
209
228
  }
210
229
  date_hierarchy = date_hierarchy_payload(
@@ -299,9 +318,15 @@ def _columns_payload(
299
318
  ) -> list[dict[str, Any]]:
300
319
  """Build the ``columns[]`` payload for the list response.
301
320
 
302
- Each entry has ``{name, label, sortable, editable}``. Labels
303
- resolve through Django's ``label_for_field`` so admin-customised
304
- labels (verbose name, ``short_description``, etc.) are honored.
321
+ Each entry has ``{name, label, sortable, editable}`` plus a
322
+ ``type`` (the closed v1 field vocabulary) whenever the column maps
323
+ to a concrete model field so the SPA can format ``datetime`` /
324
+ ``date`` / ``time`` cells for display instead of dumping raw ISO
325
+ (#413). ``list_display`` callables / display methods have no field
326
+ and so carry no ``type``; the SPA falls back to the plain string.
327
+ Labels resolve through Django's ``label_for_field`` so
328
+ admin-customised labels (verbose name, ``short_description``, etc.)
329
+ are honored.
305
330
  ``editable`` is derived from ``ModelAdmin.list_editable`` — the
306
331
  SPA renders the cell as an in-place editor when ``True`` and
307
332
  submits changes via the bulk PATCH endpoint (Issue #61). The
@@ -322,14 +347,19 @@ def _columns_payload(
322
347
  label = label_for_field(name, model_admin.model, model_admin)
323
348
  except Exception: # pragma: no cover — defensive
324
349
  label = name
325
- payload.append(
326
- {
327
- "name": name,
328
- "label": str(label),
329
- "sortable": name in sortable,
330
- "editable": name in editable,
331
- }
332
- )
350
+ entry: dict[str, Any] = {
351
+ "name": name,
352
+ "label": str(label),
353
+ "sortable": name in sortable,
354
+ "editable": name in editable,
355
+ }
356
+ # Only concrete model fields carry a type; a ``list_display``
357
+ # callable / display method resolves to ``None`` and the key is
358
+ # omitted (the SPA then renders the value as a plain string).
359
+ field = safe_get_field(model_admin.model, name) if isinstance(name, str) else None
360
+ if field is not None:
361
+ entry["type"] = field_type_for(field)
362
+ payload.append(entry)
333
363
  return payload
334
364
 
335
365
 
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "index.html": {
3
- "file": "assets/index-CgWOpEY8.js",
3
+ "file": "assets/index-BK8mlqvA.js",
4
4
  "name": "index",
5
5
  "src": "index.html",
6
6
  "isEntry": true,
7
7
  "css": [
8
- "assets/index-Beowap5h.css"
8
+ "assets/index-BVqO3W_r.css"
9
9
  ]
10
10
  }
11
11
  }