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.
@@ -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
+ ]
@@ -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,6 @@
1
+ """Admin route controllers."""
2
+
3
+ from .models import ModelController
4
+ from .session import SessionController
5
+
6
+ __all__ = ["ModelController", "SessionController"]
@@ -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")
@@ -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])