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,264 @@
|
|
|
1
|
+
"""Relation-aware helpers for the FastAPI adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from polyadmin.core._async import maybe_await
|
|
9
|
+
from polyadmin.core.admin import Admin
|
|
10
|
+
from polyadmin.core.authorization import resource_permission
|
|
11
|
+
from polyadmin.core.field import Field
|
|
12
|
+
from polyadmin.core.filter import FILTER_KIND_RELATION
|
|
13
|
+
from polyadmin.core.model_admin import ModelAdmin
|
|
14
|
+
from polyadmin.core.query import ListRequest, alist_objects
|
|
15
|
+
from polyadmin.core.relation import Relation
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _relation_fields(
|
|
19
|
+
model_admin: ModelAdmin, names: list[str]
|
|
20
|
+
) -> Iterator[tuple[str, Field, Relation]]:
|
|
21
|
+
for name in names:
|
|
22
|
+
field = model_admin.get_field(name)
|
|
23
|
+
relation = getattr(field, "relation", None)
|
|
24
|
+
if relation is not None:
|
|
25
|
+
yield name, field, relation
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def compute_relation_permissions(
|
|
29
|
+
admin: Admin, principal: Any, model_admin: ModelAdmin, names: list[str]
|
|
30
|
+
) -> dict[str, bool]:
|
|
31
|
+
"""Whether the current principal may view each relation target
|
|
32
|
+
referenced by `names` -- the admin must not link to an
|
|
33
|
+
object the principal isn't authorized to see.
|
|
34
|
+
"""
|
|
35
|
+
result: dict[str, bool] = {}
|
|
36
|
+
for _, _, relation in _relation_fields(model_admin, names):
|
|
37
|
+
if relation.target in result:
|
|
38
|
+
continue
|
|
39
|
+
try:
|
|
40
|
+
target_admin = admin.get_model_admin(relation.target)
|
|
41
|
+
except KeyError:
|
|
42
|
+
result[relation.target] = False
|
|
43
|
+
continue
|
|
44
|
+
if not target_admin.can_view:
|
|
45
|
+
result[relation.target] = False
|
|
46
|
+
elif admin.authorizer is None:
|
|
47
|
+
result[relation.target] = True
|
|
48
|
+
else:
|
|
49
|
+
result[relation.target] = admin.authorizer.can(
|
|
50
|
+
principal, resource_permission(relation.target, "view"), target_admin
|
|
51
|
+
)
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def compute_relation_options(
|
|
56
|
+
admin: Admin, model_admin: ModelAdmin, obj: Any = None
|
|
57
|
+
) -> dict[str, dict[str, Any]]:
|
|
58
|
+
"""Selectable options for each relation field in `model_admin.form_fields`.
|
|
59
|
+
|
|
60
|
+
For a field listed in `model_admin.autocomplete_fields`, this skips
|
|
61
|
+
loading the target's queryset entirely and only resolves the
|
|
62
|
+
current selection's own label -- the rest of the options are
|
|
63
|
+
fetched on demand from the /lookup route as the user
|
|
64
|
+
types, via components/ui/field.html's combobox. Every other relation
|
|
65
|
+
field keeps populating a same-page <select> from the target's full
|
|
66
|
+
unfiltered queryset, gated only by its static `can_view` -- coarser
|
|
67
|
+
than `compute_relation_permissions`, fine for a same-page selector.
|
|
68
|
+
"""
|
|
69
|
+
autocomplete_names = set(model_admin.autocomplete_fields)
|
|
70
|
+
result: dict[str, dict[str, Any]] = {}
|
|
71
|
+
for name, field, relation in _relation_fields(
|
|
72
|
+
model_admin, model_admin.get_form_fields()
|
|
73
|
+
):
|
|
74
|
+
try:
|
|
75
|
+
target_admin = admin.get_model_admin(relation.target)
|
|
76
|
+
except KeyError:
|
|
77
|
+
continue
|
|
78
|
+
if not target_admin.can_view:
|
|
79
|
+
continue
|
|
80
|
+
display_field = target_admin.get_field(relation.display_field)
|
|
81
|
+
current = field.get_value(obj) if obj is not None else None
|
|
82
|
+
|
|
83
|
+
if name in autocomplete_names and field.field_type in (
|
|
84
|
+
"foreignkey",
|
|
85
|
+
"onetoone",
|
|
86
|
+
):
|
|
87
|
+
selected_pk = target_admin.get_pk(current) if current is not None else None
|
|
88
|
+
selected_label = (
|
|
89
|
+
display_field.get_value(current) if current is not None else None
|
|
90
|
+
)
|
|
91
|
+
result[name] = {
|
|
92
|
+
"options": [],
|
|
93
|
+
"selected_pk": selected_pk,
|
|
94
|
+
"selected_pks": [],
|
|
95
|
+
"autocomplete": True,
|
|
96
|
+
"selected_label": selected_label,
|
|
97
|
+
"lookup_target": relation.target,
|
|
98
|
+
}
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
# unlimited: a non-autocomplete relation renders every choice
|
|
102
|
+
# inline, which is exactly what this widget is for. Going through
|
|
103
|
+
# alist_objects means a list_page target, or one with async hooks,
|
|
104
|
+
# answers this from its own data source like every other list query.
|
|
105
|
+
related_objects, _ = await alist_objects(target_admin, ListRequest(unlimited=True))
|
|
106
|
+
options = [
|
|
107
|
+
(target_admin.get_pk(related), display_field.get_value(related))
|
|
108
|
+
for related in related_objects
|
|
109
|
+
]
|
|
110
|
+
if field.field_type == "manytomany":
|
|
111
|
+
selected_pks = [target_admin.get_pk(related) for related in (current or [])]
|
|
112
|
+
result[name] = {
|
|
113
|
+
"options": options,
|
|
114
|
+
"selected_pk": None,
|
|
115
|
+
"selected_pks": selected_pks,
|
|
116
|
+
"autocomplete": False,
|
|
117
|
+
}
|
|
118
|
+
else:
|
|
119
|
+
selected_pk = target_admin.get_pk(current) if current is not None else None
|
|
120
|
+
result[name] = {
|
|
121
|
+
"options": options,
|
|
122
|
+
"selected_pk": selected_pk,
|
|
123
|
+
"selected_pks": [],
|
|
124
|
+
"autocomplete": False,
|
|
125
|
+
}
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def relation_filter_choices(
|
|
130
|
+
admin: Admin, principal: Any, model_admin: ModelAdmin, field: Any
|
|
131
|
+
) -> list[dict[str, str]] | None:
|
|
132
|
+
"""A relation filter's choice list: every record of the target
|
|
133
|
+
ModelAdmin, as {"value", "label"} pairs.
|
|
134
|
+
|
|
135
|
+
None when the principal may not view the target. The caller drops the
|
|
136
|
+
filter entirely in that case -- an empty filter group reads as a
|
|
137
|
+
broken control, and a reader who may not see organizations should not
|
|
138
|
+
be told they exist. This is the principal-aware check, not
|
|
139
|
+
compute_relation_options' coarser can_view: a filter offers records by
|
|
140
|
+
name, so it has to answer to the same authorizer the target's own list
|
|
141
|
+
does.
|
|
142
|
+
|
|
143
|
+
The queryset is loaded whole and uncapped, exactly as
|
|
144
|
+
compute_relation_options already does for a non-autocomplete relation
|
|
145
|
+
<select>. autocomplete_fields is the answer to a large target, and it
|
|
146
|
+
is the same answer in both places; a cap here and not there would have
|
|
147
|
+
the panel and the form disagree about the same relation.
|
|
148
|
+
"""
|
|
149
|
+
relation = getattr(field, "relation", None)
|
|
150
|
+
if relation is None:
|
|
151
|
+
return None
|
|
152
|
+
allowed = compute_relation_permissions(admin, principal, model_admin, [field.name])
|
|
153
|
+
if not allowed.get(relation.target, False):
|
|
154
|
+
return None
|
|
155
|
+
target_admin = admin.get_model_admin(relation.target)
|
|
156
|
+
if target_admin is None:
|
|
157
|
+
return None
|
|
158
|
+
related_objects, _ = await alist_objects(target_admin, ListRequest(unlimited=True))
|
|
159
|
+
try:
|
|
160
|
+
display_field = target_admin.get_field(relation.display_field)
|
|
161
|
+
except KeyError:
|
|
162
|
+
display_field = None
|
|
163
|
+
choices = []
|
|
164
|
+
for related in related_objects:
|
|
165
|
+
pk = str(target_admin.get_pk(related))
|
|
166
|
+
label = str(display_field.get_value(related)) if display_field is not None else pk
|
|
167
|
+
choices.append({"value": pk, "label": label})
|
|
168
|
+
return choices
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def relation_filter_choices_for(
|
|
172
|
+
admin: Admin,
|
|
173
|
+
principal: Any,
|
|
174
|
+
model_admin: ModelAdmin,
|
|
175
|
+
filters: dict[str, str] | None = None,
|
|
176
|
+
base_path: str = "/admin",
|
|
177
|
+
) -> dict[str, dict[str, Any] | None]:
|
|
178
|
+
"""What each relation-kind filter needs but cannot supply itself,
|
|
179
|
+
keyed by filter name. A None value means "the principal may not view
|
|
180
|
+
this target, drop the filter".
|
|
181
|
+
|
|
182
|
+
Each entry is {"choices": [...], "combobox": {...} | None}: a relation
|
|
183
|
+
in `autocomplete_fields` gets the combobox and no choices, because the
|
|
184
|
+
point of that declaration is never loading the target's queryset into
|
|
185
|
+
the page; every other relation gets the link list.
|
|
186
|
+
|
|
187
|
+
Computed here and passed into `list_context` rather than resolved
|
|
188
|
+
inside it: core must not import the adapter (core/page.py), and this
|
|
189
|
+
needs the adapter's relation-permission check. That is the same
|
|
190
|
+
arrangement `delete_selected_context` already uses for its
|
|
191
|
+
permission-checked item list.
|
|
192
|
+
"""
|
|
193
|
+
filters = filters or {}
|
|
194
|
+
sourced: dict[str, dict[str, Any] | None] = {}
|
|
195
|
+
for filt in model_admin.filters:
|
|
196
|
+
if filt.control_kind != FILTER_KIND_RELATION:
|
|
197
|
+
continue
|
|
198
|
+
try:
|
|
199
|
+
field = model_admin.get_field(filt.name)
|
|
200
|
+
except KeyError:
|
|
201
|
+
sourced[filt.name] = None
|
|
202
|
+
continue
|
|
203
|
+
if filt.name in model_admin.autocomplete_fields:
|
|
204
|
+
if not relation_filter_target_is_viewable(admin, principal, model_admin, field):
|
|
205
|
+
sourced[filt.name] = None
|
|
206
|
+
continue
|
|
207
|
+
sourced[filt.name] = {
|
|
208
|
+
"choices": [],
|
|
209
|
+
"combobox": await relation_filter_combobox(
|
|
210
|
+
admin, principal, model_admin, field, filters.get(filt.name, ""), base_path
|
|
211
|
+
),
|
|
212
|
+
}
|
|
213
|
+
continue
|
|
214
|
+
choices = await relation_filter_choices(admin, principal, model_admin, field)
|
|
215
|
+
sourced[filt.name] = None if choices is None else {"choices": choices, "combobox": None}
|
|
216
|
+
return sourced
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def relation_filter_target_is_viewable(
|
|
220
|
+
admin: Admin, principal: Any, model_admin: ModelAdmin, field: Any
|
|
221
|
+
) -> bool:
|
|
222
|
+
"""Whether the principal may view a relation filter's target, without
|
|
223
|
+
loading any of it. The combobox path needs the permission answer but
|
|
224
|
+
not the records.
|
|
225
|
+
"""
|
|
226
|
+
relation = getattr(field, "relation", None)
|
|
227
|
+
if relation is None:
|
|
228
|
+
return False
|
|
229
|
+
allowed = compute_relation_permissions(admin, principal, model_admin, [field.name])
|
|
230
|
+
return allowed.get(relation.target, False)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def relation_filter_combobox(
|
|
234
|
+
admin: Admin, principal: Any, model_admin: ModelAdmin, field: Any, current: str, base_path: str
|
|
235
|
+
) -> dict[str, str]:
|
|
236
|
+
"""What the panel's combobox needs for a relation filter: the target's
|
|
237
|
+
lookup route, and the label of whatever is currently selected. It
|
|
238
|
+
loads no queryset -- that is the whole point of autocomplete_fields.
|
|
239
|
+
|
|
240
|
+
`current` is the filter's raw value, which is the target's primary key.
|
|
241
|
+
"""
|
|
242
|
+
relation = field.relation
|
|
243
|
+
result = {
|
|
244
|
+
"lookup_url": f"{base_path}/{relation.target}/lookup",
|
|
245
|
+
"selected_pk": "",
|
|
246
|
+
"selected_label": "",
|
|
247
|
+
}
|
|
248
|
+
if not current:
|
|
249
|
+
return result
|
|
250
|
+
target_admin = admin.get_model_admin(relation.target)
|
|
251
|
+
if target_admin is None:
|
|
252
|
+
return result
|
|
253
|
+
# One object, not the queryset: the trigger has to show what is
|
|
254
|
+
# selected or the reader cannot tell what they are filtering by.
|
|
255
|
+
related = await maybe_await(target_admin.get_object(current))
|
|
256
|
+
if related is None:
|
|
257
|
+
return result
|
|
258
|
+
result["selected_pk"] = current
|
|
259
|
+
try:
|
|
260
|
+
display_field = target_admin.get_field(relation.display_field)
|
|
261
|
+
result["selected_label"] = str(display_field.get_value(related))
|
|
262
|
+
except KeyError:
|
|
263
|
+
result["selected_label"] = current
|
|
264
|
+
return result
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""HTMX-aware response helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import Request, Response
|
|
8
|
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
9
|
+
|
|
10
|
+
_FLASH_COOKIE = "admin_messages"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_htmx_request(request: Request) -> bool:
|
|
14
|
+
return request.headers.get("HX-Request") == "true"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def set_flash(response: Response, level: str, text: str) -> None:
|
|
18
|
+
response.set_cookie(
|
|
19
|
+
_FLASH_COOKIE,
|
|
20
|
+
json.dumps([{"level": level, "text": text}]),
|
|
21
|
+
max_age=10,
|
|
22
|
+
httponly=True,
|
|
23
|
+
samesite="lax",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def pop_flash(request: Request) -> list[dict[str, Any]]:
|
|
28
|
+
"""Read pending flash messages. Does not clear the cookie itself --
|
|
29
|
+
callers should call `clear_flash` on whichever response they end up
|
|
30
|
+
returning, once they know they've consumed the messages.
|
|
31
|
+
"""
|
|
32
|
+
raw = request.cookies.get(_FLASH_COOKIE)
|
|
33
|
+
if not raw:
|
|
34
|
+
return []
|
|
35
|
+
try:
|
|
36
|
+
return json.loads(raw)
|
|
37
|
+
except (TypeError, ValueError):
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def clear_flash(response: Response) -> None:
|
|
42
|
+
response.delete_cookie(_FLASH_COOKIE)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def redirect(request: Request, url: str) -> Response:
|
|
46
|
+
"""Redirect that works for both a normal browser navigation and an
|
|
47
|
+
HTMX request: a plain 303 in the former case, an `HX-Redirect`
|
|
48
|
+
response in the latter -- htmx otherwise treats a redirected AJAX
|
|
49
|
+
response as content to swap in, not a page navigation.
|
|
50
|
+
"""
|
|
51
|
+
if is_htmx_request(request):
|
|
52
|
+
response = HTMLResponse("", status_code=200)
|
|
53
|
+
response.headers["HX-Redirect"] = url
|
|
54
|
+
return response
|
|
55
|
+
return RedirectResponse(url, status_code=303)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""FastAPI adapter: mounts an Admin as an APIRouter.
|
|
2
|
+
|
|
3
|
+
app.include_router(create_router(admin, base_path="/admin"), prefix="/admin")
|
|
4
|
+
|
|
5
|
+
`base_path` must match the `prefix` passed to `include_router` — it's
|
|
6
|
+
used to build the links rendered inside templates (nav, pagination,
|
|
7
|
+
redirects). The two aren't derived from each other because the router
|
|
8
|
+
is built before FastAPI knows where it will be mounted.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from html import escape
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, Request
|
|
16
|
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
17
|
+
|
|
18
|
+
from polyadmin.core.admin import Admin
|
|
19
|
+
from polyadmin.core.authorization import DASHBOARD_VIEW
|
|
20
|
+
from polyadmin.core.exporter import CSVExporter, XLSXExporter
|
|
21
|
+
from polyadmin.core.login import LOCALE_PATH, LOGIN_PATH, LOGOUT_PATH
|
|
22
|
+
from polyadmin.fastapi.auth import authorize
|
|
23
|
+
from polyadmin.fastapi.handlers import (
|
|
24
|
+
build_action_handler,
|
|
25
|
+
build_create_handlers,
|
|
26
|
+
build_delete_handlers,
|
|
27
|
+
build_detail_handler,
|
|
28
|
+
build_edit_handlers,
|
|
29
|
+
build_export_handler,
|
|
30
|
+
build_inline_handlers,
|
|
31
|
+
build_list_handler,
|
|
32
|
+
build_lookup_handler,
|
|
33
|
+
)
|
|
34
|
+
from polyadmin.fastapi.inlines import validate_inlines
|
|
35
|
+
from polyadmin.fastapi.locale import build_locale_handler, make_admin_route, switcher_on
|
|
36
|
+
from polyadmin.fastapi.login import build_login_handlers, build_logout_handler
|
|
37
|
+
from polyadmin.fastapi.pages import build_page_handler
|
|
38
|
+
from polyadmin.fastapi.responses import clear_flash, pop_flash
|
|
39
|
+
from polyadmin.fastapi.static import mount_static
|
|
40
|
+
from polyadmin.i18n import I18n, gettext
|
|
41
|
+
from polyadmin.templating import Renderer
|
|
42
|
+
|
|
43
|
+
DEFAULT_EXPORTERS = [CSVExporter(), XLSXExporter()]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_router(
|
|
47
|
+
admin: Admin,
|
|
48
|
+
*,
|
|
49
|
+
base_path: str = "/admin",
|
|
50
|
+
template_dirs: tuple[str | Path, ...] = (),
|
|
51
|
+
static_dir: str | Path | None = None,
|
|
52
|
+
exporters: tuple = tuple(DEFAULT_EXPORTERS),
|
|
53
|
+
) -> APIRouter:
|
|
54
|
+
i18n = I18n.from_admin(admin)
|
|
55
|
+
renderer = Renderer(template_dirs=template_dirs, i18n=i18n, switcher_enabled=admin.locale_switcher)
|
|
56
|
+
# route_class, not a dependency: see polyadmin/fastapi/locale.py.
|
|
57
|
+
router = APIRouter(route_class=make_admin_route(admin, base_path, i18n, renderer))
|
|
58
|
+
|
|
59
|
+
mount_static(router, static_dir=static_dir)
|
|
60
|
+
|
|
61
|
+
# The login routes go on before anything else, and only when the
|
|
62
|
+
# application supplied a backend to make them work. They are the
|
|
63
|
+
# routes that do not authenticate -- requiring a session to reach the
|
|
64
|
+
# page that creates one is a loop -- so they are also the ones that
|
|
65
|
+
# must not be shadowed by a ModelAdmin whose slug happens to be
|
|
66
|
+
# "login". Registering them first is what makes that collision
|
|
67
|
+
# visible rather than a silently unreachable login page.
|
|
68
|
+
if admin.login_backend is not None:
|
|
69
|
+
login_get, login_post = build_login_handlers(admin, renderer, base_path)
|
|
70
|
+
router.add_api_route(LOGIN_PATH, login_get, methods=["GET"], include_in_schema=False)
|
|
71
|
+
router.add_api_route(LOGIN_PATH, login_post, methods=["POST"], include_in_schema=False)
|
|
72
|
+
router.add_api_route(
|
|
73
|
+
LOGOUT_PATH,
|
|
74
|
+
build_logout_handler(admin, base_path),
|
|
75
|
+
methods=["POST"],
|
|
76
|
+
include_in_schema=False,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if switcher_on(admin, i18n):
|
|
80
|
+
router.add_api_route(
|
|
81
|
+
LOCALE_PATH, build_locale_handler(i18n, base_path), methods=["POST"], include_in_schema=False
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@router.get("", include_in_schema=False)
|
|
85
|
+
async def index(request: Request) -> HTMLResponse:
|
|
86
|
+
principal, error = await authorize(admin, request, base_path, DASHBOARD_VIEW)
|
|
87
|
+
if error:
|
|
88
|
+
return error
|
|
89
|
+
|
|
90
|
+
if admin.dashboard is not None:
|
|
91
|
+
widgets = admin.dashboard.get_widgets(principal, admin.authorizer)
|
|
92
|
+
messages = pop_flash(request)
|
|
93
|
+
html = renderer.render_dashboard(
|
|
94
|
+
admin,
|
|
95
|
+
admin.dashboard,
|
|
96
|
+
widgets,
|
|
97
|
+
base_path=base_path,
|
|
98
|
+
messages=messages,
|
|
99
|
+
principal=principal,
|
|
100
|
+
csrf_token=request.state.csrf_token,
|
|
101
|
+
)
|
|
102
|
+
response = HTMLResponse(html)
|
|
103
|
+
clear_flash(response)
|
|
104
|
+
return response
|
|
105
|
+
|
|
106
|
+
# No Dashboard configured -- land on the first viewable resource.
|
|
107
|
+
for model_admin in admin.model_admins:
|
|
108
|
+
if model_admin.can_view:
|
|
109
|
+
return RedirectResponse(f"{base_path}/{model_admin.get_slug()}")
|
|
110
|
+
return HTMLResponse(f"<p>{escape(gettext('No resources registered.'))}</p>")
|
|
111
|
+
|
|
112
|
+
for model_admin in admin.model_admins:
|
|
113
|
+
prefix = f"/{model_admin.get_slug()}"
|
|
114
|
+
|
|
115
|
+
if model_admin.can_view:
|
|
116
|
+
router.add_api_route(
|
|
117
|
+
prefix, build_list_handler(admin, model_admin, renderer, base_path), methods=["GET"]
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if model_admin.can_create:
|
|
121
|
+
create_get, create_post = build_create_handlers(admin, model_admin, renderer, base_path)
|
|
122
|
+
router.add_api_route(f"{prefix}/create", create_get, methods=["GET"])
|
|
123
|
+
router.add_api_route(f"{prefix}/create", create_post, methods=["POST"])
|
|
124
|
+
|
|
125
|
+
if model_admin.can_export:
|
|
126
|
+
for exporter in exporters:
|
|
127
|
+
router.add_api_route(
|
|
128
|
+
f"{prefix}/export/{exporter.format}",
|
|
129
|
+
build_export_handler(admin, model_admin, exporter, base_path),
|
|
130
|
+
methods=["GET"],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if model_admin.can_view:
|
|
134
|
+
router.add_api_route(
|
|
135
|
+
f"{prefix}/lookup",
|
|
136
|
+
build_lookup_handler(admin, model_admin, renderer, base_path),
|
|
137
|
+
methods=["GET"],
|
|
138
|
+
)
|
|
139
|
+
if model_admin.get_actions():
|
|
140
|
+
router.add_api_route(
|
|
141
|
+
f"{prefix}/actions/{{action_name}}",
|
|
142
|
+
build_action_handler(admin, model_admin, renderer, base_path),
|
|
143
|
+
methods=["POST"],
|
|
144
|
+
)
|
|
145
|
+
router.add_api_route(
|
|
146
|
+
f"{prefix}/{{pk}}",
|
|
147
|
+
build_detail_handler(admin, model_admin, renderer, base_path),
|
|
148
|
+
methods=["GET"],
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if model_admin.can_update:
|
|
152
|
+
edit_get, edit_post = build_edit_handlers(admin, model_admin, renderer, base_path)
|
|
153
|
+
router.add_api_route(f"{prefix}/{{pk}}/edit", edit_get, methods=["GET"])
|
|
154
|
+
router.add_api_route(f"{prefix}/{{pk}}/edit", edit_post, methods=["POST"])
|
|
155
|
+
|
|
156
|
+
if model_admin.can_delete:
|
|
157
|
+
delete_get, delete_post, delete_htmx = build_delete_handlers(admin, model_admin, renderer, base_path)
|
|
158
|
+
router.add_api_route(f"{prefix}/{{pk}}/delete", delete_get, methods=["GET"])
|
|
159
|
+
router.add_api_route(f"{prefix}/{{pk}}/delete", delete_post, methods=["POST"])
|
|
160
|
+
router.add_api_route(f"{prefix}/{{pk}}/delete", delete_htmx, methods=["DELETE"])
|
|
161
|
+
|
|
162
|
+
if model_admin.inlines:
|
|
163
|
+
validate_inlines(admin, model_admin)
|
|
164
|
+
inline_create, inline_update, inline_delete = build_inline_handlers(admin, model_admin, renderer, base_path)
|
|
165
|
+
router.add_api_route(f"{prefix}/{{pk}}/inlines/{{child_slug}}", inline_create, methods=["POST"])
|
|
166
|
+
router.add_api_route(f"{prefix}/{{pk}}/inlines/{{child_slug}}/{{child_pk}}", inline_update, methods=["POST"])
|
|
167
|
+
router.add_api_route(f"{prefix}/{{pk}}/inlines/{{child_slug}}/{{child_pk}}", inline_delete, methods=["DELETE"])
|
|
168
|
+
|
|
169
|
+
for page in admin.pages:
|
|
170
|
+
router.add_api_route(
|
|
171
|
+
page.path, build_page_handler(admin, page, renderer, base_path), methods=list(page.methods)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return router
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Static asset mounting for the FastAPI adapter."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter
|
|
7
|
+
from fastapi.staticfiles import StaticFiles
|
|
8
|
+
|
|
9
|
+
FRAMEWORK_STATIC_DIR = Path(__file__).parent.parent / "static"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def mount_static(router: APIRouter, *, static_dir: str | Path | None = None) -> None:
|
|
13
|
+
"""Mount the admin's static assets at `<base_path>/static/*`.
|
|
14
|
+
|
|
15
|
+
Pass `static_dir` to serve an application's own directory (for
|
|
16
|
+
`custom.css`/`custom.js`) instead of the framework's.
|
|
17
|
+
"""
|
|
18
|
+
directory = Path(static_dir) if static_dir else FRAMEWORK_STATIC_DIR
|
|
19
|
+
if not directory.exists():
|
|
20
|
+
return
|
|
21
|
+
router.mount("/static", StaticFiles(directory=str(directory)), name="admin-static")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Internationalisation: the Translator, locale resolution, and the
|
|
2
|
+
helpers host code uses to translate its own strings. See docs/i18n.md."""
|
|
3
|
+
|
|
4
|
+
from polyadmin.i18n.context import (
|
|
5
|
+
N_,
|
|
6
|
+
LocaleContext,
|
|
7
|
+
get_locale,
|
|
8
|
+
gettext,
|
|
9
|
+
ngettext,
|
|
10
|
+
use_locale,
|
|
11
|
+
)
|
|
12
|
+
from polyadmin.i18n.negotiation import (
|
|
13
|
+
match_accept_language,
|
|
14
|
+
match_locale,
|
|
15
|
+
parse_accept_language,
|
|
16
|
+
)
|
|
17
|
+
from polyadmin.i18n.setup import BUILTIN_LOCALE_NAMES, I18n, LocaleOption
|
|
18
|
+
from polyadmin.i18n.translator import (
|
|
19
|
+
DEFAULT_LOCALE,
|
|
20
|
+
DOMAIN,
|
|
21
|
+
FRAMEWORK_LOCALE_DIR,
|
|
22
|
+
PSEUDO_LOCALE,
|
|
23
|
+
GettextTranslator,
|
|
24
|
+
PseudoTranslator,
|
|
25
|
+
Translator,
|
|
26
|
+
pseudo,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"BUILTIN_LOCALE_NAMES",
|
|
31
|
+
"DEFAULT_LOCALE",
|
|
32
|
+
"DOMAIN",
|
|
33
|
+
"FRAMEWORK_LOCALE_DIR",
|
|
34
|
+
"N_",
|
|
35
|
+
"PSEUDO_LOCALE",
|
|
36
|
+
"GettextTranslator",
|
|
37
|
+
"I18n",
|
|
38
|
+
"LocaleContext",
|
|
39
|
+
"LocaleOption",
|
|
40
|
+
"PseudoTranslator",
|
|
41
|
+
"Translator",
|
|
42
|
+
"get_locale",
|
|
43
|
+
"gettext",
|
|
44
|
+
"match_accept_language",
|
|
45
|
+
"match_locale",
|
|
46
|
+
"ngettext",
|
|
47
|
+
"parse_accept_language",
|
|
48
|
+
"pseudo",
|
|
49
|
+
"use_locale",
|
|
50
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""The request's locale, for code that has no request to hand.
|
|
2
|
+
|
|
3
|
+
Set per request by the FastAPI adapter's route class; action handlers,
|
|
4
|
+
validators and ModelAdmin methods read it through these helpers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from contextvars import ContextVar
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from polyadmin.i18n.translator import DEFAULT_LOCALE, Translator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class LocaleContext:
|
|
19
|
+
locale: str
|
|
20
|
+
translator: Translator
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_current: ContextVar[LocaleContext | None] = ContextVar("polyadmin_locale", default=None)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_locale() -> str:
|
|
27
|
+
current = _current.get()
|
|
28
|
+
return current.locale if current else DEFAULT_LOCALE
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def gettext(message: str) -> str:
|
|
32
|
+
current = _current.get()
|
|
33
|
+
return current.translator.gettext(current.locale, message) if current else message
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ngettext(singular: str, plural: str, n: int) -> str:
|
|
37
|
+
current = _current.get()
|
|
38
|
+
if current is None:
|
|
39
|
+
return singular if n == 1 else plural
|
|
40
|
+
return current.translator.ngettext(current.locale, singular, plural, n)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def N_(message: str) -> str:
|
|
44
|
+
"""Mark a string for extraction without translating it."""
|
|
45
|
+
return message
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@contextmanager
|
|
49
|
+
def use_locale(locale: str, translator: Translator) -> Iterator[None]:
|
|
50
|
+
token = _current.set(LocaleContext(locale, translator))
|
|
51
|
+
try:
|
|
52
|
+
yield
|
|
53
|
+
finally:
|
|
54
|
+
_current.reset(token)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Locale matching. Hand-written so Babel stays a dev-only dependency."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from polyadmin.i18n.translator import PSEUDO_LOCALE
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_accept_language(header: str | None) -> list[str]:
|
|
11
|
+
"""Language ranges ordered by q, highest first; q=0 and "*" dropped."""
|
|
12
|
+
ranked = []
|
|
13
|
+
for position, part in enumerate((header or "").split(",")):
|
|
14
|
+
tag, _, params = part.strip().partition(";")
|
|
15
|
+
tag = tag.strip()
|
|
16
|
+
q = 1.0
|
|
17
|
+
for param in params.split(";"):
|
|
18
|
+
key, _, value = param.strip().partition("=")
|
|
19
|
+
if key == "q":
|
|
20
|
+
try:
|
|
21
|
+
q = float(value)
|
|
22
|
+
except ValueError:
|
|
23
|
+
q = 0.0
|
|
24
|
+
if tag and tag != "*" and q > 0:
|
|
25
|
+
ranked.append((-q, position, tag))
|
|
26
|
+
return [tag for _, _, tag in sorted(ranked)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def match_locale(supported: Sequence[str], candidate: str | None) -> str | None:
|
|
30
|
+
"""The supported locale a candidate names, or None.
|
|
31
|
+
|
|
32
|
+
Exact matches win (case-insensitively, "_" or "-"); otherwise a
|
|
33
|
+
regional variant maps onto its base language (fr-CA -> fr). The
|
|
34
|
+
pseudo-locale matches only exactly.
|
|
35
|
+
"""
|
|
36
|
+
if not candidate:
|
|
37
|
+
return None
|
|
38
|
+
wanted = candidate.strip().replace("_", "-").lower()
|
|
39
|
+
by_lower = {locale.lower(): locale for locale in supported}
|
|
40
|
+
if wanted in by_lower:
|
|
41
|
+
return by_lower[wanted]
|
|
42
|
+
base = wanted.split("-")[0]
|
|
43
|
+
match = by_lower.get(base)
|
|
44
|
+
return match if match and match != PSEUDO_LOCALE else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def match_accept_language(supported: Sequence[str], header: str | None) -> str | None:
|
|
48
|
+
"""The best supported locale for an Accept-Language header, or None.
|
|
49
|
+
Ranges are tried one at a time in q order."""
|
|
50
|
+
for tag in parse_accept_language(header):
|
|
51
|
+
if tag.lower() == PSEUDO_LOCALE.lower():
|
|
52
|
+
continue
|
|
53
|
+
match = match_locale(supported, tag)
|
|
54
|
+
if match:
|
|
55
|
+
return match
|
|
56
|
+
return None
|