polyadmin 0.1.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. polyadmin/__init__.py +47 -0
  2. polyadmin/core/__init__.py +0 -0
  3. polyadmin/core/_async.py +21 -0
  4. polyadmin/core/action.py +135 -0
  5. polyadmin/core/admin.py +129 -0
  6. polyadmin/core/audit.py +65 -0
  7. polyadmin/core/auth.py +52 -0
  8. polyadmin/core/authorization.py +48 -0
  9. polyadmin/core/csrf.py +70 -0
  10. polyadmin/core/dashboard.py +41 -0
  11. polyadmin/core/delete.py +146 -0
  12. polyadmin/core/exporter.py +136 -0
  13. polyadmin/core/field.py +201 -0
  14. polyadmin/core/filter.py +305 -0
  15. polyadmin/core/inline.py +97 -0
  16. polyadmin/core/login.py +110 -0
  17. polyadmin/core/model_admin.py +349 -0
  18. polyadmin/core/page.py +62 -0
  19. polyadmin/core/pagination.py +70 -0
  20. polyadmin/core/query.py +243 -0
  21. polyadmin/core/relation.py +40 -0
  22. polyadmin/core/slug.py +57 -0
  23. polyadmin/core/template_context.py +679 -0
  24. polyadmin/core/widget.py +280 -0
  25. polyadmin/fastapi/__init__.py +3 -0
  26. polyadmin/fastapi/audit.py +52 -0
  27. polyadmin/fastapi/auth.py +112 -0
  28. polyadmin/fastapi/csrf.py +93 -0
  29. polyadmin/fastapi/deletes.py +76 -0
  30. polyadmin/fastapi/errors.py +93 -0
  31. polyadmin/fastapi/handlers.py +798 -0
  32. polyadmin/fastapi/inlines.py +177 -0
  33. polyadmin/fastapi/locale.py +128 -0
  34. polyadmin/fastapi/login.py +110 -0
  35. polyadmin/fastapi/pages.py +97 -0
  36. polyadmin/fastapi/relations.py +264 -0
  37. polyadmin/fastapi/responses.py +55 -0
  38. polyadmin/fastapi/router.py +174 -0
  39. polyadmin/fastapi/static.py +21 -0
  40. polyadmin/i18n/__init__.py +50 -0
  41. polyadmin/i18n/context.py +54 -0
  42. polyadmin/i18n/negotiation.py +56 -0
  43. polyadmin/i18n/setup.py +81 -0
  44. polyadmin/i18n/translator.py +124 -0
  45. polyadmin/locale/fr/LC_MESSAGES/polyadmin.mo +0 -0
  46. polyadmin/locale/fr/LC_MESSAGES/polyadmin.po +486 -0
  47. polyadmin/locale/polyadmin.pot +485 -0
  48. polyadmin/locale/ru/LC_MESSAGES/polyadmin.mo +0 -0
  49. polyadmin/locale/ru/LC_MESSAGES/polyadmin.po +496 -0
  50. polyadmin/templates/admin/base.html +91 -0
  51. polyadmin/templates/admin/components/action_confirm_modal.html +86 -0
  52. polyadmin/templates/admin/components/csrf-field.html +5 -0
  53. polyadmin/templates/admin/components/error_fragment.html +6 -0
  54. polyadmin/templates/admin/components/field.html +60 -0
  55. polyadmin/templates/admin/components/form_wrapper.html +131 -0
  56. polyadmin/templates/admin/components/icons.html +67 -0
  57. polyadmin/templates/admin/components/inline.html +251 -0
  58. polyadmin/templates/admin/components/inline_fragment.html +2 -0
  59. polyadmin/templates/admin/components/list_content.html +54 -0
  60. polyadmin/templates/admin/components/lookup_results.html +19 -0
  61. polyadmin/templates/admin/components/search.html +19 -0
  62. polyadmin/templates/admin/components/toasts.html +151 -0
  63. polyadmin/templates/admin/components/ui/breadcrumb.html +30 -0
  64. polyadmin/templates/admin/components/ui/bulk-actions.html +69 -0
  65. polyadmin/templates/admin/components/ui/calendar.html +175 -0
  66. polyadmin/templates/admin/components/ui/combobox.html +82 -0
  67. polyadmin/templates/admin/components/ui/delete-preview.html +37 -0
  68. polyadmin/templates/admin/components/ui/dropdown-menu.html +71 -0
  69. polyadmin/templates/admin/components/ui/field.html +110 -0
  70. polyadmin/templates/admin/components/ui/filter-panel.html +155 -0
  71. polyadmin/templates/admin/components/ui/locale-switcher.html +30 -0
  72. polyadmin/templates/admin/components/ui/multi-select.html +253 -0
  73. polyadmin/templates/admin/components/ui/pagination.html +81 -0
  74. polyadmin/templates/admin/components/ui/radio-group.html +28 -0
  75. polyadmin/templates/admin/components/ui/select.html +165 -0
  76. polyadmin/templates/admin/components/ui/sidebar.html +175 -0
  77. polyadmin/templates/admin/components/ui/slider.html +22 -0
  78. polyadmin/templates/admin/components/ui/switch.html +36 -0
  79. polyadmin/templates/admin/components/ui/table.html +221 -0
  80. polyadmin/templates/admin/components/ui/theme-toggle.html +33 -0
  81. polyadmin/templates/admin/dashboard.html +35 -0
  82. polyadmin/templates/admin/error.html +33 -0
  83. polyadmin/templates/admin/login.html +94 -0
  84. polyadmin/templates/admin/resource/delete.html +29 -0
  85. polyadmin/templates/admin/resource/delete_selected.html +49 -0
  86. polyadmin/templates/admin/resource/detail.html +78 -0
  87. polyadmin/templates/admin/resource/form.html +5 -0
  88. polyadmin/templates/admin/resource/list.html +5 -0
  89. polyadmin/templates/admin/theme.html +372 -0
  90. polyadmin/templates/admin/widgets/activity.html +8 -0
  91. polyadmin/templates/admin/widgets/chart.html +15 -0
  92. polyadmin/templates/admin/widgets/donut.html +59 -0
  93. polyadmin/templates/admin/widgets/metric.html +1 -0
  94. polyadmin/templates/admin/widgets/progress.html +7 -0
  95. polyadmin/templates/admin/widgets/stat.html +22 -0
  96. polyadmin/templates/admin/widgets/table.html +29 -0
  97. polyadmin/templates/admin/widgets/tabs.html +34 -0
  98. polyadmin/templates/admin/widgets/timeline.html +21 -0
  99. polyadmin/templating.py +528 -0
  100. polyadmin/ui.py +817 -0
  101. polyadmin-0.1.0b1.dist-info/METADATA +239 -0
  102. polyadmin-0.1.0b1.dist-info/RECORD +104 -0
  103. polyadmin-0.1.0b1.dist-info/WHEEL +4 -0
  104. polyadmin-0.1.0b1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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