django-admin-react 0.2.0a5__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 (54) hide show
  1. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/PKG-INFO +51 -1
  2. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/README.md +50 -0
  3. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/filters.py +85 -6
  4. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/inlines.py +28 -0
  5. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/inlines_write.py +15 -0
  6. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/registry.py +6 -0
  7. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/actions.py +12 -2
  8. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/bulk.py +37 -4
  9. django_admin_react-0.2.0a7/django_admin_react/api/views/create.py +213 -0
  10. django_admin_react-0.2.0a7/django_admin_react/api/views/create_form.py +229 -0
  11. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/detail.py +39 -9
  12. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/list.py +106 -13
  13. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/password.py +5 -1
  14. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/schema.py +20 -0
  15. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/update.py +80 -31
  16. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/writes.py +27 -0
  17. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/conf.py +9 -0
  18. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/static/admin_react/.vite/manifest.json +2 -2
  19. django_admin_react-0.2.0a7/django_admin_react/static/admin_react/assets/index-BK8mlqvA.js +8 -0
  20. django_admin_react-0.2.0a7/django_admin_react/static/admin_react/assets/index-BVqO3W_r.css +1 -0
  21. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/static/admin_react/index.html +2 -2
  22. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/templates/admin_react/index.html +8 -0
  23. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/views.py +23 -0
  24. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/pyproject.toml +1 -1
  25. django_admin_react-0.2.0a5/django_admin_react/api/views/create.py +0 -143
  26. django_admin_react-0.2.0a5/django_admin_react/api/views/create_form.py +0 -102
  27. django_admin_react-0.2.0a5/django_admin_react/static/admin_react/assets/index-BncyUUo8.js +0 -8
  28. django_admin_react-0.2.0a5/django_admin_react/static/admin_react/assets/index-Bt-X3hQW.css +0 -1
  29. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/LICENSE +0 -0
  30. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/README.md +0 -0
  31. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/__init__.py +0 -0
  32. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/README.md +0 -0
  33. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/__init__.py +0 -0
  34. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/dates.py +0 -0
  35. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/panels.py +0 -0
  36. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/permissions.py +0 -0
  37. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/serializers.py +0 -0
  38. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/urls.py +0 -0
  39. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/README.md +0 -0
  40. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/__init__.py +0 -0
  41. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/auth.py +0 -0
  42. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/autocomplete.py +0 -0
  43. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/delete_preview.py +0 -0
  44. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/destroy.py +0 -0
  45. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/history.py +0 -0
  46. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/registry.py +0 -0
  47. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/apps.py +0 -0
  48. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/audit.py +0 -0
  49. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/pwa.py +0 -0
  50. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/templates/README.md +0 -0
  51. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/templates/admin_react/README.md +0 -0
  52. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/templates/admin_react/login.html +0 -0
  53. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/templates/admin_react/sw.js +0 -0
  54. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/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.0a5
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,
@@ -144,18 +176,40 @@ def _spec_for_fk(
144
176
  "to": {"app_label": meta.app_label, "model_name": meta.model_name},
145
177
  }
146
178
  # Inline up to _FK_FILTER_MAX_OPTIONS choices for tiny tables;
147
- # larger tables defer to the autocomplete endpoint (#59).
179
+ # larger tables defer to the autocomplete endpoint (#59). Respect the
180
+ # FK's ``limit_choices_to`` so the offered options match Django's
181
+ # RelatedFieldListFilter, whose choices come from
182
+ # ``complex_filter(limit_choices_to)`` — a FK declared with, e.g.,
183
+ # ``limit_choices_to={"is_active": True}`` must not offer the rows it
184
+ # excludes (#273). An unset / empty / callable-returning-empty limit
185
+ # is falsy, so the unfiltered manager is used unchanged (and we never
186
+ # call ``complex_filter(None)``, which would raise).
187
+ base_qs = related._default_manager.all()
188
+ limit = field.get_limit_choices_to()
189
+ if limit:
190
+ try:
191
+ base_qs = related._default_manager.complex_filter(limit)
192
+ except Exception:
193
+ base_qs = related._default_manager.all()
148
194
  try:
149
- count = related._default_manager.count()
195
+ count = base_qs.count()
150
196
  except Exception:
151
197
  count = _FK_FILTER_MAX_OPTIONS + 1
152
198
  if count <= _FK_FILTER_MAX_OPTIONS:
153
199
  from django_admin_react.api.serializers import label_for
154
200
 
155
201
  payload["choices"] = [
156
- {"value": obj.pk, "label": label_for(obj)}
157
- for obj in related._default_manager.all()[:_FK_FILTER_MAX_OPTIONS]
202
+ {"value": obj.pk, "label": label_for(obj)} for obj in base_qs[:_FK_FILTER_MAX_OPTIONS]
158
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
159
213
  return payload
160
214
 
161
215
 
@@ -179,10 +233,22 @@ def _spec_for_simple_filter(
179
233
  lookups = list(instance.lookups(request, model_admin) or [])
180
234
  except Exception: # pragma: no cover — admin author error
181
235
  lookups = []
236
+ # The lookup the filter is currently applying — Django's
237
+ # ``SimpleListFilter.value()``. Crucially this includes a *default*
238
+ # the filter applies when no querystring param is present (a common
239
+ # "exclude test tenants unless opted in" pattern): such a filter
240
+ # returns its default from ``value()``, so the SPA can reflect the
241
+ # default as selected instead of showing "All" while the backend
242
+ # silently narrows the rows (#283). ``None`` means no selection.
243
+ try:
244
+ selected = instance.value()
245
+ except Exception: # pragma: no cover — admin author error
246
+ selected = None
182
247
  return {
183
248
  "name": instance.parameter_name,
184
249
  "label": str(getattr(instance, "title", "") or instance.parameter_name),
185
250
  "type": "custom",
251
+ "selected": selected,
186
252
  "lookups": [{"value": v, "label": str(lbl)} for v, lbl in lookups],
187
253
  }
188
254
 
@@ -242,9 +308,17 @@ def filters_payload(
242
308
  if is_sensitive_field_name(field_name):
243
309
  continue
244
310
 
245
- 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)
246
315
  if field is None:
247
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
248
322
  if isinstance(field, BooleanField):
249
323
  out.append(_spec_for_boolean(field_name, field))
250
324
  elif isinstance(field, ForeignKey):
@@ -302,9 +376,14 @@ def apply_filters(queryset: QuerySet, model_admin: ModelAdmin, request: HttpRequ
302
376
  if raw_value is None or raw_value == "":
303
377
  continue
304
378
 
305
- 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)
306
383
  if field is None:
307
384
  continue
385
+ if is_sensitive_field_name(field.name):
386
+ continue
308
387
 
309
388
  try:
310
389
  if isinstance(field, BooleanField):
@@ -100,6 +100,26 @@ def _get_inline_instances(
100
100
  return []
101
101
 
102
102
 
103
+ def _show_change_link_allowed(
104
+ admin_site: Any, child_model: type[Model], request: HttpRequest
105
+ ) -> bool:
106
+ """Whether to advertise an inline row's link to the child's change page.
107
+
108
+ Mirrors ``serialize_fk_value``'s ``to`` gate (#301): only when the child
109
+ model is registered on this admin site **and** the requesting user has
110
+ ``has_view_permission`` for it. Registration alone isn't enough — without
111
+ the per-user check the SPA would render a link the detail endpoint 404s
112
+ on and leak the adjacency / identity of a model the user can't view
113
+ (extends the #89 registry guard to a per-user check).
114
+ """
115
+ if admin_site is None:
116
+ return False
117
+ target_admin = getattr(admin_site, "_registry", {}).get(child_model)
118
+ if target_admin is None:
119
+ return False
120
+ return bool(target_admin.has_view_permission(request))
121
+
122
+
103
123
  def _spec_for_inline(
104
124
  inline: InlineModelAdmin,
105
125
  parent: Model,
@@ -148,6 +168,14 @@ def _spec_for_inline(
148
168
  "can_add": can_add,
149
169
  "can_change": can_change,
150
170
  "can_delete": can_delete,
171
+ # InlineModelAdmin.show_change_link (#384) — when True, the SPA
172
+ # renders a per-row link to the child's own change page. Gated on
173
+ # the child being registered **and** the user's per-model
174
+ # has_view_permission (#301 least-disclosure, same gate as
175
+ # serialize_fk_value's `to`): never advertise a link the detail
176
+ # endpoint would 404 on, never leak adjacency to an unviewable model.
177
+ "show_change_link": bool(getattr(inline, "show_change_link", False))
178
+ and _show_change_link_allowed(admin_site, child_model, request),
151
179
  "fields": fields_meta,
152
180
  "rows": rows,
153
181
  }
@@ -69,6 +69,21 @@ class InlinePermissionDenied(Exception):
69
69
  self.state = state
70
70
 
71
71
 
72
+ class InlineValidationError(Exception):
73
+ """Carries inline formset errors out of the caller's ``atomic()`` block.
74
+
75
+ Raised so the transaction unwinds (reverting the parent write), then
76
+ caught immediately outside the block and converted to a 400 with the
77
+ per-inline error detail. Using an exception rather than an early return
78
+ is what guarantees the rollback — a plain return inside ``atomic()``
79
+ would commit the parent. Shared by the create + update endpoints.
80
+ """
81
+
82
+ def __init__(self, errors: dict) -> None:
83
+ super().__init__("inline formset validation failed")
84
+ self.errors = errors
85
+
86
+
72
87
  def _inline_name(inline: InlineModelAdmin, parent: Model) -> str:
73
88
  """The identifier the read half emits for this inline.
74
89
 
@@ -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"
@@ -33,6 +33,7 @@ from __future__ import annotations
33
33
 
34
34
  from typing import Any
35
35
 
36
+ from django.db import IntegrityError
36
37
  from django.db import transaction
37
38
  from django.http import HttpRequest
38
39
  from django.http import HttpResponse
@@ -44,6 +45,7 @@ from django_admin_react.api.permissions import is_admin_user
44
45
  from django_admin_react.api.registry import get_admin_site
45
46
  from django_admin_react.api.registry import resolve_model
46
47
  from django_admin_react.api.writes import bad_request
48
+ from django_admin_react.api.writes import conflict_error
47
49
  from django_admin_react.api.writes import form_errors_to_envelope
48
50
  from django_admin_react.api.writes import load_object_or_none
49
51
  from django_admin_react.api.writes import log_change
@@ -180,6 +182,26 @@ def _apply_one(
180
182
  "error": {"code": "forbidden", "message": "You do not have permission."},
181
183
  }
182
184
 
185
+ # list_editable parity + scope guard (#401): this endpoint powers the
186
+ # changelist's inline-editable cells, so a write may only touch fields
187
+ # the admin put in ``list_editable`` — exactly like Django, which only
188
+ # accepts ``list_editable`` names on a changelist POST. A field that's
189
+ # writable on the *change form* but not list_editable (or ANY field
190
+ # when list_editable is empty) is rejected here, even though the user
191
+ # could edit it through the detail form. This keeps the bulk surface
192
+ # from silently widening the set of fields editable from the list.
193
+ list_editable = set(getattr(model_admin, "list_editable", ()) or ())
194
+ not_editable = sorted(k for k in fields if k not in list_editable)
195
+ if not_editable:
196
+ return {
197
+ "pk": pk,
198
+ "ok": False,
199
+ "error": {
200
+ "code": "bad_request",
201
+ "message": f"Field(s) not editable in the list view: {', '.join(not_editable)}.",
202
+ },
203
+ }
204
+
183
205
  writable = writable_field_names(model, model_admin, request, obj)
184
206
  forbidden = readonly_or_excluded_names(model_admin, request, obj)
185
207
  rejection = reject_forbidden_keys(fields, writable, forbidden)
@@ -208,8 +230,19 @@ def _apply_one(
208
230
  },
209
231
  }
210
232
 
211
- instance = form.save(commit=False)
212
- model_admin.save_model(request, instance, form, change=True)
213
- form.save_m2m()
214
- log_change(model_admin, request, instance, form)
233
+ # Per-row savepoint so a DB IntegrityError the form didn't catch (a
234
+ # uniqueness race, or a DB-level constraint) rolls back just this row
235
+ # and returns a clean per-row error — keeping the surrounding batch
236
+ # transaction usable instead of aborting it (and 500ing) (#404). The
237
+ # batch still rolls everything back when any row is rejected.
238
+ try:
239
+ with transaction.atomic():
240
+ instance = form.save(commit=False)
241
+ model_admin.save_model(request, instance, form, change=True)
242
+ # M2M / related via the admin hook (#402) so a consumer's
243
+ # save_related override runs (default = save_m2m).
244
+ model_admin.save_related(request, form, [], change=True)
245
+ log_change(model_admin, request, instance, form)
246
+ except IntegrityError:
247
+ return {"pk": pk, "ok": False, "error": conflict_error()}
215
248
  return {"pk": pk, "ok": True}