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
polyadmin/templating.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"""Renderer: turns a ModelAdmin plus data into an HTML page.
|
|
2
|
+
|
|
3
|
+
Templates resolve in three levels: an explicit override, then the application
|
|
4
|
+
directories in the order given, then this package's own. `render_list_fragment`
|
|
5
|
+
and `render_form_fragment` render the inner region alone for htmx swaps,
|
|
6
|
+
sharing the full page's TemplateContext builder so the two never drift apart.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Iterable, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import date, datetime
|
|
15
|
+
from decimal import Decimal
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import jinja2
|
|
20
|
+
|
|
21
|
+
from polyadmin.core.admin import Admin
|
|
22
|
+
from polyadmin.core.inline import INLINE_MULTISELECT_ROWS, Inline
|
|
23
|
+
from polyadmin.core.model_admin import ModelAdmin
|
|
24
|
+
from polyadmin.core.pagination import Page
|
|
25
|
+
from polyadmin.core.query import DEFAULT_EMPTY_VALUE, ListRequest
|
|
26
|
+
from polyadmin.core.template_context import (
|
|
27
|
+
dashboard_context,
|
|
28
|
+
delete_context,
|
|
29
|
+
delete_selected_context,
|
|
30
|
+
detail_context,
|
|
31
|
+
form_context,
|
|
32
|
+
list_context,
|
|
33
|
+
)
|
|
34
|
+
from polyadmin.i18n import I18n, LocaleOption, get_locale, gettext
|
|
35
|
+
from polyadmin.ui import ui
|
|
36
|
+
|
|
37
|
+
# Imported lazily, not at module level: polyadmin.fastapi.__init__ imports
|
|
38
|
+
# router -> handlers -> Renderer, so an eager import here would close the
|
|
39
|
+
# cycle.
|
|
40
|
+
|
|
41
|
+
FRAMEWORK_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
42
|
+
|
|
43
|
+
_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
44
|
+
_ISO_DATETIME = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def iso_date(value: Any) -> str | None:
|
|
48
|
+
"""YYYY-MM-DD, the only form the browser-side formatter reads for a date."""
|
|
49
|
+
if isinstance(value, datetime):
|
|
50
|
+
return value.date().isoformat()
|
|
51
|
+
if isinstance(value, date):
|
|
52
|
+
return value.isoformat()
|
|
53
|
+
if isinstance(value, str) and _ISO_DATE.match(value):
|
|
54
|
+
return value
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def iso_datetime(value: Any) -> str | None:
|
|
59
|
+
"""ISO 8601 with its offset when aware; a naive value stays naive and is
|
|
60
|
+
shown as wall-clock time.
|
|
61
|
+
"""
|
|
62
|
+
if isinstance(value, datetime):
|
|
63
|
+
return value.isoformat(timespec="seconds")
|
|
64
|
+
if isinstance(value, str) and _ISO_DATETIME.match(value):
|
|
65
|
+
return value
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def form_value(value: Any, field_type: str) -> str:
|
|
70
|
+
"""A field's value as its form control's value attribute.
|
|
71
|
+
|
|
72
|
+
A date or datetime-local input discards anything but its own ISO form
|
|
73
|
+
-- str() of an aware datetime carries an offset and a space -- so a
|
|
74
|
+
date is written as YYYY-MM-DD and a datetime as YYYY-MM-DDTHH:MM, its
|
|
75
|
+
wall-clock time, as the input shows it. None is empty; anything else
|
|
76
|
+
(a string posted back after a validation error) is shown as given.
|
|
77
|
+
"""
|
|
78
|
+
if value is None:
|
|
79
|
+
return ""
|
|
80
|
+
if field_type == "date" and isinstance(value, date):
|
|
81
|
+
return (value.date() if isinstance(value, datetime) else value).isoformat()
|
|
82
|
+
if field_type == "datetime" and isinstance(value, datetime):
|
|
83
|
+
# isoformat, not strftime: glibc's %Y does not zero-pad years below 1000.
|
|
84
|
+
return value.replace(tzinfo=None).isoformat(timespec="minutes")
|
|
85
|
+
return str(value)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def decimal_display(value: Any) -> str:
|
|
89
|
+
"""Fixed-point text for a decimal value -- never the scientific notation
|
|
90
|
+
plain str() can fall into (a Decimal built from scientific-notation
|
|
91
|
+
input, or any sufficiently large/small float), which would throw off
|
|
92
|
+
the browser-side formatter's fraction-digit count.
|
|
93
|
+
|
|
94
|
+
A Decimal is shown exactly as the host gave it -- format(value, "f")
|
|
95
|
+
only changes notation, never precision, so a host's Decimal("12.50")
|
|
96
|
+
keeps its two places. A float carries no such intended precision (it
|
|
97
|
+
is what Field.parse_form_value hands back for the "decimal" field
|
|
98
|
+
type -- see core/field.py), so it is shown with the shortest digit
|
|
99
|
+
string that round-trips back to the same float, in fixed-point, with
|
|
100
|
+
no trailing ".0" -- the same contract as Go's decimalText
|
|
101
|
+
(fiber/render_helpers.go), which uses strconv.FormatFloat(f, 'f', -1,
|
|
102
|
+
64). Anything else (an int) falls back to plain str().
|
|
103
|
+
"""
|
|
104
|
+
if isinstance(value, Decimal):
|
|
105
|
+
return format(value, "f")
|
|
106
|
+
if isinstance(value, float):
|
|
107
|
+
return _shortest_fixed_point(value)
|
|
108
|
+
return str(value)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _shortest_fixed_point(value: float) -> str:
|
|
112
|
+
"""The shortest decimal string that round-trips back to `value`, in
|
|
113
|
+
fixed-point notation, with no forced trailing zero.
|
|
114
|
+
|
|
115
|
+
repr(value) is Python's own shortest round-tripping form, but it can
|
|
116
|
+
be scientific notation for a large/small magnitude (repr(1e21) ==
|
|
117
|
+
"1e+21"); routing it through Decimal expands that to fixed-point
|
|
118
|
+
without losing or adding digits. What Decimal's own "f" format does
|
|
119
|
+
add back is a trailing ".0" for a whole number (Decimal("1E+2") formats
|
|
120
|
+
as "100", but Decimal("100.0") -- what repr(100.0) produces -- formats
|
|
121
|
+
as "100.0"), so a fractional part of all zeros is stripped afterwards.
|
|
122
|
+
"""
|
|
123
|
+
text = format(Decimal(repr(value)), "f")
|
|
124
|
+
if "." in text:
|
|
125
|
+
text = text.rstrip("0").rstrip(".")
|
|
126
|
+
return text
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class LocaleSwitcher:
|
|
131
|
+
"""The language switcher's data; None when it is off."""
|
|
132
|
+
|
|
133
|
+
options: list[LocaleOption]
|
|
134
|
+
current: str
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Renderer:
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
*,
|
|
141
|
+
template_dirs: Iterable[str | Path] = (),
|
|
142
|
+
i18n: I18n | None = None,
|
|
143
|
+
switcher_enabled: bool = False,
|
|
144
|
+
) -> None:
|
|
145
|
+
self.switcher_enabled = switcher_enabled
|
|
146
|
+
self.i18n = i18n or I18n.from_admin(Admin())
|
|
147
|
+
search_dirs = [str(d) for d in template_dirs] + [str(FRAMEWORK_TEMPLATES_DIR)]
|
|
148
|
+
loader = jinja2.FileSystemLoader(search_dirs)
|
|
149
|
+
# One environment per locale: the i18n extension installs gettext
|
|
150
|
+
# per environment, and the request's locale picks one (see `env`).
|
|
151
|
+
self._envs = {locale: self._make_env(loader, locale) for locale in self.i18n.supported}
|
|
152
|
+
|
|
153
|
+
def _make_env(self, loader: jinja2.BaseLoader, locale: str) -> jinja2.Environment:
|
|
154
|
+
env = jinja2.Environment(
|
|
155
|
+
loader=loader,
|
|
156
|
+
autoescape=jinja2.select_autoescape(["html"]),
|
|
157
|
+
trim_blocks=True,
|
|
158
|
+
lstrip_blocks=True,
|
|
159
|
+
extensions=["jinja2.ext.i18n"],
|
|
160
|
+
)
|
|
161
|
+
translator = self.i18n.translator
|
|
162
|
+
|
|
163
|
+
# Our own callables, not Jinja's newstyle wrappers: those mark a
|
|
164
|
+
# translation as Markup, which let a host string through `_()`
|
|
165
|
+
# unescaped, and always %-format, which broke on a label containing
|
|
166
|
+
# "%". These return plain text, so autoescape escapes it at output
|
|
167
|
+
# and `|tojson` sees the raw string. `_()` formats only when given
|
|
168
|
+
# arguments -- a literal % needs %% only then, as with Go's t.
|
|
169
|
+
def translate(message: str, **variables: Any) -> str:
|
|
170
|
+
text = str(translator.gettext(locale, message))
|
|
171
|
+
return text % variables if variables else text
|
|
172
|
+
|
|
173
|
+
def translate_plural(singular: str, plural: str, num: int, **variables: Any) -> str:
|
|
174
|
+
variables.setdefault("num", num)
|
|
175
|
+
return str(translator.ngettext(locale, singular, plural, num)) % variables
|
|
176
|
+
|
|
177
|
+
# newstyle=False installs them as they are. `_` is the extension's
|
|
178
|
+
# alias for `gettext`; pgettext/npgettext are left unset (no template
|
|
179
|
+
# uses them), as is `{% trans %}`, which nothing uses either.
|
|
180
|
+
env.install_gettext_callables(translate, translate_plural, newstyle=False)
|
|
181
|
+
# A global rather than a per-template import, so overrides and
|
|
182
|
+
# custom page/widget templates style with the same vocabulary for
|
|
183
|
+
# free.
|
|
184
|
+
env.globals["ui"] = ui
|
|
185
|
+
# The macro default in components/field.html.
|
|
186
|
+
env.globals["DEFAULT_EMPTY_VALUE"] = DEFAULT_EMPTY_VALUE
|
|
187
|
+
# How many options a tabular inline's listbox shows before it
|
|
188
|
+
# scrolls. Four keeps the row near the height of the controls
|
|
189
|
+
# beside it.
|
|
190
|
+
env.globals["INLINE_MULTISELECT_ROWS"] = INLINE_MULTISELECT_ROWS
|
|
191
|
+
env.globals["locale"] = locale
|
|
192
|
+
env.globals["locale_switcher"] = self._switcher(locale)
|
|
193
|
+
env.filters["iso_date"] = iso_date
|
|
194
|
+
env.filters["iso_datetime"] = iso_datetime
|
|
195
|
+
env.filters["decimal_display"] = decimal_display
|
|
196
|
+
env.filters["form_value"] = form_value
|
|
197
|
+
return env
|
|
198
|
+
|
|
199
|
+
def _switcher(self, locale: str) -> LocaleSwitcher | None:
|
|
200
|
+
# Set by create_router from Admin.locale_switcher; a bare Renderer
|
|
201
|
+
# (tests) shows none.
|
|
202
|
+
if not self.switcher_enabled or len(self.i18n.supported) < 2:
|
|
203
|
+
return None
|
|
204
|
+
return LocaleSwitcher(options=self.i18n.options(), current=locale)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def env(self) -> jinja2.Environment:
|
|
208
|
+
"""The environment for the current request's locale."""
|
|
209
|
+
return self._envs.get(get_locale()) or self._envs[self.i18n.default]
|
|
210
|
+
|
|
211
|
+
def render(self, template_name: str, context: dict[str, Any]) -> str:
|
|
212
|
+
return self.env.get_template(template_name).render(**context)
|
|
213
|
+
|
|
214
|
+
def render_candidates(self, candidates: Sequence[str], context: dict[str, Any]) -> str:
|
|
215
|
+
return self.env.select_template(candidates).render(**context)
|
|
216
|
+
|
|
217
|
+
def render_list(
|
|
218
|
+
self,
|
|
219
|
+
admin: Admin,
|
|
220
|
+
model_admin: ModelAdmin,
|
|
221
|
+
page: Page,
|
|
222
|
+
*,
|
|
223
|
+
list_request: ListRequest | None = None,
|
|
224
|
+
permissions: dict[str, bool] | None = None,
|
|
225
|
+
relation_permissions: dict[str, bool] | None = None,
|
|
226
|
+
base_path: str = "/admin",
|
|
227
|
+
messages: list[dict[str, Any]] | None = None,
|
|
228
|
+
principal: Any = None,
|
|
229
|
+
csrf_token: str = "",
|
|
230
|
+
relation_filter_choices: dict[str, Any] | None = None,
|
|
231
|
+
) -> str:
|
|
232
|
+
context = list_context(
|
|
233
|
+
admin,
|
|
234
|
+
model_admin,
|
|
235
|
+
page,
|
|
236
|
+
list_request=list_request,
|
|
237
|
+
permissions=permissions,
|
|
238
|
+
relation_permissions=relation_permissions,
|
|
239
|
+
base_path=base_path,
|
|
240
|
+
messages=messages,
|
|
241
|
+
principal=principal,
|
|
242
|
+
csrf_token=csrf_token,
|
|
243
|
+
relation_filter_choices=relation_filter_choices,
|
|
244
|
+
)
|
|
245
|
+
return self.render_candidates(model_admin.get_template_candidates("list"), context)
|
|
246
|
+
|
|
247
|
+
def render_list_fragment(
|
|
248
|
+
self,
|
|
249
|
+
admin: Admin,
|
|
250
|
+
model_admin: ModelAdmin,
|
|
251
|
+
page: Page,
|
|
252
|
+
*,
|
|
253
|
+
list_request: ListRequest | None = None,
|
|
254
|
+
permissions: dict[str, bool] | None = None,
|
|
255
|
+
relation_permissions: dict[str, bool] | None = None,
|
|
256
|
+
base_path: str = "/admin",
|
|
257
|
+
principal: Any = None,
|
|
258
|
+
csrf_token: str = "",
|
|
259
|
+
relation_filter_choices: dict[str, Any] | None = None,
|
|
260
|
+
) -> str:
|
|
261
|
+
context = list_context(
|
|
262
|
+
admin,
|
|
263
|
+
model_admin,
|
|
264
|
+
page,
|
|
265
|
+
list_request=list_request,
|
|
266
|
+
permissions=permissions,
|
|
267
|
+
relation_permissions=relation_permissions,
|
|
268
|
+
base_path=base_path,
|
|
269
|
+
principal=principal,
|
|
270
|
+
csrf_token=csrf_token,
|
|
271
|
+
relation_filter_choices=relation_filter_choices,
|
|
272
|
+
)
|
|
273
|
+
return self.render("admin/components/list_content.html", context)
|
|
274
|
+
|
|
275
|
+
def render_detail(
|
|
276
|
+
self,
|
|
277
|
+
admin: Admin,
|
|
278
|
+
model_admin: ModelAdmin,
|
|
279
|
+
obj: Any,
|
|
280
|
+
*,
|
|
281
|
+
principal: Any = None,
|
|
282
|
+
csrf_token: str = "",
|
|
283
|
+
permissions: dict[str, bool] | None = None,
|
|
284
|
+
relation_permissions: dict[str, bool] | None = None,
|
|
285
|
+
base_path: str = "/admin",
|
|
286
|
+
messages: list[dict[str, Any]] | None = None,
|
|
287
|
+
list_token: str = "",
|
|
288
|
+
inlines: list[dict[str, Any]] | None = None,
|
|
289
|
+
) -> str:
|
|
290
|
+
context = detail_context(
|
|
291
|
+
admin,
|
|
292
|
+
model_admin,
|
|
293
|
+
obj,
|
|
294
|
+
permissions=permissions,
|
|
295
|
+
relation_permissions=relation_permissions,
|
|
296
|
+
base_path=base_path,
|
|
297
|
+
messages=messages,
|
|
298
|
+
principal=principal,
|
|
299
|
+
csrf_token=csrf_token,
|
|
300
|
+
list_token=list_token,
|
|
301
|
+
)
|
|
302
|
+
context["inlines"] = inlines or []
|
|
303
|
+
context["wide_body"] = _wide_body(context["inlines"])
|
|
304
|
+
context["history"] = _history_for(admin, model_admin, obj)
|
|
305
|
+
return self.render_candidates(model_admin.get_template_candidates("detail"), context)
|
|
306
|
+
|
|
307
|
+
def render_form(
|
|
308
|
+
self,
|
|
309
|
+
admin: Admin,
|
|
310
|
+
model_admin: ModelAdmin,
|
|
311
|
+
*,
|
|
312
|
+
principal: Any = None,
|
|
313
|
+
csrf_token: str = "",
|
|
314
|
+
obj: Any | None = None,
|
|
315
|
+
data: dict[str, Any] | None = None,
|
|
316
|
+
errors: dict[str, list[str]] | None = None,
|
|
317
|
+
non_field_errors: list[str] | None = None,
|
|
318
|
+
relation_options: dict[str, list[tuple[Any, Any]]] | None = None,
|
|
319
|
+
base_path: str = "/admin",
|
|
320
|
+
messages: list[dict[str, Any]] | None = None,
|
|
321
|
+
list_token: str = "",
|
|
322
|
+
inlines: list[dict[str, Any]] | None = None,
|
|
323
|
+
) -> str:
|
|
324
|
+
from polyadmin.fastapi.auth import compute_permissions
|
|
325
|
+
|
|
326
|
+
context = form_context(
|
|
327
|
+
admin,
|
|
328
|
+
model_admin,
|
|
329
|
+
obj=obj,
|
|
330
|
+
data=data,
|
|
331
|
+
errors=errors,
|
|
332
|
+
non_field_errors=non_field_errors,
|
|
333
|
+
relation_options=relation_options,
|
|
334
|
+
permissions=compute_permissions(admin, principal, model_admin, obj),
|
|
335
|
+
base_path=base_path,
|
|
336
|
+
messages=messages,
|
|
337
|
+
principal=principal,
|
|
338
|
+
csrf_token=csrf_token,
|
|
339
|
+
list_token=list_token,
|
|
340
|
+
)
|
|
341
|
+
context["inlines"] = inlines or []
|
|
342
|
+
context["wide_body"] = _wide_body(context["inlines"])
|
|
343
|
+
return self.render_candidates(model_admin.get_template_candidates("form"), context)
|
|
344
|
+
|
|
345
|
+
def render_form_fragment(
|
|
346
|
+
self,
|
|
347
|
+
admin: Admin,
|
|
348
|
+
model_admin: ModelAdmin,
|
|
349
|
+
*,
|
|
350
|
+
principal: Any = None,
|
|
351
|
+
csrf_token: str = "",
|
|
352
|
+
obj: Any | None = None,
|
|
353
|
+
data: dict[str, Any] | None = None,
|
|
354
|
+
errors: dict[str, list[str]] | None = None,
|
|
355
|
+
non_field_errors: list[str] | None = None,
|
|
356
|
+
relation_options: dict[str, list[tuple[Any, Any]]] | None = None,
|
|
357
|
+
base_path: str = "/admin",
|
|
358
|
+
inlines: list[dict[str, Any]] | None = None,
|
|
359
|
+
) -> str:
|
|
360
|
+
from polyadmin.fastapi.auth import compute_permissions
|
|
361
|
+
|
|
362
|
+
context = form_context(
|
|
363
|
+
admin,
|
|
364
|
+
model_admin,
|
|
365
|
+
obj=obj,
|
|
366
|
+
data=data,
|
|
367
|
+
errors=errors,
|
|
368
|
+
non_field_errors=non_field_errors,
|
|
369
|
+
relation_options=relation_options,
|
|
370
|
+
permissions=compute_permissions(admin, principal, model_admin, obj),
|
|
371
|
+
base_path=base_path,
|
|
372
|
+
principal=principal,
|
|
373
|
+
csrf_token=csrf_token,
|
|
374
|
+
)
|
|
375
|
+
context["inlines"] = inlines or []
|
|
376
|
+
return self.render("admin/components/form_wrapper.html", context)
|
|
377
|
+
|
|
378
|
+
def render_inline_fragment(
|
|
379
|
+
self,
|
|
380
|
+
admin: Admin,
|
|
381
|
+
inline: Inline,
|
|
382
|
+
sections: list[dict[str, Any]],
|
|
383
|
+
*,
|
|
384
|
+
base_path: str = "/admin",
|
|
385
|
+
refusal: dict[str, Any] | None = None,
|
|
386
|
+
) -> str:
|
|
387
|
+
"""Renders one inline section standalone: the response body for the inline
|
|
388
|
+
create/update/delete routes, swapped into `#inline-{child_slug}` by
|
|
389
|
+
htmx. `sections` is the parent's awaited inline context (built in edit mode,
|
|
390
|
+
with any redisplay already applied); `refusal` explains a Remove the
|
|
391
|
+
child's delete_preview blocked.
|
|
392
|
+
"""
|
|
393
|
+
section = next(s for s in sections if s["slug"] == inline.child)
|
|
394
|
+
return self.render(
|
|
395
|
+
"admin/components/inline_fragment.html",
|
|
396
|
+
{"admin": admin, "base_path": base_path, "inline": section, "refusal": refusal},
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def render_dashboard(
|
|
400
|
+
self,
|
|
401
|
+
admin: Admin,
|
|
402
|
+
dashboard: Any,
|
|
403
|
+
widgets: list[Any],
|
|
404
|
+
*,
|
|
405
|
+
base_path: str = "/admin",
|
|
406
|
+
messages: list[dict[str, Any]] | None = None,
|
|
407
|
+
principal: Any = None,
|
|
408
|
+
csrf_token: str = "",
|
|
409
|
+
) -> str:
|
|
410
|
+
context = dashboard_context(admin, dashboard, widgets, base_path=base_path, messages=messages, principal=principal, csrf_token=csrf_token)
|
|
411
|
+
return self.render("admin/dashboard.html", context)
|
|
412
|
+
|
|
413
|
+
def render_login(
|
|
414
|
+
self,
|
|
415
|
+
admin: Admin,
|
|
416
|
+
*,
|
|
417
|
+
csrf_token: str = "",
|
|
418
|
+
identifier: str = "",
|
|
419
|
+
error: str = "",
|
|
420
|
+
notice: str = "",
|
|
421
|
+
base_path: str = "",
|
|
422
|
+
) -> str:
|
|
423
|
+
"""The login page. Deliberately not built on base_context: there is no
|
|
424
|
+
principal (that is the point), no nav, and no trail to sit in.
|
|
425
|
+
"""
|
|
426
|
+
return self.render(
|
|
427
|
+
"admin/login.html",
|
|
428
|
+
{
|
|
429
|
+
"csrf_token": csrf_token,
|
|
430
|
+
"site_title": admin.site_title,
|
|
431
|
+
"site_logo_url": admin.site_logo_url,
|
|
432
|
+
"identifier": identifier,
|
|
433
|
+
"error": error,
|
|
434
|
+
"notice": notice,
|
|
435
|
+
"base_path": base_path,
|
|
436
|
+
},
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
def render_lookup(self, options: list[tuple[Any, Any]]) -> str:
|
|
440
|
+
return self.render("admin/components/lookup_results.html", {"options": options})
|
|
441
|
+
|
|
442
|
+
def render_delete(
|
|
443
|
+
self,
|
|
444
|
+
admin: Admin,
|
|
445
|
+
model_admin: ModelAdmin,
|
|
446
|
+
obj: Any,
|
|
447
|
+
*,
|
|
448
|
+
base_path: str = "/admin",
|
|
449
|
+
messages: list[dict[str, Any]] | None = None,
|
|
450
|
+
principal: Any = None,
|
|
451
|
+
csrf_token: str = "",
|
|
452
|
+
preview: Any = None,
|
|
453
|
+
list_token: str = "",
|
|
454
|
+
) -> str:
|
|
455
|
+
context = delete_context(admin, model_admin, obj, base_path=base_path, messages=messages, principal=principal, csrf_token=csrf_token, preview=preview, list_token=list_token)
|
|
456
|
+
return self.render_candidates(model_admin.get_template_candidates("delete"), context)
|
|
457
|
+
|
|
458
|
+
def render_delete_selected(
|
|
459
|
+
self,
|
|
460
|
+
admin: Admin,
|
|
461
|
+
model_admin: ModelAdmin,
|
|
462
|
+
selection: dict[str, Any],
|
|
463
|
+
preview: Any,
|
|
464
|
+
*,
|
|
465
|
+
base_path: str = "/admin",
|
|
466
|
+
principal: Any = None,
|
|
467
|
+
csrf_token: str = "",
|
|
468
|
+
) -> str:
|
|
469
|
+
context = delete_selected_context(admin, model_admin, selection, preview, base_path=base_path, principal=principal, csrf_token=csrf_token)
|
|
470
|
+
return self.render_candidates(model_admin.get_template_candidates("delete_selected"), context)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# A summary of recent activity, not an audit browser: a logger wanting the
|
|
474
|
+
# full trail exposed can surface it where it already lives.
|
|
475
|
+
HISTORY_LIMIT = 10
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _wide_body(inlines) -> bool:
|
|
479
|
+
"""Whether any inline section is tabular. A table needs more than the max-w-xl
|
|
480
|
+
a column of form fields wants -- squeezing one in is what clipped its row
|
|
481
|
+
actions -- so such a page gets ui("page", "body-wide").
|
|
482
|
+
"""
|
|
483
|
+
return any(inline.get("layout") == "tabular" for inline in inlines or [])
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _audit_action(action: str) -> str:
|
|
487
|
+
"""How the History panel names what happened. The framework's own verbs are
|
|
488
|
+
translated; anything else is the name of the Action that ran, an
|
|
489
|
+
identifier stored in the log, and stays as it is.
|
|
490
|
+
"""
|
|
491
|
+
from polyadmin.core.audit import AUDIT_CREATE, AUDIT_DELETE, AUDIT_UPDATE
|
|
492
|
+
|
|
493
|
+
verbs = {AUDIT_CREATE: gettext("create"), AUDIT_UPDATE: gettext("update"), AUDIT_DELETE: gettext("delete")}
|
|
494
|
+
return verbs.get(action, action)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _history_for(admin: Any, model_admin: Any, obj: Any) -> list[dict[str, str]]:
|
|
498
|
+
"""The record's recent audit entries, or [] when no logger is configured or
|
|
499
|
+
the one configured cannot read back. Built in the request's locale:
|
|
500
|
+
"system" and the framework's verbs are translated here; who did it, and
|
|
501
|
+
when, are data.
|
|
502
|
+
"""
|
|
503
|
+
from polyadmin.core.audit import AuditReader
|
|
504
|
+
|
|
505
|
+
reader = admin.audit_logger
|
|
506
|
+
if reader is None or not isinstance(reader, AuditReader):
|
|
507
|
+
return []
|
|
508
|
+
try:
|
|
509
|
+
entries = reader.history(model_admin.get_slug(), model_admin.get_pk(obj), HISTORY_LIMIT)
|
|
510
|
+
except Exception as exc: # noqa: BLE001
|
|
511
|
+
# A history panel is not worth failing a page render over.
|
|
512
|
+
logging.getLogger("polyadmin").warning(
|
|
513
|
+
"audit history unavailable for %s: %s", model_admin.get_slug(), exc
|
|
514
|
+
)
|
|
515
|
+
return []
|
|
516
|
+
rendered = []
|
|
517
|
+
system = gettext("system")
|
|
518
|
+
for entry in entries:
|
|
519
|
+
who = system
|
|
520
|
+
principal = entry.principal
|
|
521
|
+
if principal is not None:
|
|
522
|
+
who = getattr(principal, "display_name", None) or str(
|
|
523
|
+
getattr(principal, "id", "") or system
|
|
524
|
+
)
|
|
525
|
+
rendered.append(
|
|
526
|
+
{"when": entry.at.strftime("%Y-%m-%d %H:%M"), "who": who, "what": _audit_action(entry.action)}
|
|
527
|
+
)
|
|
528
|
+
return rendered
|