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