hxadmin 0.1.0__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.
- hxadmin/__init__.py +22 -0
- hxadmin/actions.py +81 -0
- hxadmin/admin.py +370 -0
- hxadmin/components.py +74 -0
- hxadmin/deps.py +11 -0
- hxadmin/export.py +176 -0
- hxadmin/fields.py +233 -0
- hxadmin/filters.py +341 -0
- hxadmin/forms.py +305 -0
- hxadmin/icons.py +59 -0
- hxadmin/nav.py +109 -0
- hxadmin/origin.py +36 -0
- hxadmin/pages.py +72 -0
- hxadmin/pk.py +102 -0
- hxadmin/py.typed +0 -0
- hxadmin/query.py +193 -0
- hxadmin/routes.py +655 -0
- hxadmin/static/hxadmin.css +2 -0
- hxadmin/static/vendor/air-datepicker.LICENSE +24 -0
- hxadmin/static/vendor/air-datepicker.css +7 -0
- hxadmin/static/vendor/air-datepicker.min.js +1 -0
- hxadmin/static/vendor/alpine-anchor.min.js +1 -0
- hxadmin/static/vendor/alpine-focus.min.js +18 -0
- hxadmin/static/vendor/alpine-plugins.LICENSE +98 -0
- hxadmin/static/vendor/alpine.min.js +21 -0
- hxadmin/static/vendor/htmx.min.js +1 -0
- hxadmin/static/vendor/lucide.LICENSE +43 -0
- hxadmin/static/vendor/lucide.json +1 -0
- hxadmin/templates/_macros.html +269 -0
- hxadmin/templates/_palette.html +32 -0
- hxadmin/templates/components/badge.html +27 -0
- hxadmin/templates/components/button.html +79 -0
- hxadmin/templates/components/card.html +34 -0
- hxadmin/templates/components/date.html +34 -0
- hxadmin/templates/components/dialog.html +62 -0
- hxadmin/templates/components/feedback.html +85 -0
- hxadmin/templates/components/icon.html +14 -0
- hxadmin/templates/components/menu.html +117 -0
- hxadmin/templates/components/misc.html +40 -0
- hxadmin/templates/dashboard.html +26 -0
- hxadmin/templates/detail/_panel.html +66 -0
- hxadmin/templates/detail/_related_plain.html +7 -0
- hxadmin/templates/detail.html +9 -0
- hxadmin/templates/error.html +17 -0
- hxadmin/templates/form/_field.html +65 -0
- hxadmin/templates/form/_fields.html +22 -0
- hxadmin/templates/form/_options.html +10 -0
- hxadmin/templates/form.html +15 -0
- hxadmin/templates/layout.html +527 -0
- hxadmin/templates/list/_pagination.html +25 -0
- hxadmin/templates/list/_table.html +110 -0
- hxadmin/templates/list/_toolbar.html +19 -0
- hxadmin/templates/list.html +14 -0
- hxadmin/templates/page.html +7 -0
- hxadmin/toasts.py +54 -0
- hxadmin/users.py +24 -0
- hxadmin/views.py +242 -0
- hxadmin-0.1.0.dist-info/METADATA +91 -0
- hxadmin-0.1.0.dist-info/RECORD +61 -0
- hxadmin-0.1.0.dist-info/WHEEL +4 -0
- hxadmin-0.1.0.dist-info/licenses/LICENSE +21 -0
hxadmin/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
from hxadmin.actions import ActionResult, action
|
|
4
|
+
from hxadmin.admin import HxAdmin
|
|
5
|
+
from hxadmin.fields import Field, RelationField
|
|
6
|
+
from hxadmin.forms import FormError
|
|
7
|
+
from hxadmin.pages import Page
|
|
8
|
+
from hxadmin.views import BadgeTone, ModelView
|
|
9
|
+
|
|
10
|
+
__version__ = version("hxadmin")
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ActionResult",
|
|
13
|
+
"BadgeTone",
|
|
14
|
+
"Field",
|
|
15
|
+
"FormError",
|
|
16
|
+
"HxAdmin",
|
|
17
|
+
"ModelView",
|
|
18
|
+
"Page",
|
|
19
|
+
"RelationField",
|
|
20
|
+
"__version__",
|
|
21
|
+
"action",
|
|
22
|
+
]
|
hxadmin/actions.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import re
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Literal, Self
|
|
6
|
+
|
|
7
|
+
from starlette.responses import Response
|
|
8
|
+
|
|
9
|
+
from hxadmin.fields import label_for
|
|
10
|
+
from hxadmin.toasts import Toast, ToastLevel
|
|
11
|
+
|
|
12
|
+
type ActionMethod = Literal["GET", "POST"]
|
|
13
|
+
type ActionHandler = Callable[..., Awaitable[Any]]
|
|
14
|
+
|
|
15
|
+
ACTION_ATTR = "__hxadmin_action__"
|
|
16
|
+
_NAME = re.compile(r"[A-Za-z0-9_-]+")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Action:
|
|
21
|
+
"""Metadata declared by one `@action` method."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
label: str
|
|
25
|
+
bulk: bool
|
|
26
|
+
confirm: str | None
|
|
27
|
+
method: ActionMethod
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class ActionResult:
|
|
32
|
+
"""Outcome of an action: a toast over the re-rendered view, a redirect, or a raw response."""
|
|
33
|
+
|
|
34
|
+
toast: Toast | None = None
|
|
35
|
+
url: str | None = None
|
|
36
|
+
raw: Response | None = None
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def message(cls, text: str, level: ToastLevel = "success") -> Self:
|
|
40
|
+
return cls(toast=Toast(text, level))
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def redirect(cls, url: str) -> Self:
|
|
44
|
+
return cls(url=url)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def response(cls, response: Response) -> Self:
|
|
48
|
+
return cls(raw=response)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _require_async(name: str, func: object) -> None:
|
|
52
|
+
if not inspect.iscoroutinefunction(func):
|
|
53
|
+
raise TypeError(f"Action {name!r} must be an async function")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def action[F: ActionHandler](
|
|
57
|
+
name: str,
|
|
58
|
+
*,
|
|
59
|
+
label: str | None = None,
|
|
60
|
+
bulk: bool = False,
|
|
61
|
+
confirm: str | None = None,
|
|
62
|
+
method: ActionMethod = "POST",
|
|
63
|
+
) -> Callable[[F], F]:
|
|
64
|
+
"""Declare a `ModelView` coroutine method as a row action, or a bulk action with `bulk=True`.
|
|
65
|
+
|
|
66
|
+
Row handlers receive `(request, session, obj)`, bulk handlers `(request, session, objs)`.
|
|
67
|
+
POST actions run over htmx; `method="GET"` actions are plain links / form submits so a
|
|
68
|
+
returned download reaches the browser.
|
|
69
|
+
"""
|
|
70
|
+
if not _NAME.fullmatch(name):
|
|
71
|
+
raise ValueError(f"Invalid action name {name!r}: use letters, digits, '_' and '-'")
|
|
72
|
+
if method not in ("GET", "POST"):
|
|
73
|
+
raise ValueError(f"Action {name!r}: method must be 'GET' or 'POST', not {method!r}")
|
|
74
|
+
spec = Action(name, label or label_for(name.replace("-", "_")), bulk, confirm, method)
|
|
75
|
+
|
|
76
|
+
def decorate(func: F) -> F:
|
|
77
|
+
_require_async(name, func)
|
|
78
|
+
setattr(func, ACTION_ATTR, spec)
|
|
79
|
+
return func
|
|
80
|
+
|
|
81
|
+
return decorate
|
hxadmin/admin.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, FastAPI
|
|
9
|
+
from fastapi.staticfiles import StaticFiles
|
|
10
|
+
from jinja2 import (
|
|
11
|
+
BaseLoader,
|
|
12
|
+
ChoiceLoader,
|
|
13
|
+
Environment,
|
|
14
|
+
FileSystemLoader,
|
|
15
|
+
PackageLoader,
|
|
16
|
+
select_autoescape,
|
|
17
|
+
)
|
|
18
|
+
from sqlalchemy import Select, select
|
|
19
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
20
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
21
|
+
from starlette.requests import Request
|
|
22
|
+
from starlette.responses import HTMLResponse, RedirectResponse, Response
|
|
23
|
+
from starlette.routing import BaseRoute
|
|
24
|
+
|
|
25
|
+
from hxadmin.components import date_format, html_attrs, pick
|
|
26
|
+
from hxadmin.deps import AuthDependency, SessionDependency
|
|
27
|
+
from hxadmin.fields import default_widget, field_widget, field_width
|
|
28
|
+
from hxadmin.icons import check_icon, icon
|
|
29
|
+
from hxadmin.nav import build_nav
|
|
30
|
+
from hxadmin.origin import is_cross_origin, normalize_origin
|
|
31
|
+
from hxadmin.pages import AdminPage, PageEndpoint, PageHandler
|
|
32
|
+
from hxadmin.toasts import FLASH_COOKIE, Toast, encode_flash, hx_trigger, read_flash
|
|
33
|
+
from hxadmin.users import user_initials, user_label
|
|
34
|
+
from hxadmin.views import ModelView
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("hxadmin")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def json_pretty(value: Any) -> str:
|
|
40
|
+
return json.dumps(value, indent=2, ensure_ascii=False)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _first_segment(path: str) -> str:
|
|
44
|
+
return path.strip("/").split("/", 1)[0]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _require_async_page(path: str, handler: object) -> None:
|
|
48
|
+
if not inspect.iscoroutinefunction(handler):
|
|
49
|
+
raise TypeError(f"Page handler for {path!r} must be an async function")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_PAGE_METHODS = ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _method_not_allowed(allowed: Sequence[str]) -> Callable[[], Awaitable[Response]]:
|
|
56
|
+
"""Endpoint answering 405 for a page path, so `/{identity}/{pk}` cannot claim it as a 404."""
|
|
57
|
+
allow = ", ".join(m.upper() for m in allowed)
|
|
58
|
+
|
|
59
|
+
async def not_allowed() -> Response:
|
|
60
|
+
return Response(status_code=405, headers={"Allow": allow})
|
|
61
|
+
|
|
62
|
+
return not_allowed
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HxAdmin:
|
|
66
|
+
current_user: Callable[..., Awaitable[Any]]
|
|
67
|
+
current_session: Callable[..., Awaitable[AsyncSession]]
|
|
68
|
+
_page_slot: int
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
app: FastAPI,
|
|
73
|
+
*,
|
|
74
|
+
session: SessionDependency,
|
|
75
|
+
auth: AuthDependency,
|
|
76
|
+
title: str = "HxAdmin",
|
|
77
|
+
prefix: str = "/admin",
|
|
78
|
+
login_url: str | None = None,
|
|
79
|
+
logout_url: str | None = None,
|
|
80
|
+
logo_url: str | None = None,
|
|
81
|
+
templates_dir: str | Path | None = None,
|
|
82
|
+
trusted_origins: Sequence[str] = (),
|
|
83
|
+
) -> None:
|
|
84
|
+
self.app = app
|
|
85
|
+
self.title = title
|
|
86
|
+
self.prefix = prefix.rstrip("/")
|
|
87
|
+
self.login_url = login_url
|
|
88
|
+
self.logout_url = logout_url
|
|
89
|
+
self.logo_url = logo_url
|
|
90
|
+
self.views: dict[str, ModelView[Any]] = {}
|
|
91
|
+
self.pages: list[AdminPage] = []
|
|
92
|
+
self._page_methods: dict[str, set[str]] = {}
|
|
93
|
+
self._method_fallbacks: dict[str, BaseRoute] = {}
|
|
94
|
+
self._session = session
|
|
95
|
+
self._auth = auth
|
|
96
|
+
self.trusted_origins = frozenset(normalize_origin(origin) for origin in trusted_origins)
|
|
97
|
+
self.templates = self._make_environment(templates_dir)
|
|
98
|
+
self.subapp = FastAPI(openapi_url=None, docs_url=None, redoc_url=None)
|
|
99
|
+
self.subapp.mount(
|
|
100
|
+
"/static",
|
|
101
|
+
StaticFiles(directory=str(Path(__file__).parent / "static")),
|
|
102
|
+
name="static",
|
|
103
|
+
)
|
|
104
|
+
self._build_dependencies()
|
|
105
|
+
self._install_routes()
|
|
106
|
+
self.subapp.add_exception_handler(StarletteHTTPException, self._handle_http_exception)
|
|
107
|
+
self.subapp.add_exception_handler(Exception, self._handle_server_error)
|
|
108
|
+
app.mount(self.prefix, self.subapp, name="hxadmin")
|
|
109
|
+
|
|
110
|
+
def _make_environment(self, templates_dir: str | Path | None) -> Environment:
|
|
111
|
+
loaders: list[BaseLoader] = [PackageLoader("hxadmin", "templates")]
|
|
112
|
+
if templates_dir is not None:
|
|
113
|
+
loaders.insert(0, FileSystemLoader(str(templates_dir)))
|
|
114
|
+
env = Environment(loader=ChoiceLoader(loaders), autoescape=select_autoescape(["html"]))
|
|
115
|
+
env.filters["json_pretty"] = json_pretty
|
|
116
|
+
env_globals = cast(dict[str, Any], env.globals)
|
|
117
|
+
env_globals["default_widget"] = default_widget
|
|
118
|
+
env_globals["field_widget"] = field_widget
|
|
119
|
+
env_globals["field_width"] = field_width
|
|
120
|
+
env_globals["lucide_icon"] = icon
|
|
121
|
+
env_globals["hx_attrs"] = html_attrs
|
|
122
|
+
env_globals["hx_pick"] = pick
|
|
123
|
+
env_globals["hx_date_format"] = date_format
|
|
124
|
+
env_globals["hx_user_label"] = user_label
|
|
125
|
+
env_globals["hx_user_initials"] = user_initials
|
|
126
|
+
return env
|
|
127
|
+
|
|
128
|
+
def _build_dependencies(self) -> None:
|
|
129
|
+
auth = self._auth
|
|
130
|
+
session = self._session
|
|
131
|
+
trusted = self.trusted_origins
|
|
132
|
+
|
|
133
|
+
async def current_user(request: Request, user: Any = Depends(auth)) -> Any:
|
|
134
|
+
request.state.hxadmin_user = user
|
|
135
|
+
if is_cross_origin(request, trusted):
|
|
136
|
+
logger.warning(
|
|
137
|
+
"Blocked cross-origin %s %s (Origin: %s, Sec-Fetch-Site: %s)",
|
|
138
|
+
request.method,
|
|
139
|
+
request.url.path,
|
|
140
|
+
request.headers.get("origin"),
|
|
141
|
+
request.headers.get("sec-fetch-site"),
|
|
142
|
+
)
|
|
143
|
+
raise StarletteHTTPException(403, "Cross-origin request blocked.")
|
|
144
|
+
return user
|
|
145
|
+
|
|
146
|
+
async def current_session(
|
|
147
|
+
session_: AsyncSession = Depends(session),
|
|
148
|
+
) -> AsyncSession:
|
|
149
|
+
return session_
|
|
150
|
+
|
|
151
|
+
self.current_user = current_user
|
|
152
|
+
self.current_session = current_session
|
|
153
|
+
|
|
154
|
+
def _install_routes(self) -> None:
|
|
155
|
+
from hxadmin.routes import build_router
|
|
156
|
+
|
|
157
|
+
self._page_slot = len(self.subapp.router.routes)
|
|
158
|
+
self.subapp.include_router(build_router(self))
|
|
159
|
+
|
|
160
|
+
async def _handle_http_exception(self, request: Request, exc: Exception) -> Response:
|
|
161
|
+
if not isinstance(exc, StarletteHTTPException):
|
|
162
|
+
raise exc
|
|
163
|
+
htmx = request.headers.get("HX-Request") == "true"
|
|
164
|
+
authenticated = hasattr(request.state, "hxadmin_user")
|
|
165
|
+
if exc.status_code in (401, 403) and self.login_url is not None and not authenticated:
|
|
166
|
+
if htmx:
|
|
167
|
+
return Response(status_code=200, headers={"HX-Redirect": self.login_url})
|
|
168
|
+
return RedirectResponse(self.login_url, status_code=303)
|
|
169
|
+
if htmx:
|
|
170
|
+
toast = Toast(str(exc.detail), "error")
|
|
171
|
+
response: Response = Response(
|
|
172
|
+
status_code=exc.status_code,
|
|
173
|
+
headers={"HX-Trigger": hx_trigger(toast), "HX-Reswap": "none"},
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
response = self.render(
|
|
177
|
+
request,
|
|
178
|
+
"error.html",
|
|
179
|
+
{"status_code": exc.status_code, "detail": exc.detail},
|
|
180
|
+
status_code=exc.status_code,
|
|
181
|
+
)
|
|
182
|
+
response.headers.update(exc.headers or {})
|
|
183
|
+
return response
|
|
184
|
+
|
|
185
|
+
async def _handle_server_error(self, request: Request, exc: Exception) -> Response:
|
|
186
|
+
"""An error toast for htmx, else the error page; Starlette still re-raises `exc`."""
|
|
187
|
+
detail = "Something went wrong."
|
|
188
|
+
if request.headers.get("HX-Request") == "true":
|
|
189
|
+
return Response(
|
|
190
|
+
status_code=500,
|
|
191
|
+
headers={"HX-Trigger": hx_trigger(Toast(detail, "error")), "HX-Reswap": "none"},
|
|
192
|
+
)
|
|
193
|
+
return self.render(
|
|
194
|
+
request, "error.html", {"status_code": 500, "detail": detail}, status_code=500
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def register[V: ModelView[Any]](self, view_cls: type[V]) -> type[V]:
|
|
198
|
+
view = view_cls()
|
|
199
|
+
if view.identity in self.views:
|
|
200
|
+
raise ValueError(f"A view with identity {view.identity!r} is already registered")
|
|
201
|
+
if any(_first_segment(page.path) == view.identity for page in self.pages):
|
|
202
|
+
raise ValueError(f"A page path already uses {view.identity!r}")
|
|
203
|
+
check_icon(view.icon)
|
|
204
|
+
self.views[view.identity] = view
|
|
205
|
+
return view_cls
|
|
206
|
+
|
|
207
|
+
def page[F: PageHandler](
|
|
208
|
+
self,
|
|
209
|
+
path: str,
|
|
210
|
+
*,
|
|
211
|
+
title: str,
|
|
212
|
+
category: str | None = None,
|
|
213
|
+
icon: str | None = None,
|
|
214
|
+
methods: Sequence[str] = ("GET",),
|
|
215
|
+
name: str | None = None,
|
|
216
|
+
) -> Callable[[F], F]:
|
|
217
|
+
"""Register a custom page under the admin prefix, guarded by `auth`, in the sidebar."""
|
|
218
|
+
if "{" in path:
|
|
219
|
+
raise ValueError(f"Sidebar page {path!r} cannot take path parameters; use admin.route")
|
|
220
|
+
check_icon(icon)
|
|
221
|
+
return self._add_page(AdminPage(path, title, category, icon, in_nav=True), methods, name)
|
|
222
|
+
|
|
223
|
+
def route[F: PageHandler](
|
|
224
|
+
self,
|
|
225
|
+
path: str,
|
|
226
|
+
*,
|
|
227
|
+
title: str = "",
|
|
228
|
+
methods: Sequence[str] = ("GET",),
|
|
229
|
+
name: str | None = None,
|
|
230
|
+
) -> Callable[[F], F]:
|
|
231
|
+
"""Register a route under the admin prefix, guarded by `auth`, not in the sidebar."""
|
|
232
|
+
return self._add_page(AdminPage(path, title), methods, name)
|
|
233
|
+
|
|
234
|
+
def _add_page[F: PageHandler](
|
|
235
|
+
self, page: AdminPage, methods: Sequence[str], name: str | None
|
|
236
|
+
) -> Callable[[F], F]:
|
|
237
|
+
self._check_page_path(page.path)
|
|
238
|
+
wanted = {m.upper() for m in methods}
|
|
239
|
+
if "GET" in wanted:
|
|
240
|
+
wanted.add("HEAD")
|
|
241
|
+
clash = wanted & self._page_methods.get(page.path, set())
|
|
242
|
+
if clash:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"Page path {page.path!r} is already registered for {', '.join(sorted(clash))}"
|
|
245
|
+
)
|
|
246
|
+
if page.in_nav and any(p.in_nav and p.path == page.path for p in self.pages):
|
|
247
|
+
raise ValueError(f"Page path {page.path!r} is already in the sidebar")
|
|
248
|
+
|
|
249
|
+
def decorate(handler: F) -> F:
|
|
250
|
+
_require_async_page(page.path, handler)
|
|
251
|
+
routes = self.subapp.router.routes
|
|
252
|
+
start = len(routes)
|
|
253
|
+
self.subapp.add_api_route(
|
|
254
|
+
page.path,
|
|
255
|
+
PageEndpoint(self, page, handler),
|
|
256
|
+
methods=list(methods),
|
|
257
|
+
name=name or getattr(handler, "__name__", page.path),
|
|
258
|
+
dependencies=[Depends(self.current_user)],
|
|
259
|
+
response_model=None,
|
|
260
|
+
include_in_schema=False,
|
|
261
|
+
)
|
|
262
|
+
added = routes[start:]
|
|
263
|
+
del routes[start:]
|
|
264
|
+
routes[self._page_slot : self._page_slot] = added
|
|
265
|
+
self._page_slot += len(added)
|
|
266
|
+
allowed = self._page_methods.get(page.path, set()) | wanted
|
|
267
|
+
self._page_methods[page.path] = allowed
|
|
268
|
+
self._set_method_fallback(page.path, allowed)
|
|
269
|
+
self.pages.append(page)
|
|
270
|
+
return handler
|
|
271
|
+
|
|
272
|
+
return decorate
|
|
273
|
+
|
|
274
|
+
def _set_method_fallback(self, path: str, allowed: set[str]) -> None:
|
|
275
|
+
"""One 405 route per page path, kept after every page handler and before model routes."""
|
|
276
|
+
routes = self.subapp.router.routes
|
|
277
|
+
old = self._method_fallbacks.pop(path, None)
|
|
278
|
+
if old is not None:
|
|
279
|
+
routes.remove(old)
|
|
280
|
+
other = [m for m in _PAGE_METHODS if m not in allowed]
|
|
281
|
+
if not other:
|
|
282
|
+
return
|
|
283
|
+
self.subapp.add_api_route(
|
|
284
|
+
path,
|
|
285
|
+
_method_not_allowed(sorted(allowed)),
|
|
286
|
+
methods=other,
|
|
287
|
+
dependencies=[Depends(self.current_user)],
|
|
288
|
+
include_in_schema=False,
|
|
289
|
+
)
|
|
290
|
+
route = routes.pop()
|
|
291
|
+
routes.insert(self._page_slot, route)
|
|
292
|
+
self._method_fallbacks[path] = route
|
|
293
|
+
|
|
294
|
+
def _check_page_path(self, path: str) -> None:
|
|
295
|
+
first = _first_segment(path)
|
|
296
|
+
if not path.startswith("/") or not first or first.startswith("{"):
|
|
297
|
+
raise ValueError(f"Page path must start with a fixed segment like '/reports': {path!r}")
|
|
298
|
+
if first == "static" or first in self.views:
|
|
299
|
+
raise ValueError(f"Page path {path!r} collides with the admin's {first!r} routes")
|
|
300
|
+
|
|
301
|
+
def view_for(self, model: type[Any]) -> ModelView[Any] | None:
|
|
302
|
+
return next((v for v in self.views.values() if v.model is model), None)
|
|
303
|
+
|
|
304
|
+
def can_reach(self, request: Request, model: type[Any]) -> bool:
|
|
305
|
+
"""Whether rows of `model` may be offered: it has no view, or an accessible one."""
|
|
306
|
+
target = self.view_for(model)
|
|
307
|
+
return target is None or target.is_accessible(request)
|
|
308
|
+
|
|
309
|
+
def relation_scope(self, request: Request, model: type[Any]) -> Select[Any] | None:
|
|
310
|
+
"""Rows of `model` a relation may offer or accept, or None if its view is locked."""
|
|
311
|
+
target = self.view_for(model)
|
|
312
|
+
if target is None:
|
|
313
|
+
return select(model)
|
|
314
|
+
if not target.is_accessible(request):
|
|
315
|
+
return None
|
|
316
|
+
return target.get_query(request)
|
|
317
|
+
|
|
318
|
+
def display(self, obj: Any) -> str:
|
|
319
|
+
view = self.view_for(type(obj))
|
|
320
|
+
return view.display(obj) if view is not None else str(obj)
|
|
321
|
+
|
|
322
|
+
def url(self, request: Request, path: str = "/") -> str:
|
|
323
|
+
return f"{request.scope.get('root_path', '')}{path}"
|
|
324
|
+
|
|
325
|
+
def redirect(self, request: Request, url: str, *, toast: Toast | None = None) -> Response:
|
|
326
|
+
"""Redirect after a successful write: HX-Redirect on 200 for htmx, 303 otherwise.
|
|
327
|
+
|
|
328
|
+
A `toast` rides along in a short-lived cookie and is shown on the next full page.
|
|
329
|
+
"""
|
|
330
|
+
if request.headers.get("HX-Request") == "true":
|
|
331
|
+
response: Response = Response(status_code=200, headers={"HX-Redirect": url})
|
|
332
|
+
else:
|
|
333
|
+
response = RedirectResponse(url, status_code=303)
|
|
334
|
+
if toast is not None:
|
|
335
|
+
response.set_cookie(
|
|
336
|
+
FLASH_COOKIE,
|
|
337
|
+
encode_flash(toast),
|
|
338
|
+
max_age=60,
|
|
339
|
+
path=self.url(request, "/"),
|
|
340
|
+
httponly=True,
|
|
341
|
+
samesite="lax",
|
|
342
|
+
)
|
|
343
|
+
return response
|
|
344
|
+
|
|
345
|
+
def render(
|
|
346
|
+
self,
|
|
347
|
+
request: Request,
|
|
348
|
+
template: str,
|
|
349
|
+
context: Mapping[str, Any] | None = None,
|
|
350
|
+
*,
|
|
351
|
+
status_code: int = 200,
|
|
352
|
+
) -> HTMLResponse:
|
|
353
|
+
consume = request.headers.get("HX-Request") != "true" and FLASH_COOKIE in request.cookies
|
|
354
|
+
flash = read_flash(request) if consume else None
|
|
355
|
+
full_context: dict[str, Any] = {
|
|
356
|
+
"admin": self,
|
|
357
|
+
"request": request,
|
|
358
|
+
"user": getattr(request.state, "hxadmin_user", None),
|
|
359
|
+
"nav": build_nav(self, request),
|
|
360
|
+
"toasts": [flash.as_dict()] if flash is not None else [],
|
|
361
|
+
}
|
|
362
|
+
if context:
|
|
363
|
+
full_context.update(context)
|
|
364
|
+
html = self.templates.get_template(template).render(full_context)
|
|
365
|
+
response = HTMLResponse(html, status_code=status_code)
|
|
366
|
+
if consume:
|
|
367
|
+
response.delete_cookie(
|
|
368
|
+
FLASH_COOKIE, path=self.url(request, "/"), httponly=True, samesite="lax"
|
|
369
|
+
)
|
|
370
|
+
return response
|
hxadmin/components.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Helpers the component macros in `templates/components/` call as Jinja globals."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from jinja2 import Undefined
|
|
9
|
+
from markupsafe import escape
|
|
10
|
+
|
|
11
|
+
_NAME = re.compile(r"[@:A-Za-z_][\w.:@-]*")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Attributes:
|
|
16
|
+
"""Extra HTML attributes that render themselves (Jinja treats them as safe markup).
|
|
17
|
+
|
|
18
|
+
Each pair is a name and an escaped value, or None for a bare boolean attribute.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
pairs: tuple[tuple[str, str | None], ...]
|
|
22
|
+
|
|
23
|
+
def __html__(self) -> str:
|
|
24
|
+
return "".join(f" {k}" if v is None else f' {k}="{v}"' for k, v in self.pairs)
|
|
25
|
+
|
|
26
|
+
def __str__(self) -> str:
|
|
27
|
+
return self.__html__()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def html_attrs(
|
|
31
|
+
attrs: Mapping[str, Any] | None = None, kwargs: Mapping[str, Any] | None = None
|
|
32
|
+
) -> Attributes:
|
|
33
|
+
"""Render `attrs` as-is and `kwargs` with `_` turned into `-`, later keys winning.
|
|
34
|
+
|
|
35
|
+
None, False and undefined values are dropped, True renders a bare attribute and anything
|
|
36
|
+
else is escaped. Names must look like HTML, Alpine or htmx attribute names, and `class`
|
|
37
|
+
is rejected because every component takes it as its own argument.
|
|
38
|
+
"""
|
|
39
|
+
merged: dict[str, Any] = {}
|
|
40
|
+
for key, value in (attrs or {}).items():
|
|
41
|
+
merged[key] = value
|
|
42
|
+
for key, value in (kwargs or {}).items():
|
|
43
|
+
merged[key.replace("_", "-")] = value
|
|
44
|
+
pairs: list[tuple[str, str | None]] = []
|
|
45
|
+
for key, value in merged.items():
|
|
46
|
+
if not _NAME.fullmatch(key):
|
|
47
|
+
raise ValueError(f"Invalid attribute name {key!r}")
|
|
48
|
+
if key == "class":
|
|
49
|
+
raise ValueError("Pass class as the component's class argument, not as an attribute")
|
|
50
|
+
if value is None or value is False or isinstance(value, Undefined):
|
|
51
|
+
continue
|
|
52
|
+
pairs.append((key, None if value is True else str(escape(value))))
|
|
53
|
+
return Attributes(tuple(pairs))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def pick(options: Mapping[str, str], key: str, what: str) -> str:
|
|
57
|
+
"""`options[key]`, or a `ValueError` naming `what` and the allowed keys."""
|
|
58
|
+
try:
|
|
59
|
+
return options[key]
|
|
60
|
+
except KeyError:
|
|
61
|
+
allowed = ", ".join(options)
|
|
62
|
+
raise ValueError(f"Unknown {what} {key!r}: use one of {allowed}") from None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
DATE_FORMATS: Mapping[str, str] = {
|
|
66
|
+
"date": "MM/dd/yyyy",
|
|
67
|
+
"datetime": "MM/dd/yyyy HH:mm",
|
|
68
|
+
"time": "HH:mm",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def date_format(kind: str) -> str:
|
|
73
|
+
"""Air Datepicker format for a date input of `kind`; lower-cased, its placeholder."""
|
|
74
|
+
return pick(DATE_FORMATS, kind, "date kind")
|
hxadmin/deps.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
type SessionDependency = (
|
|
7
|
+
Callable[..., AsyncSession]
|
|
8
|
+
| Callable[..., AsyncIterator[AsyncSession]]
|
|
9
|
+
| Callable[..., Awaitable[AsyncSession]]
|
|
10
|
+
)
|
|
11
|
+
type AuthDependency = Callable[..., Any]
|