adminsite 0.1.0a1__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.
- adminsite/__init__.py +50 -0
- adminsite/actions/__init__.py +4 -0
- adminsite/actions/action.py +70 -0
- adminsite/actions/selection.py +105 -0
- adminsite/admin.py +202 -0
- adminsite/auth/__init__.py +4 -0
- adminsite/auth/passwords.py +46 -0
- adminsite/auth/provider.py +86 -0
- adminsite/backends/__init__.py +0 -0
- adminsite/backends/sqlalchemy/__init__.py +37 -0
- adminsite/backends/sqlalchemy/filters.py +355 -0
- adminsite/backends/sqlalchemy/inspector.py +161 -0
- adminsite/backends/sqlalchemy/loader.py +54 -0
- adminsite/backends/sqlalchemy/repository.py +355 -0
- adminsite/backends/sqlalchemy/session.py +243 -0
- adminsite/exceptions.py +68 -0
- adminsite/fields/__init__.py +39 -0
- adminsite/fields/base.py +81 -0
- adminsite/fields/choice.py +94 -0
- adminsite/fields/registry.py +82 -0
- adminsite/fields/relation.py +66 -0
- adminsite/fields/scalars.py +95 -0
- adminsite/fields/temporal.py +91 -0
- adminsite/filters/__init__.py +15 -0
- adminsite/filters/base.py +101 -0
- adminsite/http/__init__.py +0 -0
- adminsite/http/endpoints.py +419 -0
- adminsite/http/export.py +60 -0
- adminsite/http/forms.py +136 -0
- adminsite/http/listing.py +148 -0
- adminsite/http/templating.py +80 -0
- adminsite/http/urls.py +124 -0
- adminsite/protocols.py +15 -0
- adminsite/py.typed +0 -0
- adminsite/query.py +99 -0
- adminsite/schema.py +128 -0
- adminsite/security/__init__.py +10 -0
- adminsite/security/csrf.py +41 -0
- adminsite/security/permissions.py +16 -0
- adminsite/static/adminsite.css +2 -0
- adminsite/static/alpine.min.js +21 -0
- adminsite/static/htmx.min.js +1 -0
- adminsite/templates/adminsite/_field.html +84 -0
- adminsite/templates/adminsite/_lookup.html +9 -0
- adminsite/templates/adminsite/_table.html +129 -0
- adminsite/templates/adminsite/_toolbar.html +85 -0
- adminsite/templates/adminsite/base.html +92 -0
- adminsite/templates/adminsite/detail.html +31 -0
- adminsite/templates/adminsite/form.html +40 -0
- adminsite/templates/adminsite/index.html +20 -0
- adminsite/templates/adminsite/list.html +20 -0
- adminsite/templates/adminsite/login.html +35 -0
- adminsite/text.py +59 -0
- adminsite/views/__init__.py +4 -0
- adminsite/views/model_view.py +422 -0
- adminsite/views/registry.py +64 -0
- adminsite/views/writing.py +40 -0
- adminsite-0.1.0a1.dist-info/METADATA +231 -0
- adminsite-0.1.0a1.dist-info/RECORD +61 -0
- adminsite-0.1.0a1.dist-info/WHEEL +4 -0
- adminsite-0.1.0a1.dist-info/licenses/LICENSE +21 -0
adminsite/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
from adminsite.admin import Admin
|
|
4
|
+
from adminsite.exceptions import (
|
|
5
|
+
AdminSiteError,
|
|
6
|
+
InvalidPathError,
|
|
7
|
+
NotAModelError,
|
|
8
|
+
PermissionDeniedError,
|
|
9
|
+
RecordNotFoundError,
|
|
10
|
+
RefusedError,
|
|
11
|
+
UnknownFieldError,
|
|
12
|
+
)
|
|
13
|
+
from adminsite.protocols import ModelInspector
|
|
14
|
+
from adminsite.query import CountMode, Page, QuerySpec, Sort
|
|
15
|
+
from adminsite.schema import (
|
|
16
|
+
FieldPath,
|
|
17
|
+
FieldSchema,
|
|
18
|
+
ModelSchema,
|
|
19
|
+
RelationDirection,
|
|
20
|
+
RelationSchema,
|
|
21
|
+
)
|
|
22
|
+
from adminsite.security import Permission
|
|
23
|
+
from adminsite.views import ModelView, ViewRegistry
|
|
24
|
+
|
|
25
|
+
__version__ = version("adminsite")
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Admin",
|
|
29
|
+
"AdminSiteError",
|
|
30
|
+
"CountMode",
|
|
31
|
+
"FieldPath",
|
|
32
|
+
"FieldSchema",
|
|
33
|
+
"InvalidPathError",
|
|
34
|
+
"ModelInspector",
|
|
35
|
+
"ModelSchema",
|
|
36
|
+
"ModelView",
|
|
37
|
+
"NotAModelError",
|
|
38
|
+
"Page",
|
|
39
|
+
"Permission",
|
|
40
|
+
"PermissionDeniedError",
|
|
41
|
+
"QuerySpec",
|
|
42
|
+
"RecordNotFoundError",
|
|
43
|
+
"RefusedError",
|
|
44
|
+
"RelationDirection",
|
|
45
|
+
"RelationSchema",
|
|
46
|
+
"Sort",
|
|
47
|
+
"UnknownFieldError",
|
|
48
|
+
"ViewRegistry",
|
|
49
|
+
"__version__",
|
|
50
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, TypeVar
|
|
4
|
+
|
|
5
|
+
from adminsite.security import Permission
|
|
6
|
+
from adminsite.text import humanize
|
|
7
|
+
|
|
8
|
+
MARKER = "__adminsite_action__"
|
|
9
|
+
|
|
10
|
+
Handler = TypeVar("Handler", bound=Callable[..., Awaitable[Any]])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Action:
|
|
15
|
+
"""A button that runs over the rows the user picked."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
label: str
|
|
19
|
+
method: str
|
|
20
|
+
confirm: str = ""
|
|
21
|
+
permission: str = Permission.EDIT
|
|
22
|
+
dangerous: bool = False
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def needs_confirming(self) -> bool:
|
|
26
|
+
"""Whether the user is asked before it runs."""
|
|
27
|
+
return bool(self.confirm)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def action(
|
|
31
|
+
label: str = "",
|
|
32
|
+
*,
|
|
33
|
+
name: str = "",
|
|
34
|
+
confirm: str = "",
|
|
35
|
+
permission: str = Permission.EDIT,
|
|
36
|
+
dangerous: bool = False,
|
|
37
|
+
) -> Callable[[Handler], Handler]:
|
|
38
|
+
"""Mark a method as a bulk action.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
@action("Mark as shipped", confirm="Mark these orders as shipped?")
|
|
42
|
+
async def ship(self, selection: Selection) -> str:
|
|
43
|
+
await selection.update(status="shipped")
|
|
44
|
+
return f"{await selection.count()} orders marked as shipped."
|
|
45
|
+
```
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def mark(handler: Handler) -> Handler:
|
|
49
|
+
chosen = name or handler.__name__
|
|
50
|
+
setattr(
|
|
51
|
+
handler,
|
|
52
|
+
MARKER,
|
|
53
|
+
Action(
|
|
54
|
+
name=chosen,
|
|
55
|
+
label=label or humanize(chosen),
|
|
56
|
+
method=handler.__name__,
|
|
57
|
+
confirm=confirm,
|
|
58
|
+
permission=permission,
|
|
59
|
+
dangerous=dangerous,
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
return handler
|
|
63
|
+
|
|
64
|
+
return mark
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def action_of(candidate: object) -> Action | None:
|
|
68
|
+
"""The action a method carries, if it was marked as one."""
|
|
69
|
+
found = getattr(candidate, MARKER, None)
|
|
70
|
+
return found if isinstance(found, Action) else None
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import Select, delete, func, select, update
|
|
6
|
+
from sqlalchemy.sql import Executable
|
|
7
|
+
|
|
8
|
+
from adminsite.backends.sqlalchemy.session import SessionAdapter
|
|
9
|
+
from adminsite.query import QuerySpec
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from adminsite.views import ModelView
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Selection:
|
|
17
|
+
"""The rows an action runs over.
|
|
18
|
+
|
|
19
|
+
Either the rows that were ticked, or every row the current search and
|
|
20
|
+
filters match. The second kind is never loaded into memory, so an
|
|
21
|
+
action over a large table stays one statement.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
view: "ModelView"
|
|
25
|
+
session: SessionAdapter
|
|
26
|
+
spec: QuerySpec
|
|
27
|
+
keys: Sequence[str] = ()
|
|
28
|
+
everything: bool = False
|
|
29
|
+
request: Any = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def repository(self) -> Any:
|
|
33
|
+
"""The repository of the view this selection belongs to."""
|
|
34
|
+
return self.view.repository
|
|
35
|
+
|
|
36
|
+
def statement(self) -> Select[Any]:
|
|
37
|
+
"""A statement selecting the primary keys this covers."""
|
|
38
|
+
columns = [
|
|
39
|
+
getattr(self.view.model, name) for name in self.view.schema.primary_key
|
|
40
|
+
]
|
|
41
|
+
rows: Select[Any] = select(*columns).select_from(self.view.model)
|
|
42
|
+
rows = self.view.scope_for(self.request)(rows)
|
|
43
|
+
rows = self.repository.narrow(rows, self.spec)
|
|
44
|
+
|
|
45
|
+
if not self.everything:
|
|
46
|
+
rows = rows.where(self._key_condition())
|
|
47
|
+
return rows
|
|
48
|
+
|
|
49
|
+
async def count(self) -> int:
|
|
50
|
+
"""How many rows this covers."""
|
|
51
|
+
counted = select(func.count()).select_from(self.statement().subquery())
|
|
52
|
+
return int(await self.session.scalar(counted) or 0)
|
|
53
|
+
|
|
54
|
+
async def records(self) -> list[Any]:
|
|
55
|
+
"""Load the records, for work that needs each one in turn."""
|
|
56
|
+
statement = self.repository.base_statement(
|
|
57
|
+
self.view.scope_for(self.request)
|
|
58
|
+
).where(
|
|
59
|
+
self._primary_key_column().in_(select(self.statement().subquery().c[0]))
|
|
60
|
+
)
|
|
61
|
+
return list((await self.session.scalars(statement)).unique().all())
|
|
62
|
+
|
|
63
|
+
async def update(self, **values: Any) -> int:
|
|
64
|
+
"""Change every row this covers, in one statement.
|
|
65
|
+
|
|
66
|
+
This does not run the save hooks, because it never loads the
|
|
67
|
+
records. Use `records` when the hooks matter.
|
|
68
|
+
"""
|
|
69
|
+
if not values:
|
|
70
|
+
return 0
|
|
71
|
+
statement = (
|
|
72
|
+
update(self.view.model)
|
|
73
|
+
.where(
|
|
74
|
+
self._primary_key_column().in_(select(self.statement().subquery().c[0]))
|
|
75
|
+
)
|
|
76
|
+
.values(**values)
|
|
77
|
+
)
|
|
78
|
+
return await self._run(statement)
|
|
79
|
+
|
|
80
|
+
async def delete(self) -> int:
|
|
81
|
+
"""Delete every row this covers, in one statement."""
|
|
82
|
+
statement = delete(self.view.model).where(
|
|
83
|
+
self._primary_key_column().in_(select(self.statement().subquery().c[0]))
|
|
84
|
+
)
|
|
85
|
+
return await self._run(statement)
|
|
86
|
+
|
|
87
|
+
async def _run(self, statement: Executable) -> int:
|
|
88
|
+
"""Run a write and report how many rows it touched."""
|
|
89
|
+
result = await self.session.execute(statement)
|
|
90
|
+
return int(getattr(result, "rowcount", 0) or 0)
|
|
91
|
+
|
|
92
|
+
def _primary_key_column(self) -> Any:
|
|
93
|
+
return getattr(self.view.model, self.view.schema.primary_key[0])
|
|
94
|
+
|
|
95
|
+
def _key_condition(self) -> Any:
|
|
96
|
+
column = self._primary_key_column()
|
|
97
|
+
field = self.view.schema.field_named(self.view.schema.primary_key[0])
|
|
98
|
+
wanted = [self._as_key(field.python_type, key) for key in self.keys]
|
|
99
|
+
return column.in_(wanted)
|
|
100
|
+
|
|
101
|
+
def _as_key(self, python_type: type[Any], key: str) -> Any:
|
|
102
|
+
try:
|
|
103
|
+
return python_type(key)
|
|
104
|
+
except (TypeError, ValueError, ArithmeticError):
|
|
105
|
+
return key
|
adminsite/admin.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from functools import partial
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from starlette.applications import Starlette
|
|
7
|
+
from starlette.middleware import Middleware
|
|
8
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
from starlette.responses import RedirectResponse, Response
|
|
11
|
+
from starlette.routing import Mount, Route
|
|
12
|
+
from starlette.staticfiles import StaticFiles
|
|
13
|
+
|
|
14
|
+
from adminsite.auth import AuthProvider
|
|
15
|
+
from adminsite.backends.sqlalchemy.inspector import SQLAlchemyInspector
|
|
16
|
+
from adminsite.backends.sqlalchemy.session import Database, SessionSource
|
|
17
|
+
from adminsite.exceptions import AdminSiteError
|
|
18
|
+
from adminsite.fields import FieldRegistry, default_registry
|
|
19
|
+
from adminsite.http import endpoints
|
|
20
|
+
from adminsite.http.templating import Templates
|
|
21
|
+
from adminsite.http.urls import Urls
|
|
22
|
+
from adminsite.views import ModelView, ViewRegistry
|
|
23
|
+
|
|
24
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Admin:
|
|
28
|
+
"""The admin itself: a small ASGI app you mount into your own.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
admin = Admin(engine, title="Acme")
|
|
32
|
+
admin.add_view(OrderView)
|
|
33
|
+
app.mount("/admin", admin)
|
|
34
|
+
```
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
source: Database | SessionSource,
|
|
40
|
+
*,
|
|
41
|
+
title: str = "Admin",
|
|
42
|
+
views: Sequence[ModelView | type[ModelView]] = (),
|
|
43
|
+
inspector: SQLAlchemyInspector | None = None,
|
|
44
|
+
fields: FieldRegistry | None = None,
|
|
45
|
+
template_dirs: Sequence[str | Path] = (),
|
|
46
|
+
auth: AuthProvider | None = None,
|
|
47
|
+
secret_key: str = "",
|
|
48
|
+
session_cookie: str = "adminsite_session",
|
|
49
|
+
) -> None:
|
|
50
|
+
if auth is not None and not secret_key:
|
|
51
|
+
raise AdminSiteError(
|
|
52
|
+
"Signing in needs a secret_key to sign the session cookie."
|
|
53
|
+
)
|
|
54
|
+
self.database = source if isinstance(source, Database) else Database(source)
|
|
55
|
+
self.title = title
|
|
56
|
+
self.inspector = inspector or SQLAlchemyInspector()
|
|
57
|
+
self.fields = fields or default_registry
|
|
58
|
+
self.views = ViewRegistry()
|
|
59
|
+
self.templates = Templates(template_dirs)
|
|
60
|
+
self.auth = auth
|
|
61
|
+
self.secret_key = secret_key
|
|
62
|
+
self.session_cookie = session_cookie
|
|
63
|
+
self._app: Starlette | None = None
|
|
64
|
+
|
|
65
|
+
for view in views:
|
|
66
|
+
self.add_view(view)
|
|
67
|
+
|
|
68
|
+
def add_view(self, view: ModelView | type[ModelView]) -> ModelView:
|
|
69
|
+
"""Register a model with the admin."""
|
|
70
|
+
built = view(self.inspector, self.fields) if isinstance(view, type) else view
|
|
71
|
+
return self.views.add(built)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def app(self) -> Starlette:
|
|
75
|
+
"""The Starlette app behind the admin, built once."""
|
|
76
|
+
if self._app is None:
|
|
77
|
+
self._app = Starlette(routes=self.routes(), middleware=self.middleware())
|
|
78
|
+
return self._app
|
|
79
|
+
|
|
80
|
+
def middleware(self) -> list[Middleware]:
|
|
81
|
+
"""The middleware the admin runs behind, if it needs any."""
|
|
82
|
+
if not self.secret_key:
|
|
83
|
+
return []
|
|
84
|
+
return [
|
|
85
|
+
Middleware(
|
|
86
|
+
SessionMiddleware,
|
|
87
|
+
secret_key=self.secret_key,
|
|
88
|
+
session_cookie=self.session_cookie,
|
|
89
|
+
same_site="lax",
|
|
90
|
+
https_only=False,
|
|
91
|
+
)
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
def routes(self) -> list[Route | Mount]:
|
|
95
|
+
"""Every route the admin answers."""
|
|
96
|
+
# The fixed paths come first, so a record cannot be called "new".
|
|
97
|
+
return [
|
|
98
|
+
Route("/", self._handler(endpoints.index), name="index"),
|
|
99
|
+
Mount(
|
|
100
|
+
"/static",
|
|
101
|
+
app=StaticFiles(directory=STATIC_DIR),
|
|
102
|
+
name="static",
|
|
103
|
+
),
|
|
104
|
+
Route(
|
|
105
|
+
"/login",
|
|
106
|
+
self._handler(endpoints.login_form, guarded=False),
|
|
107
|
+
methods=["GET"],
|
|
108
|
+
name="login_form",
|
|
109
|
+
),
|
|
110
|
+
Route(
|
|
111
|
+
"/login",
|
|
112
|
+
self._handler(endpoints.login, guarded=False),
|
|
113
|
+
methods=["POST"],
|
|
114
|
+
name="login",
|
|
115
|
+
),
|
|
116
|
+
Route(
|
|
117
|
+
"/logout",
|
|
118
|
+
self._handler(endpoints.logout, guarded=False),
|
|
119
|
+
methods=["POST"],
|
|
120
|
+
name="logout",
|
|
121
|
+
),
|
|
122
|
+
Route("/{view}", self._handler(endpoints.list_records), name="list"),
|
|
123
|
+
Route(
|
|
124
|
+
"/{view}/new",
|
|
125
|
+
self._handler(endpoints.create_form),
|
|
126
|
+
methods=["GET"],
|
|
127
|
+
name="create_form",
|
|
128
|
+
),
|
|
129
|
+
Route(
|
|
130
|
+
"/{view}/new",
|
|
131
|
+
self._handler(endpoints.create_record),
|
|
132
|
+
methods=["POST"],
|
|
133
|
+
name="create",
|
|
134
|
+
),
|
|
135
|
+
Route(
|
|
136
|
+
"/{view}/export",
|
|
137
|
+
self._handler(endpoints.export_records),
|
|
138
|
+
name="export",
|
|
139
|
+
),
|
|
140
|
+
Route(
|
|
141
|
+
"/{view}/action/{name}",
|
|
142
|
+
self._handler(endpoints.run_action),
|
|
143
|
+
methods=["POST"],
|
|
144
|
+
name="action",
|
|
145
|
+
),
|
|
146
|
+
Route(
|
|
147
|
+
"/{view}/lookup/{path}",
|
|
148
|
+
self._handler(endpoints.lookup),
|
|
149
|
+
name="lookup",
|
|
150
|
+
),
|
|
151
|
+
Route(
|
|
152
|
+
"/{view}/{key}",
|
|
153
|
+
self._handler(endpoints.detail),
|
|
154
|
+
name="detail",
|
|
155
|
+
),
|
|
156
|
+
Route(
|
|
157
|
+
"/{view}/{key}/edit",
|
|
158
|
+
self._handler(endpoints.edit_form),
|
|
159
|
+
methods=["GET"],
|
|
160
|
+
name="edit_form",
|
|
161
|
+
),
|
|
162
|
+
Route(
|
|
163
|
+
"/{view}/{key}/edit",
|
|
164
|
+
self._handler(endpoints.edit_record),
|
|
165
|
+
methods=["POST"],
|
|
166
|
+
name="edit",
|
|
167
|
+
),
|
|
168
|
+
Route(
|
|
169
|
+
"/{view}/{key}/delete",
|
|
170
|
+
self._handler(endpoints.delete_record),
|
|
171
|
+
methods=["POST"],
|
|
172
|
+
name="delete",
|
|
173
|
+
),
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
async def render(
|
|
177
|
+
self,
|
|
178
|
+
name: str,
|
|
179
|
+
request: Request,
|
|
180
|
+
context: dict[str, Any] | None = None,
|
|
181
|
+
status_code: int = 200,
|
|
182
|
+
) -> Response:
|
|
183
|
+
"""Render one of the admin templates."""
|
|
184
|
+
return await self.templates.render(name, request, self, context, status_code)
|
|
185
|
+
|
|
186
|
+
def _handler(self, endpoint: Any, guarded: bool = True) -> Any:
|
|
187
|
+
if not guarded or self.auth is None:
|
|
188
|
+
return partial(endpoint, self)
|
|
189
|
+
|
|
190
|
+
async def guard(request: Request) -> Response:
|
|
191
|
+
user = await self.auth.current_user(request) if self.auth else None
|
|
192
|
+
if user is None:
|
|
193
|
+
return RedirectResponse(Urls(request).login(), status_code=303)
|
|
194
|
+
request.scope["user_record"] = user
|
|
195
|
+
answer: Response = await endpoint(self, request)
|
|
196
|
+
return answer
|
|
197
|
+
|
|
198
|
+
return guard
|
|
199
|
+
|
|
200
|
+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
|
201
|
+
"""Let the admin be mounted like any other ASGI app."""
|
|
202
|
+
await self.app(scope, receive, send)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import secrets
|
|
3
|
+
|
|
4
|
+
ALGORITHM = "pbkdf2_sha256"
|
|
5
|
+
ITERATIONS = 600_000
|
|
6
|
+
SALT_BYTES = 16
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def hash_password(password: str, *, iterations: int = ITERATIONS) -> str:
|
|
10
|
+
"""Hash a password for storing.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from adminsite.auth import hash_password
|
|
14
|
+
|
|
15
|
+
hash_password("letmein")
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Keep the result, not the password. The format is
|
|
19
|
+
`pbkdf2_sha256$iterations$salt$hash`.
|
|
20
|
+
"""
|
|
21
|
+
salt = secrets.token_hex(SALT_BYTES)
|
|
22
|
+
digest = _digest(password, salt, iterations)
|
|
23
|
+
return f"{ALGORITHM}${iterations}${salt}${digest}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def verify_password(password: str, stored: str) -> bool:
|
|
27
|
+
"""Check a password against a stored hash, in constant time."""
|
|
28
|
+
try:
|
|
29
|
+
algorithm, rounds, salt, digest = stored.split("$")
|
|
30
|
+
iterations = int(rounds)
|
|
31
|
+
except ValueError:
|
|
32
|
+
return False
|
|
33
|
+
if algorithm != ALGORITHM:
|
|
34
|
+
return False
|
|
35
|
+
return secrets.compare_digest(digest, _digest(password, salt, iterations))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def looks_hashed(value: str) -> bool:
|
|
39
|
+
"""Whether a value is one of our hashes rather than a plain password."""
|
|
40
|
+
return value.startswith(f"{ALGORITHM}$")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _digest(password: str, salt: str, iterations: int) -> str:
|
|
44
|
+
return hashlib.pbkdf2_hmac(
|
|
45
|
+
"sha256", password.encode(), salt.encode(), iterations
|
|
46
|
+
).hex()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from starlette.requests import Request
|
|
5
|
+
|
|
6
|
+
from adminsite.auth.passwords import looks_hashed, verify_password
|
|
7
|
+
from adminsite.exceptions import AdminSiteError
|
|
8
|
+
|
|
9
|
+
SESSION_KEY = "adminsite_user"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthProvider:
|
|
13
|
+
"""Decides who may use the admin.
|
|
14
|
+
|
|
15
|
+
Subclass it and write `verify`. The session handling here is enough
|
|
16
|
+
for most projects, and `load_user` is where you turn the key kept in
|
|
17
|
+
the session back into whatever your application calls a user.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
async def verify(self, username: str, password: str) -> Any | None:
|
|
21
|
+
"""Return the user for these details, or nothing."""
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
|
|
24
|
+
async def load_user(self, key: str) -> Any | None:
|
|
25
|
+
"""Turn the key kept in the session back into a user."""
|
|
26
|
+
return key
|
|
27
|
+
|
|
28
|
+
def identity(self, user: Any) -> str:
|
|
29
|
+
"""The key to keep in the session for this user."""
|
|
30
|
+
return str(user)
|
|
31
|
+
|
|
32
|
+
async def current_user(self, request: Request) -> Any | None:
|
|
33
|
+
"""Who is signed in, if anyone."""
|
|
34
|
+
session = request.scope.get("session")
|
|
35
|
+
if not session:
|
|
36
|
+
return None
|
|
37
|
+
key = session.get(SESSION_KEY)
|
|
38
|
+
return await self.load_user(key) if key else None
|
|
39
|
+
|
|
40
|
+
async def sign_in(
|
|
41
|
+
self, request: Request, username: str, password: str
|
|
42
|
+
) -> Any | None:
|
|
43
|
+
"""Check the details and remember the user."""
|
|
44
|
+
user = await self.verify(username, password)
|
|
45
|
+
if user is None:
|
|
46
|
+
return None
|
|
47
|
+
request.session[SESSION_KEY] = self.identity(user)
|
|
48
|
+
return user
|
|
49
|
+
|
|
50
|
+
async def sign_out(self, request: Request) -> None:
|
|
51
|
+
"""Forget the user."""
|
|
52
|
+
session = request.scope.get("session")
|
|
53
|
+
if session:
|
|
54
|
+
session.pop(SESSION_KEY, None)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PasswordAuth(AuthProvider):
|
|
58
|
+
"""Signs in against a fixed set of usernames and password hashes.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from adminsite.auth import PasswordAuth, hash_password
|
|
62
|
+
|
|
63
|
+
PasswordAuth({"nima": hash_password("letmein")})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Passwords have to be hashed with `hash_password`, so a plain one
|
|
67
|
+
never ends up in your settings or your repository. This suits a
|
|
68
|
+
small internal tool. Anything larger should subclass `AuthProvider`
|
|
69
|
+
and check its own user table.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, users: Mapping[str, str]) -> None:
|
|
73
|
+
for username, stored in users.items():
|
|
74
|
+
if not looks_hashed(stored):
|
|
75
|
+
raise AdminSiteError(
|
|
76
|
+
f"The password for {username!r} is not hashed. "
|
|
77
|
+
"Use adminsite.auth.hash_password to hash it first."
|
|
78
|
+
)
|
|
79
|
+
self.users = dict(users)
|
|
80
|
+
|
|
81
|
+
async def verify(self, username: str, password: str) -> Any | None:
|
|
82
|
+
"""Check the password against the stored hash."""
|
|
83
|
+
stored = self.users.get(username)
|
|
84
|
+
if stored is None:
|
|
85
|
+
return None
|
|
86
|
+
return username if verify_password(password, stored) else None
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from adminsite.backends.sqlalchemy.filters import (
|
|
2
|
+
BooleanFilter,
|
|
3
|
+
ChoiceFilter,
|
|
4
|
+
DateRangeFilter,
|
|
5
|
+
NumberRangeFilter,
|
|
6
|
+
RelationFilter,
|
|
7
|
+
SQLFilter,
|
|
8
|
+
SQLFilterContext,
|
|
9
|
+
TextFilter,
|
|
10
|
+
filter_for,
|
|
11
|
+
)
|
|
12
|
+
from adminsite.backends.sqlalchemy.inspector import SQLAlchemyInspector
|
|
13
|
+
from adminsite.backends.sqlalchemy.repository import SQLAlchemyRepository
|
|
14
|
+
from adminsite.backends.sqlalchemy.session import (
|
|
15
|
+
AsyncSessionAdapter,
|
|
16
|
+
Database,
|
|
17
|
+
SessionAdapter,
|
|
18
|
+
SyncSessionAdapter,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AsyncSessionAdapter",
|
|
23
|
+
"BooleanFilter",
|
|
24
|
+
"ChoiceFilter",
|
|
25
|
+
"Database",
|
|
26
|
+
"DateRangeFilter",
|
|
27
|
+
"NumberRangeFilter",
|
|
28
|
+
"RelationFilter",
|
|
29
|
+
"SQLAlchemyInspector",
|
|
30
|
+
"SQLAlchemyRepository",
|
|
31
|
+
"SQLFilter",
|
|
32
|
+
"SQLFilterContext",
|
|
33
|
+
"SessionAdapter",
|
|
34
|
+
"SyncSessionAdapter",
|
|
35
|
+
"TextFilter",
|
|
36
|
+
"filter_for",
|
|
37
|
+
]
|