django-admin-react 0.2.0a6__tar.gz → 0.2.0a8__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.0a8}/PKG-INFO +78 -13
  2. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/README.md +77 -12
  3. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/filters.py +56 -2
  4. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/inlines.py +23 -1
  5. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/registry.py +6 -0
  6. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/actions.py +12 -2
  7. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/create_form.py +91 -1
  8. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/detail.py +55 -2
  9. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/list.py +72 -17
  10. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/conf.py +23 -10
  11. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/static/admin_react/.vite/manifest.json +2 -2
  12. django_admin_react-0.2.0a8/django_admin_react/static/admin_react/assets/index-BSzI7RU6.css +1 -0
  13. django_admin_react-0.2.0a8/django_admin_react/static/admin_react/assets/index-CxlHfz-w.js +8 -0
  14. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/static/admin_react/index.html +2 -2
  15. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/templates/admin_react/index.html +14 -3
  16. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/views.py +74 -3
  17. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/pyproject.toml +1 -1
  18. django_admin_react-0.2.0a6/django_admin_react/static/admin_react/assets/index-Beowap5h.css +0 -1
  19. django_admin_react-0.2.0a6/django_admin_react/static/admin_react/assets/index-CgWOpEY8.js +0 -8
  20. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/LICENSE +0 -0
  21. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/README.md +0 -0
  22. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/__init__.py +0 -0
  23. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/README.md +0 -0
  24. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/__init__.py +0 -0
  25. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/dates.py +0 -0
  26. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/inlines_write.py +0 -0
  27. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/panels.py +0 -0
  28. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/permissions.py +0 -0
  29. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/serializers.py +0 -0
  30. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/urls.py +0 -0
  31. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/README.md +0 -0
  32. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/__init__.py +0 -0
  33. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/auth.py +0 -0
  34. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/autocomplete.py +0 -0
  35. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/bulk.py +0 -0
  36. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/create.py +0 -0
  37. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/delete_preview.py +0 -0
  38. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/destroy.py +0 -0
  39. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/history.py +0 -0
  40. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/password.py +0 -0
  41. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/registry.py +0 -0
  42. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/schema.py +0 -0
  43. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/update.py +0 -0
  44. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/writes.py +0 -0
  45. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/apps.py +0 -0
  46. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/audit.py +0 -0
  47. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/pwa.py +0 -0
  48. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/templates/README.md +0 -0
  49. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/templates/admin_react/README.md +0 -0
  50. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/templates/admin_react/login.html +0 -0
  51. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/templates/admin_react/sw.js +0 -0
  52. {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/urls.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.0a8
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
@@ -142,31 +142,50 @@ All settings are optional. Defaults shown:
142
142
  ```python
143
143
  DJANGO_ADMIN_REACT = {
144
144
  "ADMIN_SITE": "django.contrib.admin.site", # dotted path to AdminSite instance
145
- "DEFAULT_PAGE_SIZE": 25,
145
+ "DEFAULT_PAGE_SIZE": 25, # fallback only; the list page size derives
146
+ # from ModelAdmin.list_per_page (Django parity).
146
147
  "MAX_PAGE_SIZE": 200,
147
148
  "ENABLE_PROFILING": False,
148
149
 
149
- # Branding — rendered server-side into the SPA shell, so the
150
- # consumer's title + favicon are present on first paint (no FOUC).
151
- "BRAND_TITLE": None, # str | None sidebar header + browser tab.
152
- "BRAND_LOGO_URL": None, # str | None used as the favicon and
153
- # the sidebar logo. Absolute URL or a
154
- # path under your STATIC_URL.
150
+ # Branding — all optional. The defaults derive from your AdminSite
151
+ # (site_header / site_title / site_logo), so if you already branded
152
+ # the HTML admin you need nothing here. Rendered server-side into the
153
+ # SPA shell, so title + favicon are present on first paint (no FOUC).
154
+ "BRAND_TITLE": None, # str | None override for BOTH brand strings.
155
+ "BRAND_LOGO_URL": None, # str | None — favicon + sidebar logo;
156
+ # falls back to AdminSite.site_logo. Absolute
157
+ # URL or a path under your STATIC_URL.
158
+ "PRIMARY_COLOR": "#2563eb", # accent for primary buttons, links, and
159
+ # active states. Hex only (validated);
160
+ # injected as the --dar-primary CSS var, so
161
+ # rebranding needs no React rebuild.
155
162
  }
156
163
  ```
157
164
 
158
165
  #### Branding (`BRAND_TITLE` + `BRAND_LOGO_URL`)
159
166
 
160
- Both default to `None`. Resolution order for the title:
167
+ Both default to `None` and **derive from your `AdminSite`**, mirroring
168
+ Django admin — so if you already customised the HTML admin's branding,
169
+ you need no settings here at all.
170
+
171
+ **Sidebar header** resolution:
161
172
 
162
173
  1. `DJANGO_ADMIN_REACT["BRAND_TITLE"]` — explicit override.
163
- 2. `<your AdminSite>.site_header` — if you already set `site_header`
164
- on a custom `AdminSite`, the SPA reuses it automatically. No need
165
- to repeat yourself.
174
+ 2. `<your AdminSite>.site_header` — reused automatically.
166
175
  3. `"Django Admin"` — last-resort fallback.
167
176
 
177
+ **Browser-tab `<title>`** resolution (Django uses `site_title` for the
178
+ tab, `site_header` for the on-page header):
179
+
180
+ 1. `DJANGO_ADMIN_REACT["BRAND_TITLE"]` — explicit override.
181
+ 2. `<your AdminSite>.site_title` — Django's tab-title source.
182
+ 3. `<your AdminSite>.site_header` — fallback.
183
+ 4. `"Django Admin"` — last-resort fallback.
184
+
168
185
  `BRAND_LOGO_URL` accepts either an absolute URL or a path the browser
169
- can resolve under your `STATIC_URL`. It is used both as the favicon
186
+ can resolve under your `STATIC_URL`. When unset, a `site_logo` attribute
187
+ on your `AdminSite` is used (Django has no logo by default, so set it as
188
+ a constant on your custom site). It is used both as the favicon
170
189
  (`<link rel="icon">` in the SPA shell) and as the small logo next to
171
190
  the brand title in the sidebar.
172
191
 
@@ -193,6 +212,52 @@ brand. No flash of the package's defaults.
193
212
  `AUTH_USER_MODEL`, custom `AUTHENTICATION_BACKENDS`, and custom
194
213
  `AdminSite.has_permission`.
195
214
 
215
+ ### Production: static files (and media for file uploads)
216
+
217
+ The wheel ships the pre-built bundle under the package's `static/` and
218
+ serves it through `{% static %}`. With `DEBUG = True`, Django's
219
+ staticfiles app serves it automatically — nothing to do. **In
220
+ production** you collect + serve static files like any Django app:
221
+
222
+ ```python
223
+ # settings.py
224
+ STATIC_URL = "/static/"
225
+ STATIC_ROOT = BASE_DIR / "staticfiles" # where collectstatic gathers files
226
+ ```
227
+
228
+ ```bash
229
+ python manage.py collectstatic --no-input
230
+ ```
231
+
232
+ Then serve `STATIC_ROOT` from your web server / CDN — or let
233
+ [WhiteNoise](https://whitenoise.readthedocs.io/) do it:
234
+
235
+ ```python
236
+ MIDDLEWARE = [
237
+ "django.middleware.security.SecurityMiddleware",
238
+ "whitenoise.middleware.WhiteNoiseMiddleware", # right after SecurityMiddleware
239
+ # ...
240
+ ]
241
+ ```
242
+
243
+ > If the SPA shell loads but its JS/CSS 404 (blank page, console errors),
244
+ > this `collectstatic` step is what's missing.
245
+
246
+ **File / image fields.** Editing `FileField` / `ImageField` needs
247
+ Django's media settings:
248
+
249
+ ```python
250
+ # settings.py
251
+ MEDIA_URL = "/media/"
252
+ MEDIA_ROOT = BASE_DIR / "media"
253
+ ```
254
+
255
+ Uploads go through your configured file storage
256
+ (`STORAGES["default"]` / `DEFAULT_FILE_STORAGE`); in production serve
257
+ `MEDIA_ROOT` from your web server or object storage as usual.
258
+
259
+ > ⚠️ **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.
260
+
196
261
  ### Running side-by-side with the legacy admin
197
262
 
198
263
  A common rollout: keep `/admin/` on the legacy HTML admin, mount the
@@ -111,31 +111,50 @@ All settings are optional. Defaults shown:
111
111
  ```python
112
112
  DJANGO_ADMIN_REACT = {
113
113
  "ADMIN_SITE": "django.contrib.admin.site", # dotted path to AdminSite instance
114
- "DEFAULT_PAGE_SIZE": 25,
114
+ "DEFAULT_PAGE_SIZE": 25, # fallback only; the list page size derives
115
+ # from ModelAdmin.list_per_page (Django parity).
115
116
  "MAX_PAGE_SIZE": 200,
116
117
  "ENABLE_PROFILING": False,
117
118
 
118
- # Branding — rendered server-side into the SPA shell, so the
119
- # consumer's title + favicon are present on first paint (no FOUC).
120
- "BRAND_TITLE": None, # str | None sidebar header + browser tab.
121
- "BRAND_LOGO_URL": None, # str | None used as the favicon and
122
- # the sidebar logo. Absolute URL or a
123
- # path under your STATIC_URL.
119
+ # Branding — all optional. The defaults derive from your AdminSite
120
+ # (site_header / site_title / site_logo), so if you already branded
121
+ # the HTML admin you need nothing here. Rendered server-side into the
122
+ # SPA shell, so title + favicon are present on first paint (no FOUC).
123
+ "BRAND_TITLE": None, # str | None override for BOTH brand strings.
124
+ "BRAND_LOGO_URL": None, # str | None — favicon + sidebar logo;
125
+ # falls back to AdminSite.site_logo. Absolute
126
+ # URL or a path under your STATIC_URL.
127
+ "PRIMARY_COLOR": "#2563eb", # accent for primary buttons, links, and
128
+ # active states. Hex only (validated);
129
+ # injected as the --dar-primary CSS var, so
130
+ # rebranding needs no React rebuild.
124
131
  }
125
132
  ```
126
133
 
127
134
  #### Branding (`BRAND_TITLE` + `BRAND_LOGO_URL`)
128
135
 
129
- Both default to `None`. Resolution order for the title:
136
+ Both default to `None` and **derive from your `AdminSite`**, mirroring
137
+ Django admin — so if you already customised the HTML admin's branding,
138
+ you need no settings here at all.
139
+
140
+ **Sidebar header** resolution:
130
141
 
131
142
  1. `DJANGO_ADMIN_REACT["BRAND_TITLE"]` — explicit override.
132
- 2. `<your AdminSite>.site_header` — if you already set `site_header`
133
- on a custom `AdminSite`, the SPA reuses it automatically. No need
134
- to repeat yourself.
143
+ 2. `<your AdminSite>.site_header` — reused automatically.
135
144
  3. `"Django Admin"` — last-resort fallback.
136
145
 
146
+ **Browser-tab `<title>`** resolution (Django uses `site_title` for the
147
+ tab, `site_header` for the on-page header):
148
+
149
+ 1. `DJANGO_ADMIN_REACT["BRAND_TITLE"]` — explicit override.
150
+ 2. `<your AdminSite>.site_title` — Django's tab-title source.
151
+ 3. `<your AdminSite>.site_header` — fallback.
152
+ 4. `"Django Admin"` — last-resort fallback.
153
+
137
154
  `BRAND_LOGO_URL` accepts either an absolute URL or a path the browser
138
- can resolve under your `STATIC_URL`. It is used both as the favicon
155
+ can resolve under your `STATIC_URL`. When unset, a `site_logo` attribute
156
+ on your `AdminSite` is used (Django has no logo by default, so set it as
157
+ a constant on your custom site). It is used both as the favicon
139
158
  (`<link rel="icon">` in the SPA shell) and as the small logo next to
140
159
  the brand title in the sidebar.
141
160
 
@@ -162,6 +181,52 @@ brand. No flash of the package's defaults.
162
181
  `AUTH_USER_MODEL`, custom `AUTHENTICATION_BACKENDS`, and custom
163
182
  `AdminSite.has_permission`.
164
183
 
184
+ ### Production: static files (and media for file uploads)
185
+
186
+ The wheel ships the pre-built bundle under the package's `static/` and
187
+ serves it through `{% static %}`. With `DEBUG = True`, Django's
188
+ staticfiles app serves it automatically — nothing to do. **In
189
+ production** you collect + serve static files like any Django app:
190
+
191
+ ```python
192
+ # settings.py
193
+ STATIC_URL = "/static/"
194
+ STATIC_ROOT = BASE_DIR / "staticfiles" # where collectstatic gathers files
195
+ ```
196
+
197
+ ```bash
198
+ python manage.py collectstatic --no-input
199
+ ```
200
+
201
+ Then serve `STATIC_ROOT` from your web server / CDN — or let
202
+ [WhiteNoise](https://whitenoise.readthedocs.io/) do it:
203
+
204
+ ```python
205
+ MIDDLEWARE = [
206
+ "django.middleware.security.SecurityMiddleware",
207
+ "whitenoise.middleware.WhiteNoiseMiddleware", # right after SecurityMiddleware
208
+ # ...
209
+ ]
210
+ ```
211
+
212
+ > If the SPA shell loads but its JS/CSS 404 (blank page, console errors),
213
+ > this `collectstatic` step is what's missing.
214
+
215
+ **File / image fields.** Editing `FileField` / `ImageField` needs
216
+ Django's media settings:
217
+
218
+ ```python
219
+ # settings.py
220
+ MEDIA_URL = "/media/"
221
+ MEDIA_ROOT = BASE_DIR / "media"
222
+ ```
223
+
224
+ Uploads go through your configured file storage
225
+ (`STORAGES["default"]` / `DEFAULT_FILE_STORAGE`); in production serve
226
+ `MEDIA_ROOT` from your web server or object storage as usual.
227
+
228
+ > ⚠️ **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.
229
+
165
230
  ### Running side-by-side with the legacy admin
166
231
 
167
232
  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):
@@ -26,6 +26,7 @@ from typing import Any
26
26
 
27
27
  from django.contrib.admin.options import InlineModelAdmin
28
28
  from django.contrib.admin.options import ModelAdmin
29
+ from django.contrib.admin.options import TabularInline
29
30
  from django.contrib.admin.utils import label_for_field
30
31
  from django.contrib.admin.utils import lookup_field
31
32
  from django.db.models import ForeignKey
@@ -120,6 +121,21 @@ def _show_change_link_allowed(
120
121
  return bool(target_admin.has_view_permission(request))
121
122
 
122
123
 
124
+ def _inline_kind(inline: InlineModelAdmin) -> str:
125
+ """Tabular vs stacked layout hint for the SPA.
126
+
127
+ Classified by the inline's **base class** (``admin.TabularInline``),
128
+ not its subclass *name*. The previous ``"Tabular" in
129
+ type(inline).__name__`` check mis-classified the common real-world
130
+ ``class BookInline(admin.TabularInline)`` (no "Tabular" in the name)
131
+ as stacked, so a tabular inline rendered as a card list (#417).
132
+ ``StackedInline`` — and a bare ``InlineModelAdmin`` — fall through to
133
+ the stacked layout, matching Django's own template selection
134
+ (``TabularInline`` is the only base that renders a table).
135
+ """
136
+ return "tabular" if isinstance(inline, TabularInline) else "stacked"
137
+
138
+
123
139
  def _spec_for_inline(
124
140
  inline: InlineModelAdmin,
125
141
  parent: Model,
@@ -146,7 +162,7 @@ def _spec_for_inline(
146
162
  can_change = bool(inline.has_change_permission(request, parent))
147
163
  can_delete = bool(inline.has_delete_permission(request, parent))
148
164
 
149
- kind = "tabular" if "Tabular" in type(inline).__name__ else "stacked"
165
+ kind = _inline_kind(inline)
150
166
 
151
167
  visible_fields = _visible_inline_fields(inline, parent, request)
152
168
  fields_meta = _fields_meta(inline, child_model, visible_fields, request)
@@ -160,6 +176,12 @@ def _spec_for_inline(
160
176
  "label": str(meta.verbose_name_plural),
161
177
  "kind": kind,
162
178
  "fk_name": fk_name,
179
+ # The child's pk field name (#418): when the pk is an explicit,
180
+ # non-auto field (e.g. a UUIDField) it shows up as an inline
181
+ # column, and the SPA must render it without ellipsis — the row's
182
+ # identity must stay fully readable/copyable (mirrors the list
183
+ # ``pk_field`` / #360).
184
+ "pk_field": meta.pk.name,
163
185
  "child": {"app_label": meta.app_label, "model_name": meta.model_name},
164
186
  "extra": int(getattr(inline, "extra", 0)),
165
187
  "min_num": getattr(inline, "min_num", None),
@@ -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,
@@ -26,6 +26,8 @@ from django.db.models import FileField
26
26
  from django.db.models import ForeignKey
27
27
  from django.db.models import ManyToManyField
28
28
  from django.db.models import Model
29
+ from django.forms.widgets import Textarea
30
+ from django.forms.widgets import TextInput
29
31
  from django.http import HttpRequest
30
32
  from django.http import HttpResponse
31
33
  from django.http import JsonResponse
@@ -131,6 +133,11 @@ def _build_payload(
131
133
  "fields": _fields_payload(model, model_admin, obj, request, visible_names, admin_site),
132
134
  "inlines": inlines_payload(model_admin, obj, request, admin_site),
133
135
  "view_on_site_url": _view_on_site_url(model_admin, obj),
136
+ # empty_value_display (#251): the admin's configured placeholder for
137
+ # empty/null values (ModelAdmin override → AdminSite default "-"), so
138
+ # the SPA renders it instead of a hardcoded em-dash. ``str()`` keeps
139
+ # it a plain string on the wire (it's a SafeString in Django).
140
+ "empty_value_display": str(model_admin.get_empty_value_display()),
134
141
  }
135
142
 
136
143
 
@@ -207,7 +214,9 @@ def _fieldsets_payload(
207
214
  except Exception:
208
215
  raw = ()
209
216
  if not raw:
210
- return [{"title": None, "fields": visible_names, "field_rows": [[n] for n in visible_names]}]
217
+ return [
218
+ {"title": None, "fields": visible_names, "field_rows": [[n] for n in visible_names]}
219
+ ]
211
220
 
212
221
  visible_set = set(visible_names)
213
222
  payload: list[dict[str, Any]] = []
@@ -333,7 +342,7 @@ def _descriptor_for(
333
342
  form_field.help_text if form_field is not None else ""
334
343
  )
335
344
 
336
- return field_metadata(
345
+ descriptor = field_metadata(
337
346
  model_field,
338
347
  label=_field_label(model_admin, model, name),
339
348
  required=required,
@@ -341,6 +350,50 @@ def _descriptor_for(
341
350
  help_text=str(help_text),
342
351
  value=value,
343
352
  )
353
+ # radio_fields (#251): when the admin lists this choice/FK field in
354
+ # ``radio_fields``, hint the SPA to render radios instead of a select.
355
+ # Presentational only — no permission/value change.
356
+ if name in (getattr(model_admin, "radio_fields", None) or {}):
357
+ descriptor["widget"] = "radio"
358
+ # raw_id_fields (#251): FK/M2M fields the admin lists here render as a
359
+ # pk input + lookup instead of a full select (for high-cardinality
360
+ # relations). ``elif`` so ``radio_fields`` wins if a field is in both.
361
+ elif name in (getattr(model_admin, "raw_id_fields", None) or ()):
362
+ descriptor["widget"] = "raw_id"
363
+ # formfield_overrides (#446): the bound form field's widget already
364
+ # reflects the admin's ``formfield_overrides`` /
365
+ # ``formfield_for_dbfield`` — Django applied them in ``get_form``.
366
+ # Honour the one override the SPA can act on with the existing type
367
+ # vocabulary: a single-line string promoted to a ``Textarea`` becomes
368
+ # the multi-line ``text`` type (rendered as a ``<textarea>``), and a
369
+ # multi-line ``text`` forced to a single-line ``TextInput`` collapses
370
+ # back to ``string``. Other widget overrides (date pickers, FK
371
+ # autocomplete) the SPA already renders from the field type. Choice
372
+ # fields are untouched — their ``choice`` type wins above.
373
+ _apply_widget_override(descriptor, form_field)
374
+ return descriptor
375
+
376
+
377
+ def _apply_widget_override(descriptor: dict[str, Any], form_field: Any) -> None:
378
+ """Reconcile the descriptor type with the bound form field's widget.
379
+
380
+ Reuses the form widget (source of truth) so ``formfield_overrides``
381
+ has a visible effect, mapping only to the existing ``string`` /
382
+ ``text`` vocabulary so no new wire type is introduced (#446).
383
+ """
384
+ if form_field is None:
385
+ return
386
+ widget = getattr(form_field, "widget", None)
387
+ if widget is None:
388
+ return
389
+ if descriptor["type"] == "string" and isinstance(widget, Textarea):
390
+ descriptor["type"] = "text"
391
+ elif (
392
+ descriptor["type"] == "text"
393
+ and isinstance(widget, TextInput)
394
+ and not isinstance(widget, Textarea)
395
+ ):
396
+ descriptor["type"] = "string"
344
397
 
345
398
 
346
399
  def _readonly_callable_descriptor(