polyadmin 0.1.0b1__py3-none-any.whl
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.
- polyadmin/__init__.py +47 -0
- polyadmin/core/__init__.py +0 -0
- polyadmin/core/_async.py +21 -0
- polyadmin/core/action.py +135 -0
- polyadmin/core/admin.py +129 -0
- polyadmin/core/audit.py +65 -0
- polyadmin/core/auth.py +52 -0
- polyadmin/core/authorization.py +48 -0
- polyadmin/core/csrf.py +70 -0
- polyadmin/core/dashboard.py +41 -0
- polyadmin/core/delete.py +146 -0
- polyadmin/core/exporter.py +136 -0
- polyadmin/core/field.py +201 -0
- polyadmin/core/filter.py +305 -0
- polyadmin/core/inline.py +97 -0
- polyadmin/core/login.py +110 -0
- polyadmin/core/model_admin.py +349 -0
- polyadmin/core/page.py +62 -0
- polyadmin/core/pagination.py +70 -0
- polyadmin/core/query.py +243 -0
- polyadmin/core/relation.py +40 -0
- polyadmin/core/slug.py +57 -0
- polyadmin/core/template_context.py +679 -0
- polyadmin/core/widget.py +280 -0
- polyadmin/fastapi/__init__.py +3 -0
- polyadmin/fastapi/audit.py +52 -0
- polyadmin/fastapi/auth.py +112 -0
- polyadmin/fastapi/csrf.py +93 -0
- polyadmin/fastapi/deletes.py +76 -0
- polyadmin/fastapi/errors.py +93 -0
- polyadmin/fastapi/handlers.py +798 -0
- polyadmin/fastapi/inlines.py +177 -0
- polyadmin/fastapi/locale.py +128 -0
- polyadmin/fastapi/login.py +110 -0
- polyadmin/fastapi/pages.py +97 -0
- polyadmin/fastapi/relations.py +264 -0
- polyadmin/fastapi/responses.py +55 -0
- polyadmin/fastapi/router.py +174 -0
- polyadmin/fastapi/static.py +21 -0
- polyadmin/i18n/__init__.py +50 -0
- polyadmin/i18n/context.py +54 -0
- polyadmin/i18n/negotiation.py +56 -0
- polyadmin/i18n/setup.py +81 -0
- polyadmin/i18n/translator.py +124 -0
- polyadmin/locale/fr/LC_MESSAGES/polyadmin.mo +0 -0
- polyadmin/locale/fr/LC_MESSAGES/polyadmin.po +486 -0
- polyadmin/locale/polyadmin.pot +485 -0
- polyadmin/locale/ru/LC_MESSAGES/polyadmin.mo +0 -0
- polyadmin/locale/ru/LC_MESSAGES/polyadmin.po +496 -0
- polyadmin/templates/admin/base.html +91 -0
- polyadmin/templates/admin/components/action_confirm_modal.html +86 -0
- polyadmin/templates/admin/components/csrf-field.html +5 -0
- polyadmin/templates/admin/components/error_fragment.html +6 -0
- polyadmin/templates/admin/components/field.html +60 -0
- polyadmin/templates/admin/components/form_wrapper.html +131 -0
- polyadmin/templates/admin/components/icons.html +67 -0
- polyadmin/templates/admin/components/inline.html +251 -0
- polyadmin/templates/admin/components/inline_fragment.html +2 -0
- polyadmin/templates/admin/components/list_content.html +54 -0
- polyadmin/templates/admin/components/lookup_results.html +19 -0
- polyadmin/templates/admin/components/search.html +19 -0
- polyadmin/templates/admin/components/toasts.html +151 -0
- polyadmin/templates/admin/components/ui/breadcrumb.html +30 -0
- polyadmin/templates/admin/components/ui/bulk-actions.html +69 -0
- polyadmin/templates/admin/components/ui/calendar.html +175 -0
- polyadmin/templates/admin/components/ui/combobox.html +82 -0
- polyadmin/templates/admin/components/ui/delete-preview.html +37 -0
- polyadmin/templates/admin/components/ui/dropdown-menu.html +71 -0
- polyadmin/templates/admin/components/ui/field.html +110 -0
- polyadmin/templates/admin/components/ui/filter-panel.html +155 -0
- polyadmin/templates/admin/components/ui/locale-switcher.html +30 -0
- polyadmin/templates/admin/components/ui/multi-select.html +253 -0
- polyadmin/templates/admin/components/ui/pagination.html +81 -0
- polyadmin/templates/admin/components/ui/radio-group.html +28 -0
- polyadmin/templates/admin/components/ui/select.html +165 -0
- polyadmin/templates/admin/components/ui/sidebar.html +175 -0
- polyadmin/templates/admin/components/ui/slider.html +22 -0
- polyadmin/templates/admin/components/ui/switch.html +36 -0
- polyadmin/templates/admin/components/ui/table.html +221 -0
- polyadmin/templates/admin/components/ui/theme-toggle.html +33 -0
- polyadmin/templates/admin/dashboard.html +35 -0
- polyadmin/templates/admin/error.html +33 -0
- polyadmin/templates/admin/login.html +94 -0
- polyadmin/templates/admin/resource/delete.html +29 -0
- polyadmin/templates/admin/resource/delete_selected.html +49 -0
- polyadmin/templates/admin/resource/detail.html +78 -0
- polyadmin/templates/admin/resource/form.html +5 -0
- polyadmin/templates/admin/resource/list.html +5 -0
- polyadmin/templates/admin/theme.html +372 -0
- polyadmin/templates/admin/widgets/activity.html +8 -0
- polyadmin/templates/admin/widgets/chart.html +15 -0
- polyadmin/templates/admin/widgets/donut.html +59 -0
- polyadmin/templates/admin/widgets/metric.html +1 -0
- polyadmin/templates/admin/widgets/progress.html +7 -0
- polyadmin/templates/admin/widgets/stat.html +22 -0
- polyadmin/templates/admin/widgets/table.html +29 -0
- polyadmin/templates/admin/widgets/tabs.html +34 -0
- polyadmin/templates/admin/widgets/timeline.html +21 -0
- polyadmin/templating.py +528 -0
- polyadmin/ui.py +817 -0
- polyadmin-0.1.0b1.dist-info/METADATA +239 -0
- polyadmin-0.1.0b1.dist-info/RECORD +104 -0
- polyadmin-0.1.0b1.dist-info/WHEEL +4 -0
- polyadmin-0.1.0b1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
"""Request handlers for ModelAdmin CRUD routes.
|
|
2
|
+
|
|
3
|
+
Each `build_*` function returns endpoint coroutine(s) closing over the
|
|
4
|
+
Admin, ModelAdmin, and Renderer they serve; `router.py` wires these onto
|
|
5
|
+
routes. This keeps the handlers testable independent of routing.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from fastapi import Request
|
|
13
|
+
from fastapi.responses import HTMLResponse, StreamingResponse
|
|
14
|
+
|
|
15
|
+
from polyadmin.core._async import maybe_await
|
|
16
|
+
from polyadmin.core.action import DELETE_SELECTED_NAME
|
|
17
|
+
from polyadmin.core.admin import Admin
|
|
18
|
+
from polyadmin.core.audit import AUDIT_CREATE, AUDIT_DELETE, AUDIT_UPDATE
|
|
19
|
+
from polyadmin.core.authorization import resource_permission
|
|
20
|
+
from polyadmin.core.csrf import safe_redirect_path
|
|
21
|
+
from polyadmin.core.delete import previews_deletes, resolve_delete_preview
|
|
22
|
+
from polyadmin.core.exporter import Exporter
|
|
23
|
+
from polyadmin.core.inline import Inline
|
|
24
|
+
from polyadmin.core.model_admin import ModelAdmin
|
|
25
|
+
from polyadmin.core.pagination import page_of
|
|
26
|
+
from polyadmin.core.query import (
|
|
27
|
+
LIST_TOKEN_FIELD,
|
|
28
|
+
RANGE_FOR_FIELD,
|
|
29
|
+
RANGE_FROM_FIELD,
|
|
30
|
+
RANGE_TO_FIELD,
|
|
31
|
+
ListRequest,
|
|
32
|
+
alist_objects,
|
|
33
|
+
apply_defaults,
|
|
34
|
+
fold_range_params,
|
|
35
|
+
safe_list_token,
|
|
36
|
+
with_list_token,
|
|
37
|
+
)
|
|
38
|
+
from polyadmin.core.template_context import delete_preview_view
|
|
39
|
+
from polyadmin.fastapi.audit import record_audit
|
|
40
|
+
from polyadmin.fastapi.auth import authorize, authorize_object, compute_permissions
|
|
41
|
+
from polyadmin.fastapi.deletes import RETURN_FIELD, confirm_delete_selected
|
|
42
|
+
from polyadmin.fastapi.errors import forbidden, not_found
|
|
43
|
+
from polyadmin.fastapi.inlines import build_inline_context
|
|
44
|
+
from polyadmin.fastapi.locale import acached_principal
|
|
45
|
+
from polyadmin.fastapi.relations import (
|
|
46
|
+
compute_relation_options,
|
|
47
|
+
compute_relation_permissions,
|
|
48
|
+
relation_filter_choices_for,
|
|
49
|
+
)
|
|
50
|
+
from polyadmin.fastapi.responses import (
|
|
51
|
+
clear_flash,
|
|
52
|
+
is_htmx_request,
|
|
53
|
+
pop_flash,
|
|
54
|
+
redirect,
|
|
55
|
+
set_flash,
|
|
56
|
+
)
|
|
57
|
+
from polyadmin.i18n import gettext, ngettext
|
|
58
|
+
from polyadmin.templating import Renderer
|
|
59
|
+
|
|
60
|
+
# The submit buttons meaning something other than "save and show me the
|
|
61
|
+
# record". An unclicked submit button's name never reaches the server, so
|
|
62
|
+
# the handler reads presence rather than a value.
|
|
63
|
+
SAVE_CONTINUE_FIELD = "_continue"
|
|
64
|
+
SAVE_ADD_ANOTHER_FIELD = "_addanother"
|
|
65
|
+
# Turns an edit into a create: the submitted values become a new record and
|
|
66
|
+
# the original is left alone (save_as).
|
|
67
|
+
SAVE_AS_NEW_FIELD = "_saveasnew"
|
|
68
|
+
|
|
69
|
+
_FILTER_KEY = re.compile(r"^filter\[(\w+)\]$")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_list_request(query_params: Any, model_admin: ModelAdmin) -> ListRequest:
|
|
73
|
+
"""Read the list query from the URL. It takes the ModelAdmin only so
|
|
74
|
+
the panel's range form can be folded into one filter value -- see
|
|
75
|
+
fold_range_params, which needs to know which filters are declared.
|
|
76
|
+
"""
|
|
77
|
+
filters = {}
|
|
78
|
+
for key, value in query_params.multi_items():
|
|
79
|
+
match = _FILTER_KEY.match(key)
|
|
80
|
+
if match:
|
|
81
|
+
filters[match.group(1)] = value
|
|
82
|
+
# The panel's range form posts two date inputs plus the filter they
|
|
83
|
+
# belong to; fold them into the one value the grammar defines before
|
|
84
|
+
# anything reads filters.
|
|
85
|
+
fold_range_params(
|
|
86
|
+
model_admin,
|
|
87
|
+
filters,
|
|
88
|
+
query_params.get(RANGE_FOR_FIELD, ""),
|
|
89
|
+
query_params.get(RANGE_FROM_FIELD, ""),
|
|
90
|
+
query_params.get(RANGE_TO_FIELD, ""),
|
|
91
|
+
)
|
|
92
|
+
# page_size defaults to 0, not 25: an unset value has to reach
|
|
93
|
+
# apply_defaults so the ModelAdmin's own list_per_page is consulted
|
|
94
|
+
# first.
|
|
95
|
+
try:
|
|
96
|
+
page = int(query_params.get("page", 1))
|
|
97
|
+
page_size = int(query_params.get("page_size", 0))
|
|
98
|
+
except ValueError:
|
|
99
|
+
page, page_size = 1, 0
|
|
100
|
+
return ListRequest(
|
|
101
|
+
search=query_params.get("search") or None,
|
|
102
|
+
filters=filters,
|
|
103
|
+
ordering=query_params.get("sort") or None,
|
|
104
|
+
page=page,
|
|
105
|
+
page_size=page_size,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# The autocomplete caps its suggestions: it is a search box, not a
|
|
110
|
+
# browser, and past a screenful the answer is "type more".
|
|
111
|
+
LOOKUP_LIMIT = 20
|
|
112
|
+
|
|
113
|
+
# The hidden flag the bulk-actions form sets when the user chose "select
|
|
114
|
+
# all N matching" rather than ticking rows.
|
|
115
|
+
SELECT_ALL_FIELD = "_select_all"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _parse_list_request_from_form(form: Any) -> ListRequest:
|
|
119
|
+
"""Rebuild the list query from the posted form, not the URL: a bulk action
|
|
120
|
+
posts to its own route, so reading the query string would silently act on
|
|
121
|
+
the unfiltered set.
|
|
122
|
+
"""
|
|
123
|
+
filters = {
|
|
124
|
+
match.group(1): value
|
|
125
|
+
for key, value in form.items()
|
|
126
|
+
if (match := _FILTER_KEY.match(key))
|
|
127
|
+
}
|
|
128
|
+
return ListRequest(
|
|
129
|
+
search=form.get("search") or None,
|
|
130
|
+
filters=filters,
|
|
131
|
+
ordering=form.get("sort") or None,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _validate_writable(model_admin: ModelAdmin, data: dict[str, Any], obj: Any = None) -> dict[str, list[str]]:
|
|
136
|
+
"""Run the ModelAdmin's validation, then drop complaints about read-only
|
|
137
|
+
fields.
|
|
138
|
+
|
|
139
|
+
Such a field is never posted, so a required one would otherwise fail every
|
|
140
|
+
save: the value is not missing, it is simply not the form's to send.
|
|
141
|
+
Wrapping rather than changing validate() leaves an application's own
|
|
142
|
+
override unaffected.
|
|
143
|
+
"""
|
|
144
|
+
errors = model_admin.validate(data)
|
|
145
|
+
return {name: errs for name, errs in errors.items() if not model_admin.is_readonly(name, obj)}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _parse_form_data(model_admin: ModelAdmin, form: Any, obj: Any = None) -> dict[str, Any]:
|
|
149
|
+
"""Read the posted form into a data map.
|
|
150
|
+
|
|
151
|
+
`obj` is the record being edited, None when creating, and is passed only to
|
|
152
|
+
resolve read-only fields: such a field is skipped entirely, so a crafted
|
|
153
|
+
POST naming it cannot write it. Omitting the input is presentation; this is
|
|
154
|
+
the enforcement.
|
|
155
|
+
"""
|
|
156
|
+
data: dict[str, Any] = {}
|
|
157
|
+
for name in model_admin.get_form_fields():
|
|
158
|
+
if model_admin.is_readonly(name, obj):
|
|
159
|
+
continue
|
|
160
|
+
field = model_admin.get_field(name)
|
|
161
|
+
if field.field_type == "boolean":
|
|
162
|
+
data[name] = name in form
|
|
163
|
+
elif field.field_type == "manytomany":
|
|
164
|
+
data[name] = form.getlist(name)
|
|
165
|
+
else:
|
|
166
|
+
data[name] = field.parse_form_value(form.get(name))
|
|
167
|
+
return data
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _list_token(request: Request, form: Any, base_path: str) -> str:
|
|
171
|
+
"""The list a page was reached from -- the _list query parameter on a GET,
|
|
172
|
+
the hidden field on a POST -- validated the way a Referer is. An invalid
|
|
173
|
+
one reads as "no list", so a forged token redirects to the bare list
|
|
174
|
+
rather than off-site."""
|
|
175
|
+
raw = request.query_params.get(LIST_TOKEN_FIELD) or (form.get(LIST_TOKEN_FIELD) if form else None)
|
|
176
|
+
return safe_list_token(raw, request.url.netloc, base_path)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def build_list_handler(admin: Admin, model_admin: ModelAdmin, renderer: Renderer, base_path: str):
|
|
180
|
+
slug = model_admin.get_slug()
|
|
181
|
+
|
|
182
|
+
async def list_view(request: Request) -> HTMLResponse:
|
|
183
|
+
principal, error = await authorize(admin, request, base_path, resource_permission(slug, "list"), model_admin)
|
|
184
|
+
if error:
|
|
185
|
+
return error
|
|
186
|
+
# None: a list page is about the model, not one record.
|
|
187
|
+
permissions = compute_permissions(admin, principal, model_admin, None)
|
|
188
|
+
relation_permissions = compute_relation_permissions(
|
|
189
|
+
admin, principal, model_admin, list(model_admin.list_display)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Resolved once and handed to both the query and the pager:
|
|
193
|
+
# otherwise page_of would size the control from the raw request
|
|
194
|
+
# and disagree with the rows fetched.
|
|
195
|
+
list_request = apply_defaults(model_admin, _parse_list_request(request.query_params, model_admin))
|
|
196
|
+
objects, total = await alist_objects(model_admin, list_request)
|
|
197
|
+
page = page_of(objects, total, list_request)
|
|
198
|
+
# A relation filter cannot source its own choices -- core reaches
|
|
199
|
+
# neither the registry nor the principal -- so they are resolved
|
|
200
|
+
# here and passed in.
|
|
201
|
+
filter_choices = await relation_filter_choices_for(
|
|
202
|
+
admin, principal, model_admin, list_request.filters, base_path
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if is_htmx_request(request):
|
|
206
|
+
html = renderer.render_list_fragment(
|
|
207
|
+
admin,
|
|
208
|
+
model_admin,
|
|
209
|
+
page,
|
|
210
|
+
list_request=list_request,
|
|
211
|
+
permissions=permissions,
|
|
212
|
+
relation_permissions=relation_permissions,
|
|
213
|
+
base_path=base_path,
|
|
214
|
+
principal=principal,
|
|
215
|
+
csrf_token=request.state.csrf_token,
|
|
216
|
+
relation_filter_choices=filter_choices,
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
messages = pop_flash(request)
|
|
220
|
+
html = renderer.render_list(
|
|
221
|
+
admin,
|
|
222
|
+
model_admin,
|
|
223
|
+
page,
|
|
224
|
+
list_request=list_request,
|
|
225
|
+
permissions=permissions,
|
|
226
|
+
relation_permissions=relation_permissions,
|
|
227
|
+
base_path=base_path,
|
|
228
|
+
messages=messages,
|
|
229
|
+
principal=principal,
|
|
230
|
+
csrf_token=request.state.csrf_token,
|
|
231
|
+
relation_filter_choices=filter_choices,
|
|
232
|
+
)
|
|
233
|
+
response = HTMLResponse(html)
|
|
234
|
+
if not is_htmx_request(request):
|
|
235
|
+
clear_flash(response)
|
|
236
|
+
return response
|
|
237
|
+
|
|
238
|
+
return list_view
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def build_detail_handler(admin: Admin, model_admin: ModelAdmin, renderer: Renderer, base_path: str):
|
|
242
|
+
slug = model_admin.get_slug()
|
|
243
|
+
|
|
244
|
+
async def detail_view(request: Request, pk: str) -> HTMLResponse:
|
|
245
|
+
principal, error = await authorize(admin, request, base_path, resource_permission(slug, "view"), model_admin)
|
|
246
|
+
if error:
|
|
247
|
+
return error
|
|
248
|
+
obj = await maybe_await(model_admin.get_object(pk))
|
|
249
|
+
if obj is None:
|
|
250
|
+
return not_found(request, admin, base_path)
|
|
251
|
+
# The record's own page: per-object rules decide whether it
|
|
252
|
+
# offers Edit/Delete at all.
|
|
253
|
+
if not authorize_object(admin, principal, resource_permission(slug, "view"), obj):
|
|
254
|
+
return forbidden(request, admin, base_path)
|
|
255
|
+
permissions = compute_permissions(admin, principal, model_admin, obj)
|
|
256
|
+
relation_permissions = compute_relation_permissions(
|
|
257
|
+
admin, principal, model_admin, model_admin.get_detail_fields()
|
|
258
|
+
)
|
|
259
|
+
messages = pop_flash(request)
|
|
260
|
+
inlines = await build_inline_context(admin, principal, model_admin, obj, "readonly", base_path)
|
|
261
|
+
html = renderer.render_detail(
|
|
262
|
+
admin,
|
|
263
|
+
model_admin,
|
|
264
|
+
obj,
|
|
265
|
+
inlines=inlines,
|
|
266
|
+
principal=principal,
|
|
267
|
+
csrf_token=request.state.csrf_token,
|
|
268
|
+
permissions=permissions,
|
|
269
|
+
relation_permissions=relation_permissions,
|
|
270
|
+
base_path=base_path,
|
|
271
|
+
messages=messages,
|
|
272
|
+
list_token=_list_token(request, None, base_path),
|
|
273
|
+
)
|
|
274
|
+
response = HTMLResponse(html)
|
|
275
|
+
clear_flash(response)
|
|
276
|
+
return response
|
|
277
|
+
|
|
278
|
+
return detail_view
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def build_create_handlers(admin: Admin, model_admin: ModelAdmin, renderer: Renderer, base_path: str):
|
|
282
|
+
slug = model_admin.get_slug()
|
|
283
|
+
|
|
284
|
+
async def create_get(request: Request) -> HTMLResponse:
|
|
285
|
+
principal, error = await authorize(admin, request, base_path, resource_permission(slug, "create"), model_admin)
|
|
286
|
+
if error:
|
|
287
|
+
return error
|
|
288
|
+
relation_options = await compute_relation_options(admin, model_admin)
|
|
289
|
+
inlines = await build_inline_context(admin, principal, model_admin, None, "placeholder", base_path)
|
|
290
|
+
html = renderer.render_form(
|
|
291
|
+
admin,
|
|
292
|
+
model_admin,
|
|
293
|
+
principal=principal,
|
|
294
|
+
csrf_token=request.state.csrf_token,
|
|
295
|
+
relation_options=relation_options,
|
|
296
|
+
inlines=inlines,
|
|
297
|
+
base_path=base_path,
|
|
298
|
+
list_token=_list_token(request, None, base_path),
|
|
299
|
+
)
|
|
300
|
+
return HTMLResponse(html)
|
|
301
|
+
|
|
302
|
+
async def create_post(request: Request):
|
|
303
|
+
principal, error = await authorize(admin, request, base_path, resource_permission(slug, "create"), model_admin)
|
|
304
|
+
if error:
|
|
305
|
+
return error
|
|
306
|
+
form = await request.form()
|
|
307
|
+
data = _parse_form_data(model_admin, form)
|
|
308
|
+
errors = _validate_writable(model_admin, data)
|
|
309
|
+
if errors:
|
|
310
|
+
relation_options = await compute_relation_options(admin, model_admin)
|
|
311
|
+
inlines = await build_inline_context(admin, principal, model_admin, None, "placeholder", base_path)
|
|
312
|
+
if is_htmx_request(request):
|
|
313
|
+
html = renderer.render_form_fragment(
|
|
314
|
+
admin,
|
|
315
|
+
model_admin,
|
|
316
|
+
principal=principal,
|
|
317
|
+
csrf_token=request.state.csrf_token,
|
|
318
|
+
data=data,
|
|
319
|
+
errors=errors,
|
|
320
|
+
relation_options=relation_options,
|
|
321
|
+
inlines=inlines,
|
|
322
|
+
base_path=base_path,
|
|
323
|
+
)
|
|
324
|
+
else:
|
|
325
|
+
html = renderer.render_form(
|
|
326
|
+
admin,
|
|
327
|
+
model_admin,
|
|
328
|
+
principal=principal,
|
|
329
|
+
csrf_token=request.state.csrf_token,
|
|
330
|
+
data=data,
|
|
331
|
+
errors=errors,
|
|
332
|
+
relation_options=relation_options,
|
|
333
|
+
inlines=inlines,
|
|
334
|
+
base_path=base_path,
|
|
335
|
+
list_token=_list_token(request, form, base_path),
|
|
336
|
+
)
|
|
337
|
+
return HTMLResponse(html, status_code=422)
|
|
338
|
+
obj = await maybe_await(model_admin.create(data))
|
|
339
|
+
record_audit(admin, principal, model_admin, AUDIT_CREATE, obj)
|
|
340
|
+
# "Save and add another" goes back to an empty form, checked
|
|
341
|
+
# before building the record's URL since it never uses one.
|
|
342
|
+
# Translators: %(name)s is the model's name. French and Russian
|
|
343
|
+
# nouns carry gender, so phrase around agreement.
|
|
344
|
+
created_message = gettext("%(name)s created.") % {"name": gettext(model_admin.get_verbose_name())}
|
|
345
|
+
# preserve_filters: every page reached from the list keeps carrying
|
|
346
|
+
# it, so the trail back leads into the filtered list.
|
|
347
|
+
back = _list_token(request, form, base_path)
|
|
348
|
+
if form.get(SAVE_ADD_ANOTHER_FIELD):
|
|
349
|
+
response = redirect(request, with_list_token(f"{base_path}/{model_admin.get_slug()}/create", back))
|
|
350
|
+
set_flash(response, "success", created_message)
|
|
351
|
+
return response
|
|
352
|
+
pk = model_admin.get_pk(obj)
|
|
353
|
+
target = f"{base_path}/{model_admin.get_slug()}/{pk}"
|
|
354
|
+
if form.get(SAVE_CONTINUE_FIELD):
|
|
355
|
+
target += "/edit"
|
|
356
|
+
response = redirect(request, with_list_token(target, back))
|
|
357
|
+
set_flash(response, "success", created_message)
|
|
358
|
+
return response
|
|
359
|
+
|
|
360
|
+
return create_get, create_post
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def build_edit_handlers(admin: Admin, model_admin: ModelAdmin, renderer: Renderer, base_path: str):
|
|
364
|
+
slug = model_admin.get_slug()
|
|
365
|
+
|
|
366
|
+
async def edit_get(request: Request, pk: str) -> HTMLResponse:
|
|
367
|
+
principal, error = await authorize(admin, request, base_path, resource_permission(slug, "update"), model_admin)
|
|
368
|
+
if error:
|
|
369
|
+
return error
|
|
370
|
+
obj = await maybe_await(model_admin.get_object(pk))
|
|
371
|
+
if obj is None:
|
|
372
|
+
return not_found(request, admin, base_path)
|
|
373
|
+
if not authorize_object(admin, principal, resource_permission(slug, "update"), obj):
|
|
374
|
+
return forbidden(request, admin, base_path)
|
|
375
|
+
relation_options = await compute_relation_options(admin, model_admin, obj=obj)
|
|
376
|
+
inlines = await build_inline_context(admin, principal, model_admin, obj, "edit", base_path)
|
|
377
|
+
html = renderer.render_form(
|
|
378
|
+
admin,
|
|
379
|
+
model_admin,
|
|
380
|
+
principal=principal,
|
|
381
|
+
csrf_token=request.state.csrf_token,
|
|
382
|
+
obj=obj,
|
|
383
|
+
relation_options=relation_options,
|
|
384
|
+
inlines=inlines,
|
|
385
|
+
base_path=base_path,
|
|
386
|
+
list_token=_list_token(request, None, base_path),
|
|
387
|
+
)
|
|
388
|
+
return HTMLResponse(html)
|
|
389
|
+
|
|
390
|
+
async def edit_post(request: Request, pk: str):
|
|
391
|
+
principal, error = await authorize(admin, request, base_path, resource_permission(slug, "update"), model_admin)
|
|
392
|
+
if error:
|
|
393
|
+
return error
|
|
394
|
+
obj = await maybe_await(model_admin.get_object(pk))
|
|
395
|
+
if obj is None:
|
|
396
|
+
return not_found(request, admin, base_path)
|
|
397
|
+
if not authorize_object(admin, principal, resource_permission(slug, "update"), obj):
|
|
398
|
+
return forbidden(request, admin, base_path)
|
|
399
|
+
form = await request.form()
|
|
400
|
+
data = _parse_form_data(model_admin, form, obj)
|
|
401
|
+
errors = _validate_writable(model_admin, data, obj)
|
|
402
|
+
if errors:
|
|
403
|
+
relation_options = await compute_relation_options(admin, model_admin, obj=obj)
|
|
404
|
+
inlines = await build_inline_context(admin, principal, model_admin, obj, "edit", base_path)
|
|
405
|
+
if is_htmx_request(request):
|
|
406
|
+
html = renderer.render_form_fragment(
|
|
407
|
+
admin,
|
|
408
|
+
model_admin,
|
|
409
|
+
principal=principal,
|
|
410
|
+
csrf_token=request.state.csrf_token,
|
|
411
|
+
obj=obj,
|
|
412
|
+
data=data,
|
|
413
|
+
errors=errors,
|
|
414
|
+
relation_options=relation_options,
|
|
415
|
+
inlines=inlines,
|
|
416
|
+
base_path=base_path,
|
|
417
|
+
)
|
|
418
|
+
else:
|
|
419
|
+
html = renderer.render_form(
|
|
420
|
+
admin,
|
|
421
|
+
model_admin,
|
|
422
|
+
principal=principal,
|
|
423
|
+
csrf_token=request.state.csrf_token,
|
|
424
|
+
obj=obj,
|
|
425
|
+
data=data,
|
|
426
|
+
errors=errors,
|
|
427
|
+
relation_options=relation_options,
|
|
428
|
+
inlines=inlines,
|
|
429
|
+
base_path=base_path,
|
|
430
|
+
list_token=_list_token(request, form, base_path),
|
|
431
|
+
)
|
|
432
|
+
return HTMLResponse(html, status_code=422)
|
|
433
|
+
# "Save as new": the submitted values become a new record, and the one
|
|
434
|
+
# being edited is left untouched. Gated on the option, so a forged
|
|
435
|
+
# field on an admin without it is an ordinary save.
|
|
436
|
+
if model_admin.save_as and form.get(SAVE_AS_NEW_FIELD):
|
|
437
|
+
if not compute_permissions(admin, principal, model_admin, None)["can_create"]:
|
|
438
|
+
return forbidden(request, admin, base_path)
|
|
439
|
+
created = await maybe_await(model_admin.create(data))
|
|
440
|
+
record_audit(admin, principal, model_admin, AUDIT_CREATE, created)
|
|
441
|
+
created_message = gettext("%(name)s created.") % {"name": gettext(model_admin.get_verbose_name())}
|
|
442
|
+
target = f"{base_path}/{model_admin.get_slug()}/{model_admin.get_pk(created)}"
|
|
443
|
+
if form.get(SAVE_CONTINUE_FIELD):
|
|
444
|
+
target += "/edit"
|
|
445
|
+
response = redirect(request, with_list_token(target, _list_token(request, form, base_path)))
|
|
446
|
+
set_flash(response, "success", created_message)
|
|
447
|
+
return response
|
|
448
|
+
await maybe_await(model_admin.update(obj, data))
|
|
449
|
+
record_audit(admin, principal, model_admin, AUDIT_UPDATE, obj)
|
|
450
|
+
# Translators: %(name)s is the model's name. French and Russian
|
|
451
|
+
# nouns carry gender, so phrase around agreement.
|
|
452
|
+
updated_message = gettext("%(name)s updated.") % {"name": gettext(model_admin.get_verbose_name())}
|
|
453
|
+
# preserve_filters: every page reached from the list keeps carrying
|
|
454
|
+
# it, so the trail back leads into the filtered list.
|
|
455
|
+
back = _list_token(request, form, base_path)
|
|
456
|
+
if form.get(SAVE_ADD_ANOTHER_FIELD):
|
|
457
|
+
response = redirect(request, with_list_token(f"{base_path}/{model_admin.get_slug()}/create", back))
|
|
458
|
+
set_flash(response, "success", updated_message)
|
|
459
|
+
return response
|
|
460
|
+
target = f"{base_path}/{model_admin.get_slug()}/{pk}"
|
|
461
|
+
if form.get(SAVE_CONTINUE_FIELD):
|
|
462
|
+
target += "/edit"
|
|
463
|
+
response = redirect(request, with_list_token(target, back))
|
|
464
|
+
set_flash(response, "success", updated_message)
|
|
465
|
+
return response
|
|
466
|
+
|
|
467
|
+
return edit_get, edit_post
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def build_delete_handlers(admin: Admin, model_admin: ModelAdmin, renderer: Renderer, base_path: str):
|
|
471
|
+
slug = model_admin.get_slug()
|
|
472
|
+
|
|
473
|
+
async def delete_get(request: Request, pk: str) -> HTMLResponse:
|
|
474
|
+
principal, error = await authorize(admin, request, base_path, resource_permission(slug, "delete"), model_admin)
|
|
475
|
+
if error:
|
|
476
|
+
return error
|
|
477
|
+
obj = await maybe_await(model_admin.get_object(pk))
|
|
478
|
+
if obj is None:
|
|
479
|
+
return not_found(request, admin, base_path)
|
|
480
|
+
if not authorize_object(admin, principal, resource_permission(slug, "delete"), obj):
|
|
481
|
+
return forbidden(request, admin, base_path)
|
|
482
|
+
preview = resolve_delete_preview(admin, model_admin, principal, [obj])
|
|
483
|
+
html = renderer.render_delete(
|
|
484
|
+
admin,
|
|
485
|
+
model_admin,
|
|
486
|
+
obj,
|
|
487
|
+
base_path=base_path,
|
|
488
|
+
principal=principal,
|
|
489
|
+
csrf_token=request.state.csrf_token,
|
|
490
|
+
preview=preview,
|
|
491
|
+
list_token=_list_token(request, None, base_path),
|
|
492
|
+
)
|
|
493
|
+
return HTMLResponse(html)
|
|
494
|
+
|
|
495
|
+
async def delete_post(request: Request, pk: str):
|
|
496
|
+
principal, error = await authorize(admin, request, base_path, resource_permission(slug, "delete"), model_admin)
|
|
497
|
+
if error:
|
|
498
|
+
return error
|
|
499
|
+
obj = await maybe_await(model_admin.get_object(pk))
|
|
500
|
+
if obj is not None:
|
|
501
|
+
if not authorize_object(admin, principal, resource_permission(slug, "delete"), obj):
|
|
502
|
+
return forbidden(request, admin, base_path)
|
|
503
|
+
if resolve_delete_preview(admin, model_admin, principal, [obj]).blocked:
|
|
504
|
+
# Back to the delete page, which says why; redirect() sends
|
|
505
|
+
# HX-Redirect for the htmx route and a 303 otherwise.
|
|
506
|
+
return redirect(request, f"{base_path}/{slug}/{model_admin.get_pk(obj)}/delete")
|
|
507
|
+
await maybe_await(model_admin.delete(obj))
|
|
508
|
+
record_audit(admin, principal, model_admin, AUDIT_DELETE, obj)
|
|
509
|
+
# Back to the list it came from -- preserve_filters.
|
|
510
|
+
back = _list_token(request, await request.form(), base_path)
|
|
511
|
+
response = redirect(request, back or f"{base_path}/{model_admin.get_slug()}")
|
|
512
|
+
# Translators: %(name)s is the model's name. French and Russian
|
|
513
|
+
# nouns carry gender, so phrase around agreement.
|
|
514
|
+
deleted_message = gettext("%(name)s deleted.") % {"name": gettext(model_admin.get_verbose_name())}
|
|
515
|
+
set_flash(response, "success", deleted_message)
|
|
516
|
+
return response
|
|
517
|
+
|
|
518
|
+
async def delete_htmx(request: Request, pk: str) -> HTMLResponse:
|
|
519
|
+
"""Row-level delete for the list view's Delete button: removes just that
|
|
520
|
+
row (an empty response, with `hx-swap="outerHTML"` on the `<tr>`)
|
|
521
|
+
instead of redirecting anywhere.
|
|
522
|
+
"""
|
|
523
|
+
principal, error = await authorize(admin, request, base_path, resource_permission(slug, "delete"), model_admin)
|
|
524
|
+
if error:
|
|
525
|
+
return error
|
|
526
|
+
obj = await maybe_await(model_admin.get_object(pk))
|
|
527
|
+
if obj is not None:
|
|
528
|
+
if not authorize_object(admin, principal, resource_permission(slug, "delete"), obj):
|
|
529
|
+
return forbidden(request, admin, base_path)
|
|
530
|
+
if resolve_delete_preview(admin, model_admin, principal, [obj]).blocked:
|
|
531
|
+
# Back to the delete page, which says why; redirect() sends
|
|
532
|
+
# HX-Redirect for the htmx route and a 303 otherwise.
|
|
533
|
+
return redirect(request, f"{base_path}/{slug}/{model_admin.get_pk(obj)}/delete")
|
|
534
|
+
await maybe_await(model_admin.delete(obj))
|
|
535
|
+
record_audit(admin, principal, model_admin, AUDIT_DELETE, obj)
|
|
536
|
+
return HTMLResponse("")
|
|
537
|
+
|
|
538
|
+
return delete_get, delete_post, delete_htmx
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def build_action_handler(admin: Admin, model_admin: ModelAdmin, renderer: Renderer, base_path: str):
|
|
542
|
+
"""POST /{slug}/actions/{action_name}, running an Action over the objects
|
|
543
|
+
named by the `pks` form field. One route for both entry points: the list's
|
|
544
|
+
bulk-select form posts every checked row, a detail page's action button
|
|
545
|
+
posts a single-item `pks`.
|
|
546
|
+
"""
|
|
547
|
+
slug = model_admin.get_slug()
|
|
548
|
+
|
|
549
|
+
async def action_view(request: Request, action_name: str):
|
|
550
|
+
_, error = await authorize(admin, request, base_path, resource_permission(slug, "view"), model_admin)
|
|
551
|
+
if error:
|
|
552
|
+
return error
|
|
553
|
+
action = model_admin.get_action(action_name)
|
|
554
|
+
if action is None:
|
|
555
|
+
return not_found(request, admin, base_path)
|
|
556
|
+
|
|
557
|
+
principal = await acached_principal(admin, request)
|
|
558
|
+
if (
|
|
559
|
+
action.permission
|
|
560
|
+
and admin.authorizer is not None
|
|
561
|
+
and not admin.authorizer.can(
|
|
562
|
+
principal, resource_permission(slug, action.permission), model_admin
|
|
563
|
+
)
|
|
564
|
+
):
|
|
565
|
+
return forbidden(request, admin, base_path)
|
|
566
|
+
|
|
567
|
+
form = await request.form()
|
|
568
|
+
pks = form.getlist("pks")
|
|
569
|
+
# Back to wherever the form was submitted from, preserving
|
|
570
|
+
# search/filter/sort/page, or the bare list URL if there is no
|
|
571
|
+
# Referer. The Referer is attacker-controlled, so it is validated
|
|
572
|
+
# first -- see safe_redirect_path.
|
|
573
|
+
redirect_to = safe_redirect_path(
|
|
574
|
+
request.headers.get("referer"),
|
|
575
|
+
request.url.netloc,
|
|
576
|
+
base_path,
|
|
577
|
+
f"{base_path}/{slug}",
|
|
578
|
+
)
|
|
579
|
+
# A confirmed delete_selected posts from its own confirmation page, so
|
|
580
|
+
# it carries the original target in _return (validated the same way).
|
|
581
|
+
if form.get(RETURN_FIELD):
|
|
582
|
+
redirect_to = safe_redirect_path(form.get(RETURN_FIELD), request.url.netloc, base_path, f"{base_path}/{slug}")
|
|
583
|
+
# "Select all N matching" posts the filters instead of the pks: a
|
|
584
|
+
# checkbox only reaches the rows on screen. The set is resolved
|
|
585
|
+
# server-side from the same query the list was showing.
|
|
586
|
+
select_all = bool(form.get(SELECT_ALL_FIELD))
|
|
587
|
+
if not select_all and not pks:
|
|
588
|
+
response = redirect(request, redirect_to)
|
|
589
|
+
set_flash(response, "warning", gettext("No items selected."))
|
|
590
|
+
return response
|
|
591
|
+
|
|
592
|
+
list_request = None
|
|
593
|
+
if select_all:
|
|
594
|
+
list_request = _parse_list_request_from_form(form)
|
|
595
|
+
list_request.unlimited = True
|
|
596
|
+
objects, _ = await alist_objects(model_admin, list_request)
|
|
597
|
+
else:
|
|
598
|
+
objects = [obj for pk in pks if (obj := await maybe_await(model_admin.get_object(pk))) is not None]
|
|
599
|
+
if not objects:
|
|
600
|
+
response = redirect(request, redirect_to)
|
|
601
|
+
set_flash(response, "warning", gettext("No items selected."))
|
|
602
|
+
return response
|
|
603
|
+
if action.name == DELETE_SELECTED_NAME and previews_deletes(model_admin):
|
|
604
|
+
page = confirm_delete_selected(
|
|
605
|
+
request, form, admin, model_admin, renderer, principal, objects, select_all, redirect_to, list_request, base_path
|
|
606
|
+
)
|
|
607
|
+
if page is not None:
|
|
608
|
+
return page
|
|
609
|
+
message = await maybe_await(action.handler(objects, principal))
|
|
610
|
+
# One entry per record, not per action: the log's question is
|
|
611
|
+
# "what happened to this record", and a bulk run over 500 rows is
|
|
612
|
+
# 500 answers to it.
|
|
613
|
+
for obj in objects:
|
|
614
|
+
record_audit(admin, principal, model_admin, action.name, obj)
|
|
615
|
+
if message:
|
|
616
|
+
# A host's static message translates from its catalog; one it
|
|
617
|
+
# already translated with gettext misses and passes through.
|
|
618
|
+
message = gettext(message)
|
|
619
|
+
else:
|
|
620
|
+
message = ngettext(
|
|
621
|
+
"%(label)s applied to %(num)d record.", "%(label)s applied to %(num)d records.", len(objects)
|
|
622
|
+
) % {"label": gettext(action.label), "num": len(objects)}
|
|
623
|
+
response = redirect(request, redirect_to)
|
|
624
|
+
set_flash(response, "success", message)
|
|
625
|
+
return response
|
|
626
|
+
|
|
627
|
+
return action_view
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def build_lookup_handler(admin: Admin, model_admin: ModelAdmin, renderer: Renderer, base_path: str):
|
|
631
|
+
"""GET /{slug}/lookup?q=..., an HTML fragment of matching options consumed by
|
|
632
|
+
another resource's relation selector. Gated on this resource's own `.view`
|
|
633
|
+
permission, since that is what is being browsed.
|
|
634
|
+
"""
|
|
635
|
+
slug = model_admin.get_slug()
|
|
636
|
+
|
|
637
|
+
async def lookup_view(request: Request) -> HTMLResponse:
|
|
638
|
+
_, error = await authorize(admin, request, base_path, resource_permission(slug, "view"), model_admin)
|
|
639
|
+
if error:
|
|
640
|
+
return error
|
|
641
|
+
query = request.query_params.get("q", "")
|
|
642
|
+
display_name = request.query_params.get("display") or (
|
|
643
|
+
model_admin.search_fields[0] if model_admin.search_fields else None
|
|
644
|
+
)
|
|
645
|
+
display_field = model_admin.get_field(display_name) if display_name else None
|
|
646
|
+
|
|
647
|
+
# The cap rides in as the page window rather than a slice
|
|
648
|
+
# afterwards, so a list_page applies it in its own query instead
|
|
649
|
+
# of returning the whole table to trim.
|
|
650
|
+
list_request = ListRequest(search=query or None, page=1, page_size=LOOKUP_LIMIT)
|
|
651
|
+
objects, _ = await alist_objects(model_admin, list_request)
|
|
652
|
+
options = [
|
|
653
|
+
(model_admin.get_pk(obj), display_field.get_value(obj) if display_field else model_admin.get_pk(obj))
|
|
654
|
+
for obj in objects
|
|
655
|
+
]
|
|
656
|
+
return HTMLResponse(renderer.render_lookup(options))
|
|
657
|
+
|
|
658
|
+
return lookup_view
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def build_export_handler(admin: Admin, model_admin: ModelAdmin, exporter: Exporter, base_path: str):
|
|
662
|
+
"""GET /{slug}/export/{exporter.format}, exporting the same filtered and
|
|
663
|
+
ordered dataset the list view would show, with `list_display` as the column
|
|
664
|
+
set. Gated on `.export`, independently of `.view`.
|
|
665
|
+
"""
|
|
666
|
+
slug = model_admin.get_slug()
|
|
667
|
+
|
|
668
|
+
async def export_view(request: Request):
|
|
669
|
+
_, error = await authorize(admin, request, base_path, resource_permission(slug, "export"), model_admin)
|
|
670
|
+
if error:
|
|
671
|
+
return error
|
|
672
|
+
# unlimited: an export of a filtered set is the whole set, not
|
|
673
|
+
# whichever page the user happened to be looking at.
|
|
674
|
+
list_request = _parse_list_request(request.query_params, model_admin)
|
|
675
|
+
list_request.unlimited = True
|
|
676
|
+
objects, _ = await alist_objects(model_admin, list_request)
|
|
677
|
+
columns = list(model_admin.list_display)
|
|
678
|
+
# Computed here, not inside the generator: the route wrapper resets
|
|
679
|
+
# the locale context variable as soon as this handler returns, and
|
|
680
|
+
# StreamingResponse only iterates the generator afterwards.
|
|
681
|
+
header = [gettext(model_admin.get_field(name).label) for name in columns]
|
|
682
|
+
filename = f"{slug}.{exporter.file_extension()}"
|
|
683
|
+
return StreamingResponse(
|
|
684
|
+
exporter.stream(admin, model_admin, objects, columns, header=header),
|
|
685
|
+
media_type=exporter.content_type,
|
|
686
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
return export_view
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def build_inline_handlers(admin: Admin, model_admin: ModelAdmin, renderer: Renderer, base_path: str):
|
|
693
|
+
"""The inline create/update/delete routes. Every response carries the rebuilt
|
|
694
|
+
inline section alone -- never a redirect, never the whole parent page --
|
|
695
|
+
matching the other fragment routes.
|
|
696
|
+
"""
|
|
697
|
+
parent_slug = model_admin.get_slug()
|
|
698
|
+
|
|
699
|
+
def _get_inline(child_slug: str):
|
|
700
|
+
return next((i for i in model_admin.inlines if i.child == child_slug), None)
|
|
701
|
+
|
|
702
|
+
async def render_section(
|
|
703
|
+
principal: Any,
|
|
704
|
+
parent_obj: Any,
|
|
705
|
+
inline: Inline,
|
|
706
|
+
*,
|
|
707
|
+
redisplay: dict[str, Any] | None = None,
|
|
708
|
+
refusal: dict[str, Any] | None = None,
|
|
709
|
+
) -> str:
|
|
710
|
+
sections = await build_inline_context(admin, principal, model_admin, parent_obj, "edit", base_path, redisplay=redisplay)
|
|
711
|
+
return renderer.render_inline_fragment(admin, inline, sections, base_path=base_path, refusal=refusal)
|
|
712
|
+
|
|
713
|
+
async def inline_create(request: Request, pk: str, child_slug: str) -> HTMLResponse:
|
|
714
|
+
inline = _get_inline(child_slug)
|
|
715
|
+
if inline is None:
|
|
716
|
+
return not_found(request, admin, base_path)
|
|
717
|
+
principal, error = await authorize(admin, request, base_path, resource_permission(parent_slug, "update"), model_admin)
|
|
718
|
+
if error:
|
|
719
|
+
return error
|
|
720
|
+
parent_obj = await maybe_await(model_admin.get_object(pk))
|
|
721
|
+
if parent_obj is None:
|
|
722
|
+
return not_found(request, admin, base_path)
|
|
723
|
+
child_admin = admin.get_model_admin(inline.child)
|
|
724
|
+
_, error = await authorize(admin, request, base_path, resource_permission(inline.child, "create"), child_admin)
|
|
725
|
+
if error:
|
|
726
|
+
return error
|
|
727
|
+
|
|
728
|
+
form = await request.form()
|
|
729
|
+
data = _parse_form_data(child_admin, form)
|
|
730
|
+
data[inline.fk_field] = str(model_admin.get_pk(parent_obj))
|
|
731
|
+
errors = child_admin.validate(data)
|
|
732
|
+
if errors:
|
|
733
|
+
html = await render_section(principal, parent_obj, inline, redisplay={"pk": None, "data": data, "errors": errors})
|
|
734
|
+
return HTMLResponse(html, status_code=422)
|
|
735
|
+
|
|
736
|
+
await maybe_await(child_admin.create(data))
|
|
737
|
+
html = await render_section(principal, parent_obj, inline)
|
|
738
|
+
return HTMLResponse(html)
|
|
739
|
+
|
|
740
|
+
async def inline_update(request: Request, pk: str, child_slug: str, child_pk: str) -> HTMLResponse:
|
|
741
|
+
inline = _get_inline(child_slug)
|
|
742
|
+
if inline is None:
|
|
743
|
+
return not_found(request, admin, base_path)
|
|
744
|
+
principal, error = await authorize(admin, request, base_path, resource_permission(parent_slug, "update"), model_admin)
|
|
745
|
+
if error:
|
|
746
|
+
return error
|
|
747
|
+
parent_obj = await maybe_await(model_admin.get_object(pk))
|
|
748
|
+
if parent_obj is None:
|
|
749
|
+
return not_found(request, admin, base_path)
|
|
750
|
+
child_admin = admin.get_model_admin(inline.child)
|
|
751
|
+
_, error = await authorize(admin, request, base_path, resource_permission(inline.child, "update"), child_admin)
|
|
752
|
+
if error:
|
|
753
|
+
return error
|
|
754
|
+
child_obj = await maybe_await(child_admin.get_object(child_pk))
|
|
755
|
+
if child_obj is None:
|
|
756
|
+
return not_found(request, admin, base_path)
|
|
757
|
+
|
|
758
|
+
form = await request.form()
|
|
759
|
+
data = _parse_form_data(child_admin, form)
|
|
760
|
+
data[inline.fk_field] = str(model_admin.get_pk(parent_obj))
|
|
761
|
+
errors = child_admin.validate(data)
|
|
762
|
+
if errors:
|
|
763
|
+
html = await render_section(principal, parent_obj, inline, redisplay={"pk": child_pk, "data": data, "errors": errors})
|
|
764
|
+
return HTMLResponse(html, status_code=422)
|
|
765
|
+
|
|
766
|
+
await maybe_await(child_admin.update(child_obj, data))
|
|
767
|
+
html = await render_section(principal, parent_obj, inline)
|
|
768
|
+
return HTMLResponse(html)
|
|
769
|
+
|
|
770
|
+
async def inline_delete(request: Request, pk: str, child_slug: str, child_pk: str) -> HTMLResponse:
|
|
771
|
+
inline = _get_inline(child_slug)
|
|
772
|
+
if inline is None:
|
|
773
|
+
return not_found(request, admin, base_path)
|
|
774
|
+
principal, error = await authorize(admin, request, base_path, resource_permission(parent_slug, "update"), model_admin)
|
|
775
|
+
if error:
|
|
776
|
+
return error
|
|
777
|
+
parent_obj = await maybe_await(model_admin.get_object(pk))
|
|
778
|
+
if parent_obj is None:
|
|
779
|
+
return not_found(request, admin, base_path)
|
|
780
|
+
child_admin = admin.get_model_admin(inline.child)
|
|
781
|
+
_, error = await authorize(admin, request, base_path, resource_permission(inline.child, "delete"), child_admin)
|
|
782
|
+
if error:
|
|
783
|
+
return error
|
|
784
|
+
child_obj = await maybe_await(child_admin.get_object(child_pk))
|
|
785
|
+
if child_obj is not None:
|
|
786
|
+
preview = resolve_delete_preview(admin, child_admin, principal, [child_obj])
|
|
787
|
+
if preview.blocked:
|
|
788
|
+
# 200 with the rebuilt section and the reason on top: htmx
|
|
789
|
+
# would drop a 4xx body, and a redirect would lose the
|
|
790
|
+
# parent form's unsaved edits.
|
|
791
|
+
html = await render_section(principal, parent_obj, inline, refusal=delete_preview_view(preview, base_path))
|
|
792
|
+
return HTMLResponse(html)
|
|
793
|
+
await maybe_await(child_admin.delete(child_obj))
|
|
794
|
+
|
|
795
|
+
html = await render_section(principal, parent_obj, inline)
|
|
796
|
+
return HTMLResponse(html)
|
|
797
|
+
|
|
798
|
+
return inline_create, inline_update, inline_delete
|