admin-litestar 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.
- admin_litestar/__init__.py +50 -0
- admin_litestar/admin.py +183 -0
- admin_litestar/auth.py +107 -0
- admin_litestar/constants.py +47 -0
- admin_litestar/controllers/__init__.py +6 -0
- admin_litestar/controllers/models.py +176 -0
- admin_litestar/controllers/session.py +77 -0
- admin_litestar/export.py +45 -0
- admin_litestar/pages.py +28 -0
- admin_litestar/passwords.py +76 -0
- admin_litestar/protocols.py +63 -0
- admin_litestar/py.typed +0 -0
- admin_litestar/queries.py +161 -0
- admin_litestar/render.py +41 -0
- admin_litestar/spec.py +145 -0
- admin_litestar/static/__init__.py +7 -0
- admin_litestar/static/admin.css +205 -0
- admin_litestar/static/htmx.min.js +1 -0
- admin_litestar/templates/__init__.py +7 -0
- admin_litestar/templates/_table.html +18 -0
- admin_litestar/templates/base.html +16 -0
- admin_litestar/templates/base_bare.html +10 -0
- admin_litestar/templates/detail.html +21 -0
- admin_litestar/templates/list.html +25 -0
- admin_litestar/templates/login.html +13 -0
- admin_litestar/templates/nav.html +20 -0
- admin_litestar-0.1.0.dist-info/METADATA +239 -0
- admin_litestar-0.1.0.dist-info/RECORD +30 -0
- admin_litestar-0.1.0.dist-info/WHEEL +4 -0
- admin_litestar-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Server-rendered admin for Litestar + SQLAlchemy applications.
|
|
2
|
+
|
|
3
|
+
Everything a host application needs is exported here. Deeper import paths such
|
|
4
|
+
as ``admin_litestar.queries`` happen to work, but the compatibility promise is
|
|
5
|
+
this module's ``__all__`` and nothing below it — see ARCHITECTURE.md.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
from .admin import Admin, AdminConfig
|
|
11
|
+
from .auth import actor_of
|
|
12
|
+
from .constants import CAPABILITIES, DELETE, DETAIL, EXPORT, LIST
|
|
13
|
+
from .export import csv_rows
|
|
14
|
+
from .pages import CustomPage
|
|
15
|
+
from .passwords import hash_password, verify_password
|
|
16
|
+
from .protocols import AuditSink, AuthBackend, CacheBackend
|
|
17
|
+
from .queries import count_statement, detail_statement, list_statement
|
|
18
|
+
from .render import is_htmx, project, render_value
|
|
19
|
+
from .spec import ModelSpec
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
__version__ = version("admin-litestar")
|
|
23
|
+
except PackageNotFoundError: # pragma: no cover - running from a source tree
|
|
24
|
+
__version__ = "0.0.0.dev0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Admin",
|
|
28
|
+
"AdminConfig",
|
|
29
|
+
"AuditSink",
|
|
30
|
+
"AuthBackend",
|
|
31
|
+
"CAPABILITIES",
|
|
32
|
+
"CacheBackend",
|
|
33
|
+
"CustomPage",
|
|
34
|
+
"DELETE",
|
|
35
|
+
"DETAIL",
|
|
36
|
+
"EXPORT",
|
|
37
|
+
"LIST",
|
|
38
|
+
"ModelSpec",
|
|
39
|
+
"__version__",
|
|
40
|
+
"actor_of",
|
|
41
|
+
"count_statement",
|
|
42
|
+
"csv_rows",
|
|
43
|
+
"detail_statement",
|
|
44
|
+
"hash_password",
|
|
45
|
+
"is_htmx",
|
|
46
|
+
"list_statement",
|
|
47
|
+
"project",
|
|
48
|
+
"render_value",
|
|
49
|
+
"verify_password",
|
|
50
|
+
]
|
admin_litestar/admin.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""The object a host application constructs, configures and mounts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from litestar import Router
|
|
10
|
+
from litestar.config.csrf import CSRFConfig
|
|
11
|
+
from litestar.di import Provide
|
|
12
|
+
from litestar.middleware.session.server_side import ServerSideSessionConfig
|
|
13
|
+
from litestar.plugins.jinja import JinjaTemplateEngine
|
|
14
|
+
from litestar.static_files import create_static_files_router
|
|
15
|
+
from litestar.template.config import TemplateConfig
|
|
16
|
+
|
|
17
|
+
from .auth import Revalidator, require_actor
|
|
18
|
+
from .constants import DEFAULT_PATH, DEFAULT_STATIC_PATH
|
|
19
|
+
from .controllers import ModelController, SessionController
|
|
20
|
+
from .protocols import CacheBackend
|
|
21
|
+
from .render import render_value
|
|
22
|
+
from .spec import Registry
|
|
23
|
+
from .static import STATIC
|
|
24
|
+
from .templates import TEMPLATES
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Callable, Sequence
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from litestar import Litestar
|
|
31
|
+
from litestar.stores.base import Store
|
|
32
|
+
|
|
33
|
+
from .pages import CustomPage
|
|
34
|
+
from .protocols import AuditSink, AuthBackend
|
|
35
|
+
from .spec import ModelSpec
|
|
36
|
+
|
|
37
|
+
SESSION_STORE_NAME = "admin_sessions"
|
|
38
|
+
CSRF_COOKIE = "admin_csrf"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _HostStoreSessionConfig(ServerSideSessionConfig):
|
|
42
|
+
"""A session config bound to a store object rather than a registry name.
|
|
43
|
+
|
|
44
|
+
``ServerSideSessionConfig`` normally resolves its store by looking up a
|
|
45
|
+
name in the Litestar app's store registry — but ``Admin`` never builds
|
|
46
|
+
that app, so it has no way to register the host's store under a name.
|
|
47
|
+
Binding directly to the supplied store sidesteps that registry entirely.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, backing_store: Store) -> None:
|
|
51
|
+
"""Wrap the host's store, keeping the registry-name field cosmetic."""
|
|
52
|
+
super().__init__(store=SESSION_STORE_NAME)
|
|
53
|
+
self._backing_store = backing_store
|
|
54
|
+
|
|
55
|
+
def get_store_from_app(self, app: Litestar) -> Store:
|
|
56
|
+
"""Return the host-supplied store, ignoring the app's registry."""
|
|
57
|
+
return self._backing_store
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class AdminConfig:
|
|
62
|
+
"""Where the admin lives and what it calls itself."""
|
|
63
|
+
|
|
64
|
+
path: str = DEFAULT_PATH
|
|
65
|
+
static_path: str = DEFAULT_STATIC_PATH
|
|
66
|
+
brand: str = "admin"
|
|
67
|
+
template_dirs: tuple[Path, ...] = field(default=())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Admin:
|
|
71
|
+
"""Assembles the admin's router and the Litestar configuration it needs.
|
|
72
|
+
|
|
73
|
+
The host supplies ``session_factory``, a zero-argument callable returning
|
|
74
|
+
an async context manager over a database session (the same shape an
|
|
75
|
+
``async_sessionmaker`` provides). ``Admin`` opens one such session per
|
|
76
|
+
request and injects it into its own handlers as ``admin_session`` — a
|
|
77
|
+
dependency every handler under :meth:`router` may request. The session is
|
|
78
|
+
closed automatically once the handler returns, including on error, via
|
|
79
|
+
Litestar's generator-dependency cleanup.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
config: AdminConfig,
|
|
85
|
+
specs: Sequence[ModelSpec],
|
|
86
|
+
auth: AuthBackend,
|
|
87
|
+
audit: AuditSink,
|
|
88
|
+
cache: Callable[[Any], CacheBackend],
|
|
89
|
+
session_factory: Callable[[], Any],
|
|
90
|
+
pages: Sequence[CustomPage] = (),
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Store the host's configuration and build the registry."""
|
|
93
|
+
self.config = config
|
|
94
|
+
self.registry = Registry(specs)
|
|
95
|
+
self.auth = auth
|
|
96
|
+
self.audit = audit
|
|
97
|
+
self.cache = cache
|
|
98
|
+
self.session_factory = session_factory
|
|
99
|
+
self.pages = tuple(pages)
|
|
100
|
+
|
|
101
|
+
def _url_for_spec(self, spec: ModelSpec) -> str:
|
|
102
|
+
"""Return the list URL for a spec."""
|
|
103
|
+
return f"{self.config.path}/m/{spec.slug}"
|
|
104
|
+
|
|
105
|
+
def _url_for_page(self, page: CustomPage) -> str:
|
|
106
|
+
"""Return the URL for a host-contributed page."""
|
|
107
|
+
return f"{self.config.path}/{page.slug}"
|
|
108
|
+
|
|
109
|
+
async def _provide_session(self) -> AsyncIterator[Any]:
|
|
110
|
+
"""Open one database session per request for the ``admin_session`` name.
|
|
111
|
+
|
|
112
|
+
Yielding keeps the session open for the handler's duration; Litestar
|
|
113
|
+
resumes this generator after the response is built, closing the
|
|
114
|
+
context manager whether the handler succeeded or raised.
|
|
115
|
+
"""
|
|
116
|
+
async with self.session_factory() as session:
|
|
117
|
+
yield session
|
|
118
|
+
|
|
119
|
+
def _dependencies(self) -> dict[str, Provide]:
|
|
120
|
+
"""Provide the admin's collaborators to every handler."""
|
|
121
|
+
return {
|
|
122
|
+
"admin_auth": Provide(lambda: self.auth, sync_to_thread=False),
|
|
123
|
+
"admin_audit": Provide(lambda: self.audit, sync_to_thread=False),
|
|
124
|
+
"admin_registry": Provide(lambda: self.registry, sync_to_thread=False),
|
|
125
|
+
"admin_path": Provide(lambda: self.config.path, sync_to_thread=False),
|
|
126
|
+
"admin_cache": Provide(self._provide_cache, sync_to_thread=False),
|
|
127
|
+
"admin_session": Provide(self._provide_session),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
def _provide_cache(self, request: Any) -> CacheBackend:
|
|
131
|
+
"""Return the host's cache for this request."""
|
|
132
|
+
return self.cache(request)
|
|
133
|
+
|
|
134
|
+
def router(self) -> Router:
|
|
135
|
+
"""Build the admin router: generic controllers plus host pages."""
|
|
136
|
+
handlers: list[Any] = [SessionController, ModelController]
|
|
137
|
+
for page in self.pages:
|
|
138
|
+
handlers.extend(page.handlers)
|
|
139
|
+
return Router(
|
|
140
|
+
path=self.config.path,
|
|
141
|
+
route_handlers=handlers,
|
|
142
|
+
guards=[require_actor],
|
|
143
|
+
before_request=Revalidator(self.auth, self.session_factory, self.cache),
|
|
144
|
+
dependencies=self._dependencies(),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def static_router(self) -> Router:
|
|
148
|
+
"""Serve the package's vendored CSS and JS."""
|
|
149
|
+
return create_static_files_router(
|
|
150
|
+
path=self.config.static_path, directories=[STATIC]
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def template_config(self) -> TemplateConfig:
|
|
154
|
+
"""Build the Jinja config, host template directories taking precedence.
|
|
155
|
+
|
|
156
|
+
The engine is built and populated with globals here, then handed to
|
|
157
|
+
``TemplateConfig`` as a ready ``instance``. Passing ``engine=`` (a
|
|
158
|
+
class) instead would have Litestar build its own, separate engine
|
|
159
|
+
via ``TemplateConfig.engine_instance`` — a fresh instance that never
|
|
160
|
+
saw these globals — so the config must carry the instance directly.
|
|
161
|
+
"""
|
|
162
|
+
directories = [*self.config.template_dirs, TEMPLATES]
|
|
163
|
+
engine = JinjaTemplateEngine(directory=directories)
|
|
164
|
+
engine.engine.globals.update(
|
|
165
|
+
admin_path=self.config.path,
|
|
166
|
+
static_path=self.config.static_path,
|
|
167
|
+
brand=self.config.brand,
|
|
168
|
+
registry=self.registry,
|
|
169
|
+
groups=self.registry.groups,
|
|
170
|
+
pages=self.pages,
|
|
171
|
+
url_for_spec=self._url_for_spec,
|
|
172
|
+
url_for_page=self._url_for_page,
|
|
173
|
+
render_value=render_value,
|
|
174
|
+
)
|
|
175
|
+
return TemplateConfig(instance=engine)
|
|
176
|
+
|
|
177
|
+
def session_config(self, store: Store) -> ServerSideSessionConfig:
|
|
178
|
+
"""Build the server-side session config over a host-supplied store."""
|
|
179
|
+
return _HostStoreSessionConfig(store)
|
|
180
|
+
|
|
181
|
+
def csrf_config(self, secret: str) -> CSRFConfig:
|
|
182
|
+
"""Build CSRF protection for the admin's mutating routes."""
|
|
183
|
+
return CSRFConfig(secret=secret, cookie_name=CSRF_COOKIE)
|
admin_litestar/auth.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Session gating, revalidation, and login lockout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from litestar.exceptions import NotAuthorizedException
|
|
8
|
+
|
|
9
|
+
from .constants import (
|
|
10
|
+
EXCLUDE_FROM_AUTH_KEY,
|
|
11
|
+
LOGIN_LOCK_TTL,
|
|
12
|
+
LOGIN_MAX_ATTEMPTS,
|
|
13
|
+
REVALIDATE_TTL,
|
|
14
|
+
SESSION_ACTOR_KEY,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
from litestar.connection import ASGIConnection
|
|
21
|
+
from litestar.handlers.base import BaseRouteHandler
|
|
22
|
+
|
|
23
|
+
from .protocols import AuthBackend, CacheBackend
|
|
24
|
+
|
|
25
|
+
FAILURE_KEY = "admin:login:fail:{username}:{ip}"
|
|
26
|
+
REVALIDATE_KEY = "admin:actor:{actor_id}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _failure_key(username: str, ip: str) -> str:
|
|
30
|
+
"""Build the cache key counting failures for a username and address."""
|
|
31
|
+
return FAILURE_KEY.format(username=username, ip=ip)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def register_failure(cache: CacheBackend, username: str, ip: str) -> int:
|
|
35
|
+
"""Increment and return the failure count for a username and address."""
|
|
36
|
+
key = _failure_key(username, ip)
|
|
37
|
+
count = int(await cache.get(key) or 0) + 1
|
|
38
|
+
await cache.set(key, count, LOGIN_LOCK_TTL)
|
|
39
|
+
return count
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def is_locked(cache: CacheBackend, username: str, ip: str) -> bool:
|
|
43
|
+
"""True when the failure count has reached the lockout threshold."""
|
|
44
|
+
return int(await cache.get(_failure_key(username, ip)) or 0) >= LOGIN_MAX_ATTEMPTS
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def clear_failures(cache: CacheBackend, username: str, ip: str) -> None:
|
|
48
|
+
"""Reset the failure counter after a successful login."""
|
|
49
|
+
await cache.delete(_failure_key(username, ip))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def actor_of(connection: ASGIConnection) -> Any:
|
|
53
|
+
"""Return the acting admin's identity from the session, or None."""
|
|
54
|
+
return connection.session.get(SESSION_ACTOR_KEY)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def require_actor(
|
|
58
|
+
connection: ASGIConnection, _handler: BaseRouteHandler | None
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Litestar guard: reject requests without a logged-in admin session.
|
|
61
|
+
|
|
62
|
+
A handler opted out with ``exclude_from_auth=True`` (the same opt key
|
|
63
|
+
Litestar's own auth middlewares use) is exempt — this is how the login
|
|
64
|
+
form and its submission stay reachable by anonymous callers even though
|
|
65
|
+
the router that hosts them carries this guard for every other route.
|
|
66
|
+
"""
|
|
67
|
+
if _handler is not None and _handler.opt.get(EXCLUDE_FROM_AUTH_KEY):
|
|
68
|
+
return
|
|
69
|
+
if actor_of(connection) is None:
|
|
70
|
+
raise NotAuthorizedException()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Revalidator:
|
|
74
|
+
"""Re-asserts the host's auth gate on established sessions.
|
|
75
|
+
|
|
76
|
+
Runs as a ``before_request`` hook rather than a guard, because guards receive
|
|
77
|
+
no dependency injection and this needs a database session. Revoking an
|
|
78
|
+
admin's access therefore takes effect within ``REVALIDATE_TTL`` seconds
|
|
79
|
+
instead of at session expiry.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
backend: AuthBackend,
|
|
85
|
+
session_factory: Callable[[], Any],
|
|
86
|
+
cache_provider: Callable[[Any], CacheBackend],
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Store the collaborators supplied by :class:`~admin_litestar.Admin`."""
|
|
89
|
+
self._backend = backend
|
|
90
|
+
self._session_factory = session_factory
|
|
91
|
+
self._cache_provider = cache_provider
|
|
92
|
+
|
|
93
|
+
async def __call__(self, request: Any) -> None:
|
|
94
|
+
"""Clear the session and reject when the actor no longer qualifies."""
|
|
95
|
+
actor_id = actor_of(request)
|
|
96
|
+
if actor_id is None:
|
|
97
|
+
return
|
|
98
|
+
cache = self._cache_provider(request)
|
|
99
|
+
key = REVALIDATE_KEY.format(actor_id=actor_id)
|
|
100
|
+
if await cache.get(key):
|
|
101
|
+
return
|
|
102
|
+
async with self._session_factory() as session:
|
|
103
|
+
valid = await self._backend.is_valid(session, actor_id)
|
|
104
|
+
if not valid:
|
|
105
|
+
request.session.clear()
|
|
106
|
+
raise NotAuthorizedException()
|
|
107
|
+
await cache.set(key, True, REVALIDATE_TTL)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Package-wide defaults and literals."""
|
|
2
|
+
|
|
3
|
+
DEFAULT_PATH = "/admin"
|
|
4
|
+
DEFAULT_STATIC_PATH = "/admin-static"
|
|
5
|
+
|
|
6
|
+
SESSION_ACTOR_KEY = "admin_actor_id"
|
|
7
|
+
EXCLUDE_FROM_AUTH_KEY = "exclude_from_auth"
|
|
8
|
+
|
|
9
|
+
HASH_PREFIX = "scrypt"
|
|
10
|
+
HASH_SEPARATOR = "$"
|
|
11
|
+
HASH_PARTS = 6
|
|
12
|
+
SCRYPT_N = 16384
|
|
13
|
+
SCRYPT_R = 8
|
|
14
|
+
SCRYPT_P = 1
|
|
15
|
+
SCRYPT_DKLEN = 32
|
|
16
|
+
SALT_BYTES = 16
|
|
17
|
+
PASSWORD_MAX_LENGTH = 128
|
|
18
|
+
|
|
19
|
+
LOGIN_MAX_ATTEMPTS = 5
|
|
20
|
+
LOGIN_LOCK_TTL = 900
|
|
21
|
+
REVALIDATE_TTL = 30
|
|
22
|
+
PAGE_SIZE = 50
|
|
23
|
+
EXPORT_LIMIT = 10000
|
|
24
|
+
|
|
25
|
+
CELL_MAX_LENGTH = 48
|
|
26
|
+
EMPTY_CELL = "—"
|
|
27
|
+
TRUE_CELL = "yes"
|
|
28
|
+
FALSE_CELL = "no"
|
|
29
|
+
BYTES_CELL = "<{size} bytes>"
|
|
30
|
+
HTMX_HEADER = "HX-Request"
|
|
31
|
+
|
|
32
|
+
ACTION_LOGIN = "login"
|
|
33
|
+
ACTION_LOGIN_FAILED = "login_failed"
|
|
34
|
+
ACTION_DETAIL_VIEW = "detail_view"
|
|
35
|
+
ACTION_EXPORT = "export"
|
|
36
|
+
ACTION_DELETE = "delete"
|
|
37
|
+
|
|
38
|
+
LIST = "list"
|
|
39
|
+
DETAIL = "detail"
|
|
40
|
+
DELETE = "delete"
|
|
41
|
+
EXPORT = "export"
|
|
42
|
+
|
|
43
|
+
# The complete set a ModelSpec may declare. Exported so a host has something to
|
|
44
|
+
# import rather than spelling capability names as bare strings, and validated in
|
|
45
|
+
# ModelSpec.__post_init__ so a typo fails loudly instead of producing a model
|
|
46
|
+
# whose routes silently do not exist.
|
|
47
|
+
CAPABILITIES = frozenset({LIST, DETAIL, DELETE, EXPORT})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Generic list, detail, delete and export routes driven by ModelSpec."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from litestar import Controller, Request, get, post
|
|
8
|
+
from litestar.di import NamedDependency
|
|
9
|
+
from litestar.exceptions import NotFoundException
|
|
10
|
+
from litestar.params import FromPath, FromQuery
|
|
11
|
+
from litestar.response import Redirect, Stream, Template
|
|
12
|
+
from litestar.status_codes import HTTP_303_SEE_OTHER
|
|
13
|
+
|
|
14
|
+
from ..auth import actor_of
|
|
15
|
+
from ..constants import (
|
|
16
|
+
ACTION_DELETE,
|
|
17
|
+
ACTION_DETAIL_VIEW,
|
|
18
|
+
ACTION_EXPORT,
|
|
19
|
+
DELETE,
|
|
20
|
+
DETAIL,
|
|
21
|
+
EXPORT,
|
|
22
|
+
EXPORT_LIMIT,
|
|
23
|
+
LIST,
|
|
24
|
+
PAGE_SIZE,
|
|
25
|
+
)
|
|
26
|
+
from ..export import csv_rows
|
|
27
|
+
from ..protocols import AuditSink
|
|
28
|
+
from ..queries import detail_statement, list_statement
|
|
29
|
+
from ..render import project
|
|
30
|
+
from ..spec import Registry
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from ..spec import ModelSpec
|
|
34
|
+
|
|
35
|
+
LIST_TEMPLATE = "list.html"
|
|
36
|
+
DETAIL_TEMPLATE = "detail.html"
|
|
37
|
+
CSV_MEDIA_TYPE = "text/csv"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _spec_or_404(registry: Registry, slug: str, capability: str) -> ModelSpec:
|
|
41
|
+
"""Return the spec offering ``capability``, or raise 404."""
|
|
42
|
+
try:
|
|
43
|
+
spec = registry.get(slug)
|
|
44
|
+
except KeyError as exc:
|
|
45
|
+
raise NotFoundException() from exc
|
|
46
|
+
if not spec.renders(capability):
|
|
47
|
+
raise NotFoundException()
|
|
48
|
+
return spec
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ModelController(Controller):
|
|
52
|
+
"""Routes shared by every registered model."""
|
|
53
|
+
|
|
54
|
+
path = "/m"
|
|
55
|
+
|
|
56
|
+
@get("/{slug:str}")
|
|
57
|
+
async def index(
|
|
58
|
+
self,
|
|
59
|
+
slug: FromPath[str],
|
|
60
|
+
request: Request,
|
|
61
|
+
admin_session: NamedDependency[Any],
|
|
62
|
+
admin_registry: NamedDependency[Registry],
|
|
63
|
+
admin_path: NamedDependency[str],
|
|
64
|
+
search: FromQuery[str | None] = None,
|
|
65
|
+
after: FromQuery[str | None] = None,
|
|
66
|
+
) -> Template:
|
|
67
|
+
"""Render a filtered, paginated list."""
|
|
68
|
+
spec = _spec_or_404(admin_registry, slug, LIST)
|
|
69
|
+
filters = {
|
|
70
|
+
name: request.query_params[name]
|
|
71
|
+
for name in spec.filters
|
|
72
|
+
if request.query_params.get(name)
|
|
73
|
+
}
|
|
74
|
+
result = (
|
|
75
|
+
await admin_session.execute(
|
|
76
|
+
list_statement(spec, search=search, filters=filters, after=after)
|
|
77
|
+
)
|
|
78
|
+
).scalars().all()
|
|
79
|
+
rows = [project(row, spec.list_columns) for row in result]
|
|
80
|
+
cursor = rows[-1][spec.order_by] if len(rows) == PAGE_SIZE else None
|
|
81
|
+
return Template(
|
|
82
|
+
LIST_TEMPLATE,
|
|
83
|
+
context={
|
|
84
|
+
"spec": spec,
|
|
85
|
+
"rows": rows,
|
|
86
|
+
"search": search,
|
|
87
|
+
"filters": filters,
|
|
88
|
+
"cursor": cursor,
|
|
89
|
+
"page_url": f"{admin_path}/m/{spec.slug}",
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@get("/{slug:str}/export")
|
|
94
|
+
async def export(
|
|
95
|
+
self,
|
|
96
|
+
slug: FromPath[str],
|
|
97
|
+
request: Request,
|
|
98
|
+
admin_session: NamedDependency[Any],
|
|
99
|
+
admin_registry: NamedDependency[Registry],
|
|
100
|
+
admin_audit: NamedDependency[AuditSink],
|
|
101
|
+
) -> Stream:
|
|
102
|
+
"""Stream the list as CSV and audit the export."""
|
|
103
|
+
spec = _spec_or_404(admin_registry, slug, EXPORT)
|
|
104
|
+
result = (
|
|
105
|
+
await admin_session.execute(list_statement(spec, limit=EXPORT_LIMIT))
|
|
106
|
+
).scalars().all()
|
|
107
|
+
rows = [project(row, spec.list_columns) for row in result]
|
|
108
|
+
await admin_audit.write(
|
|
109
|
+
admin_session, actor_of(request), ACTION_EXPORT,
|
|
110
|
+
subject=spec.slug, request=request, extra={"rows": len(rows)},
|
|
111
|
+
)
|
|
112
|
+
return Stream(
|
|
113
|
+
csv_rows(spec, rows),
|
|
114
|
+
media_type=CSV_MEDIA_TYPE,
|
|
115
|
+
headers={
|
|
116
|
+
"content-disposition": f'attachment; filename="{spec.slug}.csv"'
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@get("/{slug:str}/{pk:str}")
|
|
121
|
+
async def detail(
|
|
122
|
+
self,
|
|
123
|
+
slug: FromPath[str],
|
|
124
|
+
pk: FromPath[str],
|
|
125
|
+
request: Request,
|
|
126
|
+
admin_session: NamedDependency[Any],
|
|
127
|
+
admin_registry: NamedDependency[Registry],
|
|
128
|
+
admin_audit: NamedDependency[AuditSink],
|
|
129
|
+
admin_path: NamedDependency[str],
|
|
130
|
+
) -> Template:
|
|
131
|
+
"""Render one record, auditing the view when the spec asks for it."""
|
|
132
|
+
spec = _spec_or_404(admin_registry, slug, DETAIL)
|
|
133
|
+
row = (
|
|
134
|
+
await admin_session.execute(detail_statement(spec, pk))
|
|
135
|
+
).scalar_one_or_none()
|
|
136
|
+
if row is None:
|
|
137
|
+
raise NotFoundException()
|
|
138
|
+
if spec.audit_on_detail:
|
|
139
|
+
await admin_audit.write(
|
|
140
|
+
admin_session, actor_of(request), ACTION_DETAIL_VIEW,
|
|
141
|
+
subject=spec.slug, subject_pk=pk, request=request,
|
|
142
|
+
)
|
|
143
|
+
return Template(
|
|
144
|
+
DETAIL_TEMPLATE,
|
|
145
|
+
context={
|
|
146
|
+
"spec": spec,
|
|
147
|
+
"pk": pk,
|
|
148
|
+
"row": project(row, spec.detail_columns),
|
|
149
|
+
"page_url": f"{admin_path}/m/{spec.slug}",
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@post("/{slug:str}/{pk:str}/delete", status_code=HTTP_303_SEE_OTHER)
|
|
154
|
+
async def delete(
|
|
155
|
+
self,
|
|
156
|
+
slug: FromPath[str],
|
|
157
|
+
pk: FromPath[str],
|
|
158
|
+
request: Request,
|
|
159
|
+
admin_session: NamedDependency[Any],
|
|
160
|
+
admin_registry: NamedDependency[Registry],
|
|
161
|
+
admin_audit: NamedDependency[AuditSink],
|
|
162
|
+
admin_path: NamedDependency[str],
|
|
163
|
+
) -> Redirect:
|
|
164
|
+
"""Delete one record and audit it."""
|
|
165
|
+
spec = _spec_or_404(admin_registry, slug, DELETE)
|
|
166
|
+
row = (
|
|
167
|
+
await admin_session.execute(detail_statement(spec, pk))
|
|
168
|
+
).scalar_one_or_none()
|
|
169
|
+
if row is None:
|
|
170
|
+
raise NotFoundException()
|
|
171
|
+
await admin_session.delete(row)
|
|
172
|
+
await admin_audit.write(
|
|
173
|
+
admin_session, actor_of(request), ACTION_DELETE,
|
|
174
|
+
subject=spec.slug, subject_pk=pk, request=request,
|
|
175
|
+
)
|
|
176
|
+
return Redirect(f"{admin_path}/m/{spec.slug}")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Login and logout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
from litestar import Controller, Request, get, post
|
|
8
|
+
from litestar.di import NamedDependency
|
|
9
|
+
from litestar.enums import RequestEncodingType
|
|
10
|
+
from litestar.params import Body
|
|
11
|
+
from litestar.response import Redirect, Template
|
|
12
|
+
from litestar.status_codes import HTTP_303_SEE_OTHER
|
|
13
|
+
|
|
14
|
+
from ..auth import clear_failures, is_locked, register_failure
|
|
15
|
+
from ..constants import (
|
|
16
|
+
ACTION_LOGIN,
|
|
17
|
+
ACTION_LOGIN_FAILED,
|
|
18
|
+
SESSION_ACTOR_KEY,
|
|
19
|
+
)
|
|
20
|
+
from ..protocols import AuditSink, AuthBackend, CacheBackend
|
|
21
|
+
|
|
22
|
+
LOCKED_MESSAGE = "too many attempts, try again later"
|
|
23
|
+
INVALID_MESSAGE = "invalid credentials"
|
|
24
|
+
UNKNOWN_IP = "unknown"
|
|
25
|
+
LOGIN_TEMPLATE = "login.html"
|
|
26
|
+
|
|
27
|
+
Form = Annotated[dict[str, Any], Body(media_type=RequestEncodingType.URL_ENCODED)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SessionController(Controller):
|
|
31
|
+
"""Renders the login form and manages the admin session."""
|
|
32
|
+
|
|
33
|
+
@get("/login", exclude_from_auth=True)
|
|
34
|
+
async def form(self) -> Template:
|
|
35
|
+
"""Render the login form."""
|
|
36
|
+
return Template(LOGIN_TEMPLATE, context={"error": None})
|
|
37
|
+
|
|
38
|
+
@post("/login", exclude_from_auth=True, status_code=HTTP_303_SEE_OTHER)
|
|
39
|
+
async def submit(
|
|
40
|
+
self,
|
|
41
|
+
request: Request,
|
|
42
|
+
data: Form,
|
|
43
|
+
admin_auth: NamedDependency[AuthBackend],
|
|
44
|
+
admin_audit: NamedDependency[AuditSink],
|
|
45
|
+
admin_cache: NamedDependency[CacheBackend],
|
|
46
|
+
admin_session: NamedDependency[Any],
|
|
47
|
+
admin_path: NamedDependency[str],
|
|
48
|
+
) -> Redirect | Template:
|
|
49
|
+
"""Validate credentials, open a session, and audit the outcome."""
|
|
50
|
+
username = str(data.get("username", ""))
|
|
51
|
+
password = str(data.get("password", ""))
|
|
52
|
+
ip = request.client.host if request.client else UNKNOWN_IP
|
|
53
|
+
if await is_locked(admin_cache, username, ip):
|
|
54
|
+
return Template(LOGIN_TEMPLATE, context={"error": LOCKED_MESSAGE})
|
|
55
|
+
user = await admin_auth.authenticate(admin_session, username, password)
|
|
56
|
+
if user is None:
|
|
57
|
+
await register_failure(admin_cache, username, ip)
|
|
58
|
+
await admin_audit.write(
|
|
59
|
+
admin_session, None, ACTION_LOGIN_FAILED,
|
|
60
|
+
request=request, extra={"username": username},
|
|
61
|
+
)
|
|
62
|
+
return Template(LOGIN_TEMPLATE, context={"error": INVALID_MESSAGE})
|
|
63
|
+
await clear_failures(admin_cache, username, ip)
|
|
64
|
+
actor_id = admin_auth.identity_of(user)
|
|
65
|
+
request.session[SESSION_ACTOR_KEY] = actor_id
|
|
66
|
+
await admin_audit.write(
|
|
67
|
+
admin_session, actor_id, ACTION_LOGIN, request=request
|
|
68
|
+
)
|
|
69
|
+
return Redirect(f"{admin_path}/")
|
|
70
|
+
|
|
71
|
+
@post("/logout", status_code=HTTP_303_SEE_OTHER)
|
|
72
|
+
async def logout(
|
|
73
|
+
self, request: Request, admin_path: NamedDependency[str]
|
|
74
|
+
) -> Redirect:
|
|
75
|
+
"""Clear the admin session."""
|
|
76
|
+
request.session.clear()
|
|
77
|
+
return Redirect(f"{admin_path}/login")
|
admin_litestar/export.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Streaming CSV export driven by a spec's list columns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Iterable, Iterator
|
|
11
|
+
|
|
12
|
+
from .spec import ModelSpec
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Sink:
|
|
16
|
+
"""A one-line buffer the csv writer writes into."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._buffer = io.StringIO()
|
|
20
|
+
self._writer = csv.writer(self._buffer)
|
|
21
|
+
|
|
22
|
+
def line(self, values: list[Any]) -> str:
|
|
23
|
+
"""Encode one row and return it, clearing the buffer."""
|
|
24
|
+
self._writer.writerow(values)
|
|
25
|
+
text = self._buffer.getvalue()
|
|
26
|
+
self._buffer.seek(0)
|
|
27
|
+
self._buffer.truncate(0)
|
|
28
|
+
return text
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def csv_rows(spec: ModelSpec, rows: Iterable[dict[str, Any]]) -> Iterator[str]:
|
|
32
|
+
"""Yield CSV lines for a spec's list columns, header first.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
spec: The spec being exported. Its ``list_columns`` define the column
|
|
36
|
+
set, so hidden and excluded columns cannot appear.
|
|
37
|
+
rows: Projected row dictionaries.
|
|
38
|
+
|
|
39
|
+
Yields:
|
|
40
|
+
CSV-encoded lines, each including its trailing newline.
|
|
41
|
+
"""
|
|
42
|
+
sink = _Sink()
|
|
43
|
+
yield sink.line(list(spec.list_columns))
|
|
44
|
+
for row in rows:
|
|
45
|
+
yield sink.line([row.get(column) for column in spec.list_columns])
|