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.
Files changed (104) hide show
  1. polyadmin/__init__.py +47 -0
  2. polyadmin/core/__init__.py +0 -0
  3. polyadmin/core/_async.py +21 -0
  4. polyadmin/core/action.py +135 -0
  5. polyadmin/core/admin.py +129 -0
  6. polyadmin/core/audit.py +65 -0
  7. polyadmin/core/auth.py +52 -0
  8. polyadmin/core/authorization.py +48 -0
  9. polyadmin/core/csrf.py +70 -0
  10. polyadmin/core/dashboard.py +41 -0
  11. polyadmin/core/delete.py +146 -0
  12. polyadmin/core/exporter.py +136 -0
  13. polyadmin/core/field.py +201 -0
  14. polyadmin/core/filter.py +305 -0
  15. polyadmin/core/inline.py +97 -0
  16. polyadmin/core/login.py +110 -0
  17. polyadmin/core/model_admin.py +349 -0
  18. polyadmin/core/page.py +62 -0
  19. polyadmin/core/pagination.py +70 -0
  20. polyadmin/core/query.py +243 -0
  21. polyadmin/core/relation.py +40 -0
  22. polyadmin/core/slug.py +57 -0
  23. polyadmin/core/template_context.py +679 -0
  24. polyadmin/core/widget.py +280 -0
  25. polyadmin/fastapi/__init__.py +3 -0
  26. polyadmin/fastapi/audit.py +52 -0
  27. polyadmin/fastapi/auth.py +112 -0
  28. polyadmin/fastapi/csrf.py +93 -0
  29. polyadmin/fastapi/deletes.py +76 -0
  30. polyadmin/fastapi/errors.py +93 -0
  31. polyadmin/fastapi/handlers.py +798 -0
  32. polyadmin/fastapi/inlines.py +177 -0
  33. polyadmin/fastapi/locale.py +128 -0
  34. polyadmin/fastapi/login.py +110 -0
  35. polyadmin/fastapi/pages.py +97 -0
  36. polyadmin/fastapi/relations.py +264 -0
  37. polyadmin/fastapi/responses.py +55 -0
  38. polyadmin/fastapi/router.py +174 -0
  39. polyadmin/fastapi/static.py +21 -0
  40. polyadmin/i18n/__init__.py +50 -0
  41. polyadmin/i18n/context.py +54 -0
  42. polyadmin/i18n/negotiation.py +56 -0
  43. polyadmin/i18n/setup.py +81 -0
  44. polyadmin/i18n/translator.py +124 -0
  45. polyadmin/locale/fr/LC_MESSAGES/polyadmin.mo +0 -0
  46. polyadmin/locale/fr/LC_MESSAGES/polyadmin.po +486 -0
  47. polyadmin/locale/polyadmin.pot +485 -0
  48. polyadmin/locale/ru/LC_MESSAGES/polyadmin.mo +0 -0
  49. polyadmin/locale/ru/LC_MESSAGES/polyadmin.po +496 -0
  50. polyadmin/templates/admin/base.html +91 -0
  51. polyadmin/templates/admin/components/action_confirm_modal.html +86 -0
  52. polyadmin/templates/admin/components/csrf-field.html +5 -0
  53. polyadmin/templates/admin/components/error_fragment.html +6 -0
  54. polyadmin/templates/admin/components/field.html +60 -0
  55. polyadmin/templates/admin/components/form_wrapper.html +131 -0
  56. polyadmin/templates/admin/components/icons.html +67 -0
  57. polyadmin/templates/admin/components/inline.html +251 -0
  58. polyadmin/templates/admin/components/inline_fragment.html +2 -0
  59. polyadmin/templates/admin/components/list_content.html +54 -0
  60. polyadmin/templates/admin/components/lookup_results.html +19 -0
  61. polyadmin/templates/admin/components/search.html +19 -0
  62. polyadmin/templates/admin/components/toasts.html +151 -0
  63. polyadmin/templates/admin/components/ui/breadcrumb.html +30 -0
  64. polyadmin/templates/admin/components/ui/bulk-actions.html +69 -0
  65. polyadmin/templates/admin/components/ui/calendar.html +175 -0
  66. polyadmin/templates/admin/components/ui/combobox.html +82 -0
  67. polyadmin/templates/admin/components/ui/delete-preview.html +37 -0
  68. polyadmin/templates/admin/components/ui/dropdown-menu.html +71 -0
  69. polyadmin/templates/admin/components/ui/field.html +110 -0
  70. polyadmin/templates/admin/components/ui/filter-panel.html +155 -0
  71. polyadmin/templates/admin/components/ui/locale-switcher.html +30 -0
  72. polyadmin/templates/admin/components/ui/multi-select.html +253 -0
  73. polyadmin/templates/admin/components/ui/pagination.html +81 -0
  74. polyadmin/templates/admin/components/ui/radio-group.html +28 -0
  75. polyadmin/templates/admin/components/ui/select.html +165 -0
  76. polyadmin/templates/admin/components/ui/sidebar.html +175 -0
  77. polyadmin/templates/admin/components/ui/slider.html +22 -0
  78. polyadmin/templates/admin/components/ui/switch.html +36 -0
  79. polyadmin/templates/admin/components/ui/table.html +221 -0
  80. polyadmin/templates/admin/components/ui/theme-toggle.html +33 -0
  81. polyadmin/templates/admin/dashboard.html +35 -0
  82. polyadmin/templates/admin/error.html +33 -0
  83. polyadmin/templates/admin/login.html +94 -0
  84. polyadmin/templates/admin/resource/delete.html +29 -0
  85. polyadmin/templates/admin/resource/delete_selected.html +49 -0
  86. polyadmin/templates/admin/resource/detail.html +78 -0
  87. polyadmin/templates/admin/resource/form.html +5 -0
  88. polyadmin/templates/admin/resource/list.html +5 -0
  89. polyadmin/templates/admin/theme.html +372 -0
  90. polyadmin/templates/admin/widgets/activity.html +8 -0
  91. polyadmin/templates/admin/widgets/chart.html +15 -0
  92. polyadmin/templates/admin/widgets/donut.html +59 -0
  93. polyadmin/templates/admin/widgets/metric.html +1 -0
  94. polyadmin/templates/admin/widgets/progress.html +7 -0
  95. polyadmin/templates/admin/widgets/stat.html +22 -0
  96. polyadmin/templates/admin/widgets/table.html +29 -0
  97. polyadmin/templates/admin/widgets/tabs.html +34 -0
  98. polyadmin/templates/admin/widgets/timeline.html +21 -0
  99. polyadmin/templating.py +528 -0
  100. polyadmin/ui.py +817 -0
  101. polyadmin-0.1.0b1.dist-info/METADATA +239 -0
  102. polyadmin-0.1.0b1.dist-info/RECORD +104 -0
  103. polyadmin-0.1.0b1.dist-info/WHEEL +4 -0
  104. 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