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,177 @@
|
|
|
1
|
+
"""Inline-aware helpers for the FastAPI adapter -- builds the render
|
|
2
|
+
context for a ModelAdmin's declared `inlines` (see core/inline.py) and
|
|
3
|
+
validates those declarations at router-mount time.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from polyadmin.core.admin import Admin
|
|
10
|
+
from polyadmin.core.authorization import resource_permission
|
|
11
|
+
from polyadmin.core.inline import Inline, afilter_inline_children
|
|
12
|
+
from polyadmin.core.model_admin import ModelAdmin
|
|
13
|
+
from polyadmin.fastapi.auth import compute_permissions
|
|
14
|
+
from polyadmin.fastapi.relations import compute_relation_options
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def inline_label(admin: Admin, inline: Inline) -> str:
|
|
18
|
+
child_admin = admin.get_model_admin(inline.child)
|
|
19
|
+
return inline.label or f"{child_admin.get_verbose_name()}s"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_inlines(admin: Admin, model_admin: ModelAdmin) -> None:
|
|
23
|
+
"""The two startup invariants an `Inline` declaration must satisfy
|
|
24
|
+
-- run once per registered ModelAdmin at router-mount time (after
|
|
25
|
+
every ModelAdmin is registered, so cross-admin lookups resolve),
|
|
26
|
+
before any request is served. Mirrors Admin.register's
|
|
27
|
+
fail-fast-on-duplicate-slug precedent.
|
|
28
|
+
"""
|
|
29
|
+
parent_slug = model_admin.get_slug()
|
|
30
|
+
seen: set[str] = set()
|
|
31
|
+
for inline in model_admin.inlines:
|
|
32
|
+
if inline.child in seen:
|
|
33
|
+
raise ValueError(f"{parent_slug!r} declares more than one inline for child {inline.child!r}.")
|
|
34
|
+
seen.add(inline.child)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
child_admin = admin.get_model_admin(inline.child)
|
|
38
|
+
except KeyError:
|
|
39
|
+
raise ValueError(f"{parent_slug!r}'s inline references unknown child {inline.child!r}.") from None
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
field = child_admin.get_field(inline.fk_field)
|
|
43
|
+
except KeyError:
|
|
44
|
+
field = None
|
|
45
|
+
relation = getattr(field, "relation", None) if field is not None else None
|
|
46
|
+
if (
|
|
47
|
+
field is None
|
|
48
|
+
or relation is None
|
|
49
|
+
or field.field_type not in ("foreignkey", "onetoone")
|
|
50
|
+
or relation.target != parent_slug
|
|
51
|
+
):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"{parent_slug!r}'s inline fk_field {inline.fk_field!r} on child {inline.child!r} must be "
|
|
54
|
+
f"a ForeignKeyField/OneToOneField whose relation targets {parent_slug!r}."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _pk_column(child_admin: Any, field_names: list[str]) -> str | None:
|
|
59
|
+
"""The field whose value is the record's primary key, or the first
|
|
60
|
+
field when none is shown. None when there are no columns at all.
|
|
61
|
+
|
|
62
|
+
get_pk() reads a value, not a field name -- a ModelAdmin may override
|
|
63
|
+
it -- so the column is found by asking a probe object which attribute
|
|
64
|
+
the two agree on.
|
|
65
|
+
"""
|
|
66
|
+
if not field_names:
|
|
67
|
+
return None
|
|
68
|
+
probe = getattr(child_admin, "model", None)
|
|
69
|
+
for name in field_names:
|
|
70
|
+
# A ModelAdmin's get_pk defaults to `.id`; a name that resolves to
|
|
71
|
+
# the same attribute is the primary key's column.
|
|
72
|
+
if name == "id" or (probe is not None and getattr(probe, "pk_field", None) == name):
|
|
73
|
+
return name
|
|
74
|
+
return field_names[0]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def build_inline_context(
|
|
78
|
+
admin: Admin,
|
|
79
|
+
principal: Any,
|
|
80
|
+
model_admin: ModelAdmin,
|
|
81
|
+
obj: Any | None,
|
|
82
|
+
mode: str,
|
|
83
|
+
base_path: str,
|
|
84
|
+
*,
|
|
85
|
+
redisplay: dict[str, Any] | None = None,
|
|
86
|
+
) -> list[dict[str, Any]]:
|
|
87
|
+
"""One entry per `model_admin.inlines` the current principal may
|
|
88
|
+
view at all -- an entry is omitted entirely (the whole section
|
|
89
|
+
hidden, not just its controls) if the principal lacks the child's
|
|
90
|
+
own `.view` permission, same principle as relation-link hiding
|
|
91
|
+
elsewhere in the framework.
|
|
92
|
+
|
|
93
|
+
`mode` is "placeholder" (create page -- no parent pk yet, no rows
|
|
94
|
+
or add-form built), "edit" (rows + a persistent blank add-row, all
|
|
95
|
+
editable), or "readonly" (rows only, using the child's
|
|
96
|
+
detail_fields, no add/edit/remove controls).
|
|
97
|
+
|
|
98
|
+
`redisplay`, when given, is `{"pk": child_pk_or_None, "data":...,
|
|
99
|
+
"errors":...}` for the one row (or the add-row, if pk is None)
|
|
100
|
+
being redisplayed after a failed inline mutation.
|
|
101
|
+
"""
|
|
102
|
+
sections: list[dict[str, Any]] = []
|
|
103
|
+
for inline in model_admin.inlines:
|
|
104
|
+
try:
|
|
105
|
+
child_admin = admin.get_model_admin(inline.child)
|
|
106
|
+
except KeyError:
|
|
107
|
+
continue
|
|
108
|
+
if not child_admin.can_view:
|
|
109
|
+
continue
|
|
110
|
+
if admin.authorizer is not None and not admin.authorizer.can(
|
|
111
|
+
principal, resource_permission(inline.child, "view"), child_admin
|
|
112
|
+
):
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
child_perms = compute_permissions(admin, principal, child_admin)
|
|
116
|
+
field_names = [name for name in child_admin.get_form_fields() if name != inline.fk_field]
|
|
117
|
+
detail_field_names = [name for name in child_admin.get_detail_fields() if name != inline.fk_field]
|
|
118
|
+
# Which readonly column opens the record. The primary key's own
|
|
119
|
+
# column when it is shown -- an id is the one cell that is never a
|
|
120
|
+
# link already and never wraps -- and the first column otherwise,
|
|
121
|
+
# so a row is always reachable. See the readonly table in
|
|
122
|
+
# components/inline.html.
|
|
123
|
+
link_field = _pk_column(child_admin, detail_field_names)
|
|
124
|
+
|
|
125
|
+
rows: list[dict[str, Any]] = []
|
|
126
|
+
add_row: dict[str, Any] | None = None
|
|
127
|
+
if mode != "placeholder":
|
|
128
|
+
parent_pk = model_admin.get_pk(obj)
|
|
129
|
+
children = await afilter_inline_children(child_admin, inline.fk_field, model_admin, parent_pk)
|
|
130
|
+
for child_obj in children:
|
|
131
|
+
child_pk = child_admin.get_pk(child_obj)
|
|
132
|
+
row_redisplay = (
|
|
133
|
+
redisplay
|
|
134
|
+
if redisplay is not None and redisplay.get("pk") is not None and str(redisplay["pk"]) == str(child_pk)
|
|
135
|
+
else None
|
|
136
|
+
)
|
|
137
|
+
row_options = await compute_relation_options(admin, child_admin, obj=child_obj) if mode == "edit" else {}
|
|
138
|
+
rows.append(
|
|
139
|
+
{
|
|
140
|
+
"pk": child_pk,
|
|
141
|
+
"obj": child_obj,
|
|
142
|
+
"data": row_redisplay["data"] if row_redisplay else None,
|
|
143
|
+
"errors": row_redisplay["errors"] if row_redisplay else {},
|
|
144
|
+
"relation_options": row_options,
|
|
145
|
+
"update_url": f"{base_path}/{model_admin.get_slug()}/{parent_pk}/inlines/{inline.child}/{child_pk}",
|
|
146
|
+
"delete_url": f"{base_path}/{model_admin.get_slug()}/{parent_pk}/inlines/{inline.child}/{child_pk}",
|
|
147
|
+
"detail_url": f"{base_path}/{inline.child}/{child_pk}",
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
if mode == "edit" and child_perms["can_create"]:
|
|
151
|
+
add_redisplay = redisplay if redisplay is not None and redisplay.get("pk") is None else None
|
|
152
|
+
add_row = {
|
|
153
|
+
"data": add_redisplay["data"] if add_redisplay else None,
|
|
154
|
+
"errors": add_redisplay["errors"] if add_redisplay else {},
|
|
155
|
+
"relation_options": await compute_relation_options(admin, child_admin),
|
|
156
|
+
"create_url": f"{base_path}/{model_admin.get_slug()}/{parent_pk}/inlines/{inline.child}",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
sections.append(
|
|
160
|
+
{
|
|
161
|
+
"slug": inline.child,
|
|
162
|
+
"label": inline_label(admin, inline),
|
|
163
|
+
# The child's own placeholder, not the parent's.
|
|
164
|
+
"empty_value": child_admin.get_empty_value(),
|
|
165
|
+
"layout": inline.layout,
|
|
166
|
+
"mode": mode,
|
|
167
|
+
"child_admin": child_admin,
|
|
168
|
+
"field_names": field_names,
|
|
169
|
+
"detail_field_names": detail_field_names,
|
|
170
|
+
"link_field": link_field,
|
|
171
|
+
"can_change": child_perms["can_update"],
|
|
172
|
+
"can_delete": child_perms["can_delete"],
|
|
173
|
+
"rows": rows,
|
|
174
|
+
"add_row": add_row,
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
return sections
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Per-request locale resolution and the language switcher's route."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import Request, Response
|
|
9
|
+
from fastapi.routing import APIRoute
|
|
10
|
+
|
|
11
|
+
from polyadmin.core._async import maybe_await
|
|
12
|
+
from polyadmin.core.csrf import safe_redirect_path
|
|
13
|
+
from polyadmin.fastapi.csrf import make_csrf_route
|
|
14
|
+
from polyadmin.fastapi.responses import redirect
|
|
15
|
+
from polyadmin.i18n import I18n, use_locale
|
|
16
|
+
|
|
17
|
+
LOCALE_COOKIE_NAME = "admin_locale"
|
|
18
|
+
LOCALE_FORM_FIELD = "locale"
|
|
19
|
+
LOCALE_COOKIE_MAX_AGE = 365 * 24 * 60 * 60
|
|
20
|
+
|
|
21
|
+
_UNSET = object()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cached_principal(admin: Any, request: Request) -> Any:
|
|
25
|
+
"""Authenticate at most once per request: resolving the locale may
|
|
26
|
+
already have, to hand a locale_resolver the principal."""
|
|
27
|
+
if admin.authenticator is None:
|
|
28
|
+
return None
|
|
29
|
+
cached = getattr(request.state, "principal_cache", _UNSET)
|
|
30
|
+
if cached is _UNSET:
|
|
31
|
+
cached = admin.authenticator.authenticate(request)
|
|
32
|
+
request.state.principal_cache = cached
|
|
33
|
+
return cached
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def acached_principal(admin: Any, request: Request) -> Any:
|
|
37
|
+
"""Async counterpart of cached_principal, for an Authenticator whose
|
|
38
|
+
authenticate() is a coroutine function.
|
|
39
|
+
|
|
40
|
+
Shares the same request.state.principal_cache slot as cached_principal,
|
|
41
|
+
so whichever one runs first for a given request caches the answer for
|
|
42
|
+
the other. cached_principal itself is untouched: it is still what the
|
|
43
|
+
locale_resolver path uses (see resolve_request_locale below), which
|
|
44
|
+
runs outside any event loop and cannot await -- combining an async
|
|
45
|
+
Authenticator with a configured locale_resolver is unsupported.
|
|
46
|
+
"""
|
|
47
|
+
if admin.authenticator is None:
|
|
48
|
+
return None
|
|
49
|
+
cached = getattr(request.state, "principal_cache", _UNSET)
|
|
50
|
+
if cached is _UNSET:
|
|
51
|
+
cached = await maybe_await(admin.authenticator.authenticate(request))
|
|
52
|
+
request.state.principal_cache = cached
|
|
53
|
+
return cached
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def switcher_on(admin: Any, i18n: I18n) -> bool:
|
|
57
|
+
"""Whether the language switcher (and its route) is on: enabled, and
|
|
58
|
+
more than one locale to choose from."""
|
|
59
|
+
return bool(admin.locale_switcher) and len(i18n.supported) > 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_request_locale(admin: Any, i18n: I18n, request: Request) -> str:
|
|
63
|
+
"""The request's locale. The admin_locale cookie counts only while the
|
|
64
|
+
switcher is on: with it off nothing in the admin can change the
|
|
65
|
+
cookie, so a leftover one must not override the resolver or the
|
|
66
|
+
browser."""
|
|
67
|
+
resolver = None
|
|
68
|
+
if admin.locale_resolver is not None:
|
|
69
|
+
|
|
70
|
+
def resolver() -> str | None:
|
|
71
|
+
return admin.locale_resolver(request, cached_principal(admin, request))
|
|
72
|
+
|
|
73
|
+
cookie = request.cookies.get(LOCALE_COOKIE_NAME) if switcher_on(admin, i18n) else None
|
|
74
|
+
return i18n.resolve(cookie, resolver, request.headers.get("accept-language"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def make_admin_route(admin: Any, base_path: str, i18n: I18n, renderer: Any) -> type[APIRoute]:
|
|
78
|
+
"""The route class for one mounted admin: the locale is resolved and
|
|
79
|
+
installed first, then CSRF runs, so even its failure page is localised.
|
|
80
|
+
|
|
81
|
+
A route class, like CSRF's, rather than a dependency: it wraps the
|
|
82
|
+
handler's whole execution, so the context variable is set for the
|
|
83
|
+
handler and everything it calls, and reset afterwards.
|
|
84
|
+
"""
|
|
85
|
+
csrf_route = make_csrf_route(admin, base_path)
|
|
86
|
+
|
|
87
|
+
class AdminRoute(csrf_route):
|
|
88
|
+
def get_route_handler(self) -> Callable:
|
|
89
|
+
inner = super().get_route_handler()
|
|
90
|
+
|
|
91
|
+
async def handler(request: Request) -> Response:
|
|
92
|
+
locale = resolve_request_locale(admin, i18n, request)
|
|
93
|
+
request.state.locale = locale
|
|
94
|
+
request.state.renderer = renderer
|
|
95
|
+
with use_locale(locale, i18n.translator):
|
|
96
|
+
return await inner(request)
|
|
97
|
+
|
|
98
|
+
return handler
|
|
99
|
+
|
|
100
|
+
return AdminRoute
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_locale_handler(i18n: I18n, base_path: str) -> Callable:
|
|
104
|
+
"""POST {base}/locale: store the switcher's choice, go back.
|
|
105
|
+
|
|
106
|
+
An unsupported value is ignored rather than refused: the redirect is
|
|
107
|
+
the same either way, and there is nothing to explain."""
|
|
108
|
+
|
|
109
|
+
async def handler(request: Request) -> Response:
|
|
110
|
+
form = await request.form()
|
|
111
|
+
# The fallback is the admin root: "/" when mounted at the site root.
|
|
112
|
+
root = base_path or "/"
|
|
113
|
+
target = safe_redirect_path(request.headers.get("referer"), request.url.netloc, base_path, root)
|
|
114
|
+
response = redirect(request, target)
|
|
115
|
+
locale = i18n.match(form.get(LOCALE_FORM_FIELD))
|
|
116
|
+
if locale:
|
|
117
|
+
response.set_cookie(
|
|
118
|
+
LOCALE_COOKIE_NAME,
|
|
119
|
+
locale,
|
|
120
|
+
max_age=LOCALE_COOKIE_MAX_AGE,
|
|
121
|
+
path=base_path or "/",
|
|
122
|
+
httponly=True,
|
|
123
|
+
samesite="lax",
|
|
124
|
+
secure=request.url.scheme == "https",
|
|
125
|
+
)
|
|
126
|
+
return response
|
|
127
|
+
|
|
128
|
+
return handler
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""The login page's handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from fastapi import Request
|
|
8
|
+
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
|
9
|
+
|
|
10
|
+
from polyadmin.core._async import maybe_await
|
|
11
|
+
from polyadmin.core.admin import Admin
|
|
12
|
+
from polyadmin.core.login import NEXT_QUERY_PARAM, safe_next_url
|
|
13
|
+
from polyadmin.fastapi.locale import acached_principal
|
|
14
|
+
from polyadmin.fastapi.responses import redirect
|
|
15
|
+
from polyadmin.i18n import N_, gettext
|
|
16
|
+
from polyadmin.templating import Renderer
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("polyadmin")
|
|
19
|
+
|
|
20
|
+
# The two messages the login page can show. INVALID_CREDENTIALS is
|
|
21
|
+
# deliberately one message for both "no such user" and "wrong password":
|
|
22
|
+
# telling them apart turns the form into an account enumerator.
|
|
23
|
+
# core.login.LoginBackend asks implementations not to distinguish them
|
|
24
|
+
# either, for the same reason.
|
|
25
|
+
INVALID_CREDENTIALS = N_("That email and password don't match an account.")
|
|
26
|
+
SIGNED_OUT = N_("You have been signed out.")
|
|
27
|
+
SESSION_FAILED = N_("Sign-in could not be completed. Please try again.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_login_handlers(admin: Admin, renderer: Renderer, base_path: str):
|
|
31
|
+
"""The GET and POST for the login page.
|
|
32
|
+
|
|
33
|
+
These are two of only three routes in a mounted admin that run
|
|
34
|
+
without authenticating (the third is logout), since requiring a
|
|
35
|
+
session to reach the page that creates one is a loop.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def _page(request: Request, *, identifier: str = "", error: str = "", notice: str = "", status: int = 200) -> HTMLResponse:
|
|
39
|
+
html = renderer.render_login(
|
|
40
|
+
admin,
|
|
41
|
+
csrf_token=request.state.csrf_token,
|
|
42
|
+
identifier=identifier,
|
|
43
|
+
error=error,
|
|
44
|
+
notice=notice,
|
|
45
|
+
base_path=base_path,
|
|
46
|
+
)
|
|
47
|
+
return HTMLResponse(html, status_code=status)
|
|
48
|
+
|
|
49
|
+
async def login_get(request: Request) -> Response:
|
|
50
|
+
# Already signed in: nothing here to do, so honour ?next= and
|
|
51
|
+
# send them on rather than showing a form they would have to
|
|
52
|
+
# pointlessly fill in.
|
|
53
|
+
if await acached_principal(admin, request) is not None:
|
|
54
|
+
return RedirectResponse(
|
|
55
|
+
safe_next_url(request.query_params.get(NEXT_QUERY_PARAM), base_path), status_code=303
|
|
56
|
+
)
|
|
57
|
+
notice = gettext(SIGNED_OUT) if request.query_params.get("signedout") == "1" else ""
|
|
58
|
+
return _page(request, notice=notice)
|
|
59
|
+
|
|
60
|
+
async def login_post(request: Request) -> Response:
|
|
61
|
+
# CSRF is already enforced -- the router's route_class covers
|
|
62
|
+
# every route, this one included, and the GET above is what mints
|
|
63
|
+
# the cookie the form echoes back.
|
|
64
|
+
form = await request.form()
|
|
65
|
+
identifier = str(form.get("identifier") or "")
|
|
66
|
+
password = str(form.get("password") or "")
|
|
67
|
+
next_url = safe_next_url(request.query_params.get(NEXT_QUERY_PARAM), base_path)
|
|
68
|
+
|
|
69
|
+
principal = await maybe_await(admin.login_backend.verify_credentials(request, identifier, password))
|
|
70
|
+
if principal is None:
|
|
71
|
+
# 401, not 200: a failed sign-in is a failed sign-in, and the
|
|
72
|
+
# status is what a log or a rate limiter in front of this
|
|
73
|
+
# reads. The body is still the form.
|
|
74
|
+
return _page(request, identifier=identifier, error=gettext(INVALID_CREDENTIALS), status=401)
|
|
75
|
+
|
|
76
|
+
response = RedirectResponse(next_url, status_code=303)
|
|
77
|
+
try:
|
|
78
|
+
# The response is handed over so the backend can set a cookie
|
|
79
|
+
# on it. This is the one place the two implementations'
|
|
80
|
+
# signatures differ: Fiber's ctx is both request and
|
|
81
|
+
# response, while Starlette has no "current response" to
|
|
82
|
+
# reach for -- see core/login.py.
|
|
83
|
+
await maybe_await(admin.login_backend.begin_session(request, principal, response))
|
|
84
|
+
except Exception as exc: # noqa: BLE001 -- see below
|
|
85
|
+
# The credentials were right but the session could not be
|
|
86
|
+
# stored, so the visitor is not signed in and must not be
|
|
87
|
+
# told they are. Logged for the operator, generic on screen.
|
|
88
|
+
logger.warning("begin_session failed for %s: %s", principal.id, exc)
|
|
89
|
+
return _page(request, identifier=identifier, error=gettext(SESSION_FAILED), status=500)
|
|
90
|
+
return response
|
|
91
|
+
|
|
92
|
+
return login_get, login_post
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_logout_handler(admin: Admin, base_path: str):
|
|
96
|
+
"""POST-only (see the route table): a logout reachable by GET is one
|
|
97
|
+
any <img src> on the internet can trigger."""
|
|
98
|
+
|
|
99
|
+
async def logout(request: Request) -> Response:
|
|
100
|
+
response = redirect(request, f"{base_path}/login?signedout=1")
|
|
101
|
+
try:
|
|
102
|
+
await maybe_await(admin.login_backend.end_session(request, response))
|
|
103
|
+
except Exception as exc: # noqa: BLE001 -- see below
|
|
104
|
+
# Nothing useful to offer the visitor here: they asked to
|
|
105
|
+
# leave, and the most likely reason this failed is that there
|
|
106
|
+
# was nothing to clear.
|
|
107
|
+
logger.warning("end_session failed: %s", exc)
|
|
108
|
+
return response
|
|
109
|
+
|
|
110
|
+
return logout
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""FastAPI wiring for AdminPage: a custom admin route with its own
|
|
2
|
+
template and handler, for functionality that isn't resource CRUD
|
|
3
|
+
(reports, wizards, internal tools). See docs/routing.md.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fastapi import Request
|
|
11
|
+
from fastapi.responses import HTMLResponse, Response
|
|
12
|
+
|
|
13
|
+
from polyadmin.core.admin import Admin
|
|
14
|
+
from polyadmin.core.page import AdminPage
|
|
15
|
+
from polyadmin.core.template_context import base_context, category_breadcrumb
|
|
16
|
+
from polyadmin.fastapi.auth import authorize
|
|
17
|
+
from polyadmin.fastapi.responses import (
|
|
18
|
+
clear_flash,
|
|
19
|
+
is_htmx_request,
|
|
20
|
+
pop_flash,
|
|
21
|
+
redirect,
|
|
22
|
+
set_flash,
|
|
23
|
+
)
|
|
24
|
+
from polyadmin.i18n import gettext
|
|
25
|
+
from polyadmin.templating import Renderer
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class PageContext:
|
|
30
|
+
"""What an AdminPage's handler receives -- the raw Request (for
|
|
31
|
+
form/query parsing, exactly like any other FastAPI handler) plus
|
|
32
|
+
render/redirect helpers reusing the framework's own layout, flash
|
|
33
|
+
cookie, and HTMX-aware redirect.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
admin: Admin
|
|
37
|
+
page: AdminPage
|
|
38
|
+
request: Request
|
|
39
|
+
principal: Any
|
|
40
|
+
renderer: Renderer
|
|
41
|
+
base_path: str
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_htmx(self) -> bool:
|
|
45
|
+
return is_htmx_request(self.request)
|
|
46
|
+
|
|
47
|
+
async def form(self) -> Any:
|
|
48
|
+
return await self.request.form()
|
|
49
|
+
|
|
50
|
+
def render(self, template_name: str, *, status_code: int = 200, **extra: Any) -> HTMLResponse:
|
|
51
|
+
"""Render template_name (an application-supplied template
|
|
52
|
+
extending "admin/base.html", resolved via the same
|
|
53
|
+
template_dirs search Jinja already uses for resource
|
|
54
|
+
overrides) inside the shared admin layout.
|
|
55
|
+
"""
|
|
56
|
+
context = {
|
|
57
|
+
**base_context(
|
|
58
|
+
self.admin,
|
|
59
|
+
base_path=self.base_path,
|
|
60
|
+
messages=pop_flash(self.request),
|
|
61
|
+
breadcrumbs=[
|
|
62
|
+
*category_breadcrumb(self.page.category),
|
|
63
|
+
{"label": gettext(self.page.label), "url": None, "active": True},
|
|
64
|
+
],
|
|
65
|
+
active_nav_key=f"page:{self.page.path}",
|
|
66
|
+
# getattr, not attribute access: mirrors the Fiber
|
|
67
|
+
# adapter's csrfToken(c), which yields "" rather than
|
|
68
|
+
# failing if a page is somehow rendered outside the
|
|
69
|
+
# mounted router that sets it.
|
|
70
|
+
csrf_token=getattr(self.request.state, "csrf_token", ""),
|
|
71
|
+
),
|
|
72
|
+
"page": self.page,
|
|
73
|
+
**extra,
|
|
74
|
+
}
|
|
75
|
+
html = self.renderer.render(template_name, context)
|
|
76
|
+
response = HTMLResponse(html, status_code=status_code)
|
|
77
|
+
clear_flash(response)
|
|
78
|
+
return response
|
|
79
|
+
|
|
80
|
+
def redirect(self, url: str, *, flash: tuple[str, str] | None = None) -> Response:
|
|
81
|
+
response = redirect(self.request, url)
|
|
82
|
+
if flash:
|
|
83
|
+
set_flash(response, *flash)
|
|
84
|
+
return response
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_page_handler(admin: Admin, page: AdminPage, renderer: Renderer, base_path: str):
|
|
88
|
+
async def handler(request: Request):
|
|
89
|
+
principal, error = await authorize(admin, request, base_path, page.permission, page)
|
|
90
|
+
if error:
|
|
91
|
+
return error
|
|
92
|
+
ctx = PageContext(
|
|
93
|
+
admin=admin, page=page, request=request, principal=principal, renderer=renderer, base_path=base_path
|
|
94
|
+
)
|
|
95
|
+
return await page.handler(ctx)
|
|
96
|
+
|
|
97
|
+
return handler
|