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,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
|
+
}
|