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,679 @@
1
+ """TemplateContext: the data handed to each rendered admin page.
2
+
3
+ One function per view type, all built on `base_context`, so every page gets the
4
+ same admin-wide data (nav, base path, flash messages) without repeating it.
5
+
6
+ Breadcrumbs are translated here, where they are built, in the request's
7
+ locale: the resource's name and the crumbs' own literals go through gettext,
8
+ an object's label is data and does not. Nav labels are left for the sidebar
9
+ template to translate, so each string is translated once.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+ from urllib.parse import quote, urlencode
15
+
16
+ from polyadmin.core.action import DELETE_SELECTED_NAME
17
+ from polyadmin.core.admin import Admin
18
+ from polyadmin.core.delete import (
19
+ DELETE_PREVIEW_SAMPLE,
20
+ ResolvedDeletePreview,
21
+ previews_deletes,
22
+ )
23
+ from polyadmin.core.filter import (
24
+ FILTER_KIND_DATE_RANGE,
25
+ FILTER_KIND_RELATION,
26
+ date_filter_range_values,
27
+ )
28
+ from polyadmin.core.model_admin import ModelAdmin
29
+ from polyadmin.core.pagination import Page
30
+ from polyadmin.core.query import (
31
+ LIST_TOKEN_FIELD,
32
+ RANGE_FOR_FIELD,
33
+ RANGE_FROM_FIELD,
34
+ RANGE_TO_FIELD,
35
+ ListRequest,
36
+ is_sortable,
37
+ links_to_record,
38
+ with_list_token,
39
+ )
40
+ from polyadmin.i18n import gettext, ngettext
41
+
42
+
43
+ def default_permissions(model_admin: ModelAdmin) -> dict[str, bool]:
44
+ """Permissions as if every capability the ModelAdmin declares is also
45
+ authorized -- what you get with no Authorizer configured. Real per-
46
+ principal permissions are computed by the adapter and passed in.
47
+ """
48
+ # Keys are "can_view" etc, not "view"/"update" -- "update" in
49
+ # particular would collide with the dict.update method when
50
+ # accessed via Jinja's dot notation (permissions.update).
51
+ return {
52
+ "can_view": model_admin.can_view,
53
+ "can_create": model_admin.can_create,
54
+ "can_update": model_admin.can_update,
55
+ "can_delete": model_admin.can_delete,
56
+ "can_export": model_admin.can_export,
57
+ }
58
+
59
+
60
+ def _object_label(model_admin: ModelAdmin, obj: Any) -> str:
61
+ """A short label for an object in a breadcrumb trail: the first search_fields
62
+ entry (usually the most identifying), else the first list_display column,
63
+ else the primary key.
64
+ """
65
+ display_fields = list(model_admin.search_fields) or list(model_admin.list_display) or model_admin.get_detail_fields()
66
+ if display_fields:
67
+ field = model_admin.get_field(display_fields[0])
68
+ return str(field.get_value(obj))
69
+ return str(model_admin.get_pk(obj))
70
+
71
+
72
+ # Choices offered by the list view's "Rows per page" control. 25 is the
73
+ # handler default, so it's the one size a URL never has to spell out.
74
+ PAGE_SIZE_CHOICES = (10, 25, 50, 100)
75
+ DEFAULT_PAGE_SIZE = 25
76
+
77
+ _KEEP = object()
78
+
79
+
80
+ def _list_url(
81
+ model_admin: ModelAdmin,
82
+ list_request: ListRequest,
83
+ base_path: str,
84
+ *,
85
+ search: Any = _KEEP,
86
+ filters: Any = _KEEP,
87
+ ordering: Any = _KEEP,
88
+ page: int | None = None,
89
+ page_size: Any = _KEEP,
90
+ ) -> str:
91
+ """One list-view URL, carrying over every parameter of the current request
92
+ except those overridden (pass None to drop one).
93
+
94
+ Every control on the list page is a link needing the same "keep what's
95
+ there, change one thing" rule, so building it once here keeps the templates
96
+ free of query-string assembly.
97
+ """
98
+ search = list_request.search if search is _KEEP else search
99
+ filters = list_request.filters if filters is _KEEP else filters
100
+ ordering = list_request.ordering if ordering is _KEEP else ordering
101
+ page_size = list_request.page_size if page_size is _KEEP else page_size
102
+
103
+ params: list[tuple[str, str]] = []
104
+ if search:
105
+ params.append(("search", search))
106
+ for name, value in (filters or {}).items():
107
+ params.append((f"filter[{name}]", value))
108
+ if ordering:
109
+ params.append(("sort", ordering))
110
+ # page 1 and the default size are the implied state; leaving them out
111
+ # keeps the common URL clean and makes "reset" a bare path.
112
+ if page and page > 1:
113
+ params.append(("page", str(page)))
114
+ if page_size and page_size != DEFAULT_PAGE_SIZE:
115
+ params.append(("page_size", str(page_size)))
116
+ query = f"?{urlencode(params)}" if params else ""
117
+ return f"{base_path}/{model_admin.get_slug()}{query}"
118
+
119
+
120
+ def _filter_form_hidden(list_request: ListRequest, exclude: str) -> list[dict[str, str]]:
121
+ """Every list parameter except the one the form is about, as hidden
122
+ fields. Without them, submitting the form would drop the reader's
123
+ search, sort and other filters.
124
+ """
125
+ hidden = []
126
+ if list_request.search:
127
+ hidden.append({"name": "search", "value": list_request.search})
128
+ if list_request.ordering:
129
+ hidden.append({"name": "sort", "value": list_request.ordering})
130
+ # Page is deliberately dropped: narrowing a list returns to page 1,
131
+ # exactly as the choice links already do.
132
+ for name in sorted(n for n in list_request.filters if n != exclude):
133
+ hidden.append({"name": f"filter[{name}]", "value": list_request.filters[name]})
134
+ return hidden
135
+
136
+
137
+ def _filter_controls(
138
+ model_admin: ModelAdmin,
139
+ list_request: ListRequest,
140
+ base_path: str,
141
+ relation_choices: dict[str, dict[str, Any] | None] | None = None,
142
+ ) -> list[dict[str, Any]]:
143
+ """Per-filter choice lists with precomputed URLs. Each choice is a link, not a
144
+ <select> option, preserving everything else and resetting to page 1.
145
+
146
+ `relation_choices` carries what a relation filter cannot supply itself:
147
+ the target's records, permission-filtered. It is computed by the
148
+ adapter and passed in, because core must not import the adapter. A
149
+ None entry means the principal may not view that target, and the
150
+ filter is dropped rather than shown empty.
151
+ """
152
+ relation_choices = relation_choices or {}
153
+ controls = []
154
+ for filt in model_admin.filters:
155
+ current = list_request.filters.get(filt.name, "")
156
+ others = {n: v for n, v in list_request.filters.items() if n != filt.name}
157
+
158
+ sourced: list[dict[str, str]] = []
159
+ combobox = None
160
+ if filt.control_kind == FILTER_KIND_RELATION:
161
+ from_target = relation_choices.get(filt.name)
162
+ if from_target is None:
163
+ continue # dropped, not emptied -- see relation_filter_choices
164
+ sourced = from_target["choices"]
165
+ combobox = from_target["combobox"]
166
+
167
+ pairs = [
168
+ *filt.choices_with_labels(),
169
+ *((choice["value"], choice["label"]) for choice in sourced),
170
+ ]
171
+ choices = []
172
+ for value, label in pairs:
173
+ combined = {**others, filt.name: value} if value else others
174
+ choices.append({
175
+ "value": value,
176
+ "label": label,
177
+ "selected": value == current,
178
+ "url": _list_url(model_admin, list_request, base_path, filters=combined),
179
+ })
180
+ range_from, range_to = "", ""
181
+ if filt.control_kind == FILTER_KIND_DATE_RANGE:
182
+ values = date_filter_range_values(current)
183
+ if values is not None:
184
+ range_from, range_to = values
185
+ controls.append({
186
+ "name": filt.name,
187
+ "label": filt.label,
188
+ "choices": choices,
189
+ # The toolbar renders each filter as a dropdown trigger, so it
190
+ # needs the active choice's label for the trigger itself and
191
+ # a URL that clears just this filter.
192
+ "active": next((c["label"] for c in choices if c["selected"] and c["value"]), None),
193
+ "clear_url": _list_url(model_admin, list_request, base_path, filters=others),
194
+ # This filter's raw value on this request. The badge counts on
195
+ # it rather than on a selected choice: a custom date range and
196
+ # a combobox selection are both real values that match no
197
+ # declared choice, and counting choices treated those lists as
198
+ # unfiltered.
199
+ "current": current,
200
+ # core.filter's control_kind as a plain string: "" for a link
201
+ # list, "daterange" for the two date inputs, "relation" for
202
+ # the combobox.
203
+ "kind": filt.control_kind,
204
+ # Prefilled into the range inputs when the current value is a
205
+ # range rather than a preset. Both "" otherwise.
206
+ "range_from": range_from,
207
+ "range_to": range_to,
208
+ # The GET form an input-bearing control submits: the list's own
209
+ # path, plus every other parameter as a hidden field, so
210
+ # submitting reproduces the list it was opened from with one
211
+ # thing changed.
212
+ "form_action": f"{base_path}/{model_admin.get_slug()}",
213
+ "hidden": _filter_form_hidden(list_request, filt.name),
214
+ # A relation filter whose field is in autocomplete_fields
215
+ # renders the lookup-backed combobox instead of a link list --
216
+ # the same declaration, and the same control, the form uses.
217
+ "uses_combobox": combobox is not None,
218
+ "lookup_url": (combobox or {}).get("lookup_url", ""),
219
+ "selected_pk": (combobox or {}).get("selected_pk", ""),
220
+ "selected_label": (combobox or {}).get("selected_label", ""),
221
+ })
222
+ return controls
223
+
224
+
225
+ def _filter_choice_url(
226
+ model_admin: ModelAdmin, list_request: ListRequest, base_path: str, name: str, value: str
227
+ ) -> str:
228
+ """The list URL with one filter set to `value`, or cleared when it is
229
+ empty -- every other parameter carried over."""
230
+ filters = {other: v for other, v in list_request.filters.items() if other != name}
231
+ if value:
232
+ filters[name] = value
233
+ return _list_url(model_admin, list_request, base_path, filters=filters)
234
+
235
+
236
+ def _sort_controls(model_admin: ModelAdmin, list_request: ListRequest, base_path: str) -> dict[str, Any]:
237
+ """Per-column ascending/descending URLs and the current direction,
238
+ for the sortable column-header dropdowns."""
239
+ ordering = list_request.ordering or ""
240
+ controls = {}
241
+ for name in model_admin.list_display:
242
+ direction = "asc" if ordering == name else ("desc" if ordering == f"-{name}" else None)
243
+ controls[name] = {
244
+ "direction": direction,
245
+ # Outside sortable_by: the header renders as a plain label.
246
+ "sortable": is_sortable(model_admin, name),
247
+ "asc_url": _list_url(model_admin, list_request, base_path, ordering=name),
248
+ "desc_url": _list_url(model_admin, list_request, base_path, ordering=f"-{name}"),
249
+ }
250
+ return controls
251
+
252
+
253
+ def _page_size_options(model_admin: ModelAdmin, list_request: ListRequest, base_path: str) -> list[dict[str, Any]]:
254
+ """Rows-per-page choices. Changing the size returns to page 1: staying on page
255
+ 7 while quadrupling the size would land the reader somewhere they never
256
+ asked to be.
257
+ """
258
+ return [
259
+ {
260
+ "size": size,
261
+ "selected": size == list_request.page_size,
262
+ "url": _list_url(model_admin, list_request, base_path, page=None, page_size=size),
263
+ }
264
+ for size in PAGE_SIZE_CHOICES
265
+ ]
266
+
267
+
268
+ def _nav_link(key: str, label: str, url: str, icon: str, active_key: str | None) -> dict[str, Any]:
269
+ return {"type": "link", "key": key, "label": label, "url": url, "icon": icon, "active": key == active_key}
270
+
271
+
272
+ # GROUP_ICON is fixed, not per-category: a category is a string, not an
273
+ # object with settings of its own.
274
+ GROUP_ICON = "folder"
275
+
276
+
277
+ def build_nav(admin: Admin, base_path: str, active_key: str | None) -> list[dict[str, Any]]:
278
+ """Ordered sidebar entries: flat links and category groups, interleaved in
279
+ first-registration order. Entries the principal cannot view, and pages with
280
+ show_in_nav=False, are omitted. A group's "active" flag defaults its
281
+ accordion open and is true iff it holds the current page.
282
+ """
283
+ order: list[dict[str, Any]] = []
284
+ groups: dict[str, dict[str, Any]] = {}
285
+
286
+ def add(link: dict[str, Any], category: str | None) -> None:
287
+ if category is None:
288
+ order.append(link)
289
+ return
290
+ group = groups.get(category)
291
+ if group is None:
292
+ group = {"type": "group", "label": category, "icon": GROUP_ICON, "links": []}
293
+ groups[category] = group
294
+ order.append(group)
295
+ group["links"].append(link)
296
+
297
+ for ma in admin.model_admins:
298
+ if ma.can_view:
299
+ key = f"resource:{ma.get_slug()}"
300
+ add(
301
+ _nav_link(key, ma.get_verbose_name(), f"{base_path}/{ma.get_slug()}", ma.icon, active_key),
302
+ ma.category,
303
+ )
304
+ for page in admin.pages:
305
+ if page.show_in_nav:
306
+ key = f"page:{page.path}"
307
+ add(_nav_link(key, page.label, f"{base_path}{page.path}", page.icon, active_key), page.category)
308
+
309
+ for entry in order:
310
+ if entry["type"] == "group":
311
+ entry["active"] = any(link["active"] for link in entry["links"])
312
+ return order
313
+
314
+
315
+ def category_breadcrumb(category: str | None) -> list[dict[str, Any]]:
316
+ """The category crumb, if any: the first segment after the home crumb, never a
317
+ link and never active.
318
+ """
319
+ if not category:
320
+ return []
321
+ return [{"label": gettext(category), "url": None, "active": False}]
322
+
323
+
324
+ def _list_crumb_url(model_admin: ModelAdmin, base_path: str, list_token: str) -> str:
325
+ """The breadcrumb back to the list: the one the page was reached from when
326
+ preserve_filters handed us a token, the bare list otherwise. This is where
327
+ a user actually returns, so it is the crumb that matters most."""
328
+ return list_token or f"{base_path}/{model_admin.get_slug()}"
329
+
330
+
331
+ def base_context(
332
+ admin: Admin,
333
+ *,
334
+ model_admin: ModelAdmin | None = None,
335
+ base_path: str = "/admin",
336
+ messages: list[dict[str, Any]] | None = None,
337
+ breadcrumbs: list[dict[str, Any]] | None = None,
338
+ active_nav_key: str | None = None,
339
+ principal: Any = None,
340
+ csrf_token: str = "",
341
+ list_token: str = "",
342
+ ) -> dict[str, Any]:
343
+ if active_nav_key is None and model_admin is not None:
344
+ active_nav_key = f"resource:{model_admin.get_slug()}"
345
+ return {
346
+ "admin": admin,
347
+ # The list this page was reached from -- preserve_filters
348
+ # (docs/lists.md). Forms post it back in a hidden field.
349
+ "list_token": list_token,
350
+ "model_admin": model_admin,
351
+ "base_path": base_path,
352
+ "messages": messages or [],
353
+ "site_title": admin.site_title,
354
+ "site_logo_url": admin.site_logo_url,
355
+ "breadcrumbs": breadcrumbs or [],
356
+ "nav_items": build_nav(admin, base_path, active_nav_key),
357
+ # The sidebar footer shows who is signed in, so the principal has
358
+ # to reach every page that renders a sidebar -- which is all of
359
+ # them.
360
+ "principal": principal,
361
+ # Needed on every page: base.html renders it as a meta tag and
362
+ # every no-JS form renders it as a hidden field.
363
+ "csrf_token": csrf_token,
364
+ # Whether there is a session to end. Without a login_backend the
365
+ # admin has no logout route, so the control would be dead.
366
+ "can_sign_out": admin.login_backend is not None,
367
+ }
368
+
369
+
370
+ def list_context(
371
+ admin: Admin,
372
+ model_admin: ModelAdmin,
373
+ page: Page,
374
+ *,
375
+ list_request: ListRequest | None = None,
376
+ permissions: dict[str, bool] | None = None,
377
+ relation_permissions: dict[str, bool] | None = None,
378
+ base_path: str = "/admin",
379
+ messages: list[dict[str, Any]] | None = None,
380
+ principal: Any = None,
381
+ csrf_token: str = "",
382
+ relation_filter_choices: dict[str, dict[str, Any] | None] | None = None,
383
+ ) -> dict[str, Any]:
384
+ list_request = list_request or ListRequest()
385
+ # Carries search/filter/sort into the Export links so a download
386
+ # matches what is on screen. page/page_size are excluded: an export is
387
+ # of the whole filtered result.
388
+ export_params: list[tuple[str, str]] = []
389
+ if list_request.search:
390
+ export_params.append(("search", list_request.search))
391
+ for name, value in list_request.filters.items():
392
+ export_params.append((f"filter[{name}]", value))
393
+ if list_request.ordering:
394
+ export_params.append(("sort", list_request.ordering))
395
+ export_query = f"?{urlencode(export_params)}" if export_params else ""
396
+
397
+ filter_controls = _filter_controls(
398
+ model_admin, list_request, base_path, relation_filter_choices
399
+ )
400
+
401
+ breadcrumbs = [
402
+ *category_breadcrumb(model_admin.category),
403
+ {"label": gettext(model_admin.get_verbose_name()), "url": None, "active": True},
404
+ ]
405
+
406
+ return {
407
+ **base_context(admin, principal=principal, csrf_token=csrf_token, model_admin=model_admin, base_path=base_path, messages=messages, breadcrumbs=breadcrumbs),
408
+ "page": page,
409
+ "actions": _action_infos(model_admin, model_admin.get_list_actions()),
410
+ # A previewing resource has something to say before the delete, so
411
+ # the row's Delete leads to the page that says it.
412
+ "previews_deletes": previews_deletes(model_admin),
413
+ # Which cells link to the record -- list_display_links.
414
+ "linked_columns": [name for name in model_admin.list_display if links_to_record(model_admin, name)],
415
+ # "?_list=<this list>" for the pages reached from here, so they lead
416
+ # back into the list as it was left -- preserve_filters. Empty when
417
+ # the ModelAdmin has it off.
418
+ "list_query": (
419
+ f"?{LIST_TOKEN_FIELD}={quote(_list_url(model_admin, list_request, base_path, page=list_request.page), safe='')}"
420
+ if model_admin.preserve_filters
421
+ else ""
422
+ ),
423
+ "list_display": list(model_admin.list_display),
424
+ # "cells", not "values" -- the latter collides with dict.values,
425
+ # the built-in method, when accessed via Jinja's dot notation.
426
+ "rows": [
427
+ {
428
+ "pk": model_admin.get_pk(obj),
429
+ "cells": model_admin.get_list_display_values(obj),
430
+ }
431
+ for obj in page.items
432
+ ],
433
+ "search": list_request.search or "",
434
+ "filters": list_request.filters,
435
+ "filter_controls": filter_controls,
436
+ # The panel's range form field names, so the template never spells
437
+ # a reserved parameter itself.
438
+ "range_for_field": RANGE_FOR_FIELD,
439
+ "range_from_field": RANGE_FROM_FIELD,
440
+ "range_to_field": RANGE_TO_FIELD,
441
+ "sort_controls": _sort_controls(model_admin, list_request, base_path),
442
+ "page_size_options": _page_size_options(model_admin, list_request, base_path),
443
+ # Precomputed for the same reason the filter links are: the
444
+ # template should never assemble a query string.
445
+ "page_urls": {
446
+ "first": _list_url(model_admin, list_request, base_path, page=None),
447
+ "previous": _list_url(model_admin, list_request, base_path, page=page.previous_page) if page.has_previous else None,
448
+ "next": _list_url(model_admin, list_request, base_path, page=page.next_page) if page.has_next else None,
449
+ "last": _list_url(model_admin, list_request, base_path, page=page.num_pages),
450
+ },
451
+ # Keeps sort and page size: those are how you are reading the
452
+ # table, not what you are narrowing it to.
453
+ "reset_url": _list_url(model_admin, list_request, base_path, search=None, filters=None, page=None),
454
+ "has_active_filters": bool(list_request.search or list_request.filters),
455
+ # Badges the Filters trigger, so the panel says how much it hides
456
+ # without being opened. Search is excluded: it has its own visible
457
+ # box.
458
+ "active_filter_count": sum(1 for control in filter_controls if control["current"]),
459
+ "ordering": list_request.ordering or "",
460
+ "export_query": export_query,
461
+ "permissions": permissions or default_permissions(model_admin),
462
+ "relation_permissions": relation_permissions or {},
463
+ "reorderable": model_admin.enable_reordering,
464
+ }
465
+
466
+
467
+ def detail_context(
468
+ admin: Admin,
469
+ model_admin: ModelAdmin,
470
+ obj: Any,
471
+ *,
472
+ permissions: dict[str, bool] | None = None,
473
+ relation_permissions: dict[str, bool] | None = None,
474
+ base_path: str = "/admin",
475
+ messages: list[dict[str, Any]] | None = None,
476
+ principal: Any = None,
477
+ csrf_token: str = "",
478
+ list_token: str = "",
479
+ ) -> dict[str, Any]:
480
+ breadcrumbs = [
481
+ *category_breadcrumb(model_admin.category),
482
+ {"label": gettext(model_admin.get_verbose_name()), "url": _list_crumb_url(model_admin, base_path, list_token)},
483
+ {"label": _object_label(model_admin, obj), "url": None, "active": True},
484
+ ]
485
+ return {
486
+ **base_context(admin, principal=principal, csrf_token=csrf_token, model_admin=model_admin, base_path=base_path, messages=messages, breadcrumbs=breadcrumbs, list_token=list_token),
487
+ "object": obj,
488
+ "detail_fields": model_admin.get_detail_fields(),
489
+ "actions": _action_infos(model_admin, model_admin.get_detail_actions()),
490
+ "permissions": permissions or default_permissions(model_admin),
491
+ "relation_permissions": relation_permissions or {},
492
+ }
493
+
494
+
495
+ def form_context(
496
+ admin: Admin,
497
+ model_admin: ModelAdmin,
498
+ *,
499
+ obj: Any | None = None,
500
+ data: dict[str, Any] | None = None,
501
+ errors: dict[str, list[str]] | None = None,
502
+ non_field_errors: list[str] | None = None,
503
+ relation_options: dict[str, list[tuple[Any, Any]]] | None = None,
504
+ permissions: dict[str, bool] | None = None,
505
+ base_path: str = "/admin",
506
+ messages: list[dict[str, Any]] | None = None,
507
+ principal: Any = None,
508
+ csrf_token: str = "",
509
+ list_token: str = "",
510
+ ) -> dict[str, Any]:
511
+ slug = model_admin.get_slug()
512
+ if obj is not None:
513
+ form_action = f"{base_path}/{slug}/{model_admin.get_pk(obj)}/edit"
514
+ else:
515
+ form_action = f"{base_path}/{slug}/create"
516
+
517
+ breadcrumbs = [
518
+ *category_breadcrumb(model_admin.category),
519
+ {"label": gettext(model_admin.get_verbose_name()), "url": _list_crumb_url(model_admin, base_path, list_token)},
520
+ ]
521
+ if obj is not None:
522
+ breadcrumbs.append({
523
+ "label": _object_label(model_admin, obj),
524
+ "url": with_list_token(f"{base_path}/{slug}/{model_admin.get_pk(obj)}", list_token),
525
+ })
526
+ breadcrumbs.append({"label": gettext("Edit"), "url": None, "active": True})
527
+ else:
528
+ breadcrumbs.append({"label": gettext("New"), "url": None, "active": True})
529
+
530
+ return {
531
+ **base_context(admin, principal=principal, csrf_token=csrf_token, model_admin=model_admin, base_path=base_path, messages=messages, breadcrumbs=breadcrumbs, list_token=list_token),
532
+ "object": obj,
533
+ "data": data,
534
+ "errors": errors or {},
535
+ "non_field_errors": non_field_errors or [],
536
+ "form_fields": model_admin.get_form_fields(),
537
+ # Always at least one group (see ModelAdmin.get_fieldsets), so
538
+ # the template has a single path for the declared and the
539
+ # undeclared case.
540
+ "fieldsets": model_admin.get_fieldsets(),
541
+ "form_action": form_action,
542
+ # save_as: only on an existing record, since there is nothing to copy
543
+ # from on a create form.
544
+ "allow_save_as": model_admin.save_as and obj is not None,
545
+ # prepopulated_fields, for the behaviour in theme.html. Empty on an
546
+ # edit form -- an existing record's slug is a real identifier, and
547
+ # rewriting it from the title is how links rot.
548
+ "prepopulated": (
549
+ {
550
+ target: {"from": list(sources), "unicode": target in model_admin.prepopulated_unicode}
551
+ for target, sources in model_admin.prepopulated_fields.items()
552
+ }
553
+ if obj is None
554
+ else {}
555
+ ),
556
+ "relation_options": relation_options or {},
557
+ # The edit form offers Delete, so it needs the detail page's
558
+ # permission map: otherwise the button renders for a principal the
559
+ # route then rejects.
560
+ "permissions": permissions or default_permissions(model_admin),
561
+ }
562
+
563
+
564
+ def dashboard_context(
565
+ admin: Admin,
566
+ dashboard: Any,
567
+ widgets: list[Any],
568
+ *,
569
+ base_path: str = "/admin",
570
+ messages: list[dict[str, Any]] | None = None,
571
+ principal: Any = None,
572
+ csrf_token: str = "",
573
+ ) -> dict[str, Any]:
574
+ # A single active crumb -- since base.html has no separate <h1>,
575
+ # this is the only page-title element the dashboard gets.
576
+ breadcrumbs = [{"label": gettext(getattr(dashboard, "title", None) or "Dashboard"), "url": None, "active": True}]
577
+ return {
578
+ **base_context(admin, principal=principal, csrf_token=csrf_token, model_admin=None, base_path=base_path, messages=messages, breadcrumbs=breadcrumbs),
579
+ "dashboard": dashboard,
580
+ "widgets": widgets,
581
+ }
582
+
583
+
584
+ def delete_selected_context(
585
+ admin: Admin,
586
+ model_admin: ModelAdmin,
587
+ selection: dict[str, Any],
588
+ preview: Any,
589
+ *,
590
+ base_path: str = "/admin",
591
+ principal: Any = None,
592
+ csrf_token: str = "",
593
+ ) -> dict[str, Any]:
594
+ """The delete_selected confirmation page. `selection` carries objects,
595
+ select_all, pks, list_request, fingerprint, return_to, changed, and the
596
+ already permission-checked `items` (built by the adapter, since core must
597
+ not import one)."""
598
+ slug = model_admin.get_slug()
599
+ objects = selection["objects"]
600
+ breadcrumbs = [
601
+ *category_breadcrumb(model_admin.category),
602
+ {"label": gettext(model_admin.get_verbose_name()), "url": f"{base_path}/{slug}"},
603
+ {"label": gettext("Delete"), "url": None, "active": True},
604
+ ]
605
+ heading = ngettext("Delete %(num)d record?", "Delete %(num)d records?", len(objects)) % {"num": len(objects)}
606
+ return {
607
+ **base_context(admin, principal=principal, csrf_token=csrf_token, model_admin=model_admin, base_path=base_path, breadcrumbs=breadcrumbs),
608
+ "heading": heading,
609
+ "items": selection["items"],
610
+ "more": max(len(objects) - DELETE_PREVIEW_SAMPLE, 0),
611
+ "selection": selection,
612
+ "preview": delete_preview_view(preview, base_path),
613
+ }
614
+
615
+
616
+ def _action_infos(model_admin: ModelAdmin, actions: list[Any]) -> list[dict[str, Any]]:
617
+ """`actions` as the bulk bar and detail page see them -- already resolved
618
+ for the page by ModelAdmin.get_list_actions / get_detail_actions.
619
+ delete_selected on a ModelAdmin that previews deletes gets the server's
620
+ confirmation page instead of the modal (docs/deletes.md)."""
621
+ previews = previews_deletes(model_admin)
622
+ infos = []
623
+ for a in actions:
624
+ preview = previews and a.name == DELETE_SELECTED_NAME
625
+ infos.append({"name": a.name, "label": a.label, "confirm": None if preview else a.confirm, "preview": preview})
626
+ return infos
627
+
628
+
629
+ def delete_preview_view(preview: ResolvedDeletePreview | None, base_path: str) -> dict[str, Any]:
630
+ """The delete preview as the templates see it (docs/deletes.md): headings
631
+ translated, records labelled and linked, nothing the principal may not
632
+ view named. None renders as an empty, unblocked preview."""
633
+ preview = preview or ResolvedDeletePreview()
634
+
635
+ def group(g):
636
+ items = [
637
+ {
638
+ "label": _object_label(g.model_admin, obj),
639
+ "url": f"{base_path}/{g.model_admin.get_slug()}/{g.model_admin.get_pk(obj)}",
640
+ }
641
+ for obj in g.visible
642
+ ]
643
+ items += [{"label": text, "url": None} for text in g.texts]
644
+ heading = gettext("%(label)s (%(num)d)") % {"label": gettext(g.label), "num": g.total}
645
+ return {"heading": heading, "items": items, "more": g.more, "hidden": g.hidden}
646
+
647
+ return {
648
+ "cascades": [group(g) for g in preview.cascades],
649
+ "protected": [group(g) for g in preview.protected],
650
+ "denied": ", ".join(gettext(label) for label in preview.denied_types),
651
+ "blocked": preview.blocked,
652
+ }
653
+
654
+
655
+ def delete_context(
656
+ admin: Admin,
657
+ model_admin: ModelAdmin,
658
+ obj: Any,
659
+ *,
660
+ base_path: str = "/admin",
661
+ messages: list[dict[str, Any]] | None = None,
662
+ principal: Any = None,
663
+ csrf_token: str = "",
664
+ preview: ResolvedDeletePreview | None = None,
665
+ list_token: str = "",
666
+ ) -> dict[str, Any]:
667
+ slug = model_admin.get_slug()
668
+ breadcrumbs = [
669
+ *category_breadcrumb(model_admin.category),
670
+ {"label": gettext(model_admin.get_verbose_name()), "url": _list_crumb_url(model_admin, base_path, list_token)},
671
+ {"label": _object_label(model_admin, obj), "url": with_list_token(f"{base_path}/{slug}/{model_admin.get_pk(obj)}", list_token)},
672
+ {"label": gettext("Delete"), "url": None, "active": True},
673
+ ]
674
+ return {
675
+ **base_context(admin, principal=principal, csrf_token=csrf_token, model_admin=model_admin, base_path=base_path, messages=messages, breadcrumbs=breadcrumbs, list_token=list_token),
676
+ "object": obj,
677
+ "object_label": _object_label(model_admin, obj),
678
+ "preview": delete_preview_view(preview, base_path),
679
+ }