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.
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/PKG-INFO +78 -13
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/README.md +77 -12
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/filters.py +56 -2
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/inlines.py +23 -1
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/registry.py +6 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/actions.py +12 -2
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/create_form.py +91 -1
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/detail.py +55 -2
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/list.py +72 -17
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/conf.py +23 -10
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/static/admin_react/.vite/manifest.json +2 -2
- django_admin_react-0.2.0a8/django_admin_react/static/admin_react/assets/index-BSzI7RU6.css +1 -0
- django_admin_react-0.2.0a8/django_admin_react/static/admin_react/assets/index-CxlHfz-w.js +8 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/static/admin_react/index.html +2 -2
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/templates/admin_react/index.html +14 -3
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/views.py +74 -3
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/pyproject.toml +1 -1
- django_admin_react-0.2.0a6/django_admin_react/static/admin_react/assets/index-Beowap5h.css +0 -1
- django_admin_react-0.2.0a6/django_admin_react/static/admin_react/assets/index-CgWOpEY8.js +0 -8
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/LICENSE +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/README.md +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/__init__.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/README.md +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/__init__.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/dates.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/inlines_write.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/panels.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/permissions.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/serializers.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/urls.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/README.md +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/__init__.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/auth.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/autocomplete.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/bulk.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/create.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/delete_preview.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/destroy.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/history.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/password.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/registry.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/schema.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/update.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/writes.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/apps.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/audit.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/pwa.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/templates/README.md +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/templates/admin_react/README.md +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/templates/admin_react/login.html +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/templates/admin_react/sw.js +0 -0
- {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.
|
|
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 —
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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` —
|
|
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`.
|
|
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 —
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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` —
|
|
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`.
|
|
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
|
|
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
|
|
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 =
|
|
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),
|
{django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/registry.py
RENAMED
|
@@ -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
|
|
{django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/actions.py
RENAMED
|
@@ -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,
|
{django_admin_react-0.2.0a6 → django_admin_react-0.2.0a8}/django_admin_react/api/views/detail.py
RENAMED
|
@@ -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 [
|
|
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
|
-
|
|
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(
|