polyadmin 0.1.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polyadmin/__init__.py +47 -0
- polyadmin/core/__init__.py +0 -0
- polyadmin/core/_async.py +21 -0
- polyadmin/core/action.py +135 -0
- polyadmin/core/admin.py +129 -0
- polyadmin/core/audit.py +65 -0
- polyadmin/core/auth.py +52 -0
- polyadmin/core/authorization.py +48 -0
- polyadmin/core/csrf.py +70 -0
- polyadmin/core/dashboard.py +41 -0
- polyadmin/core/delete.py +146 -0
- polyadmin/core/exporter.py +136 -0
- polyadmin/core/field.py +201 -0
- polyadmin/core/filter.py +305 -0
- polyadmin/core/inline.py +97 -0
- polyadmin/core/login.py +110 -0
- polyadmin/core/model_admin.py +349 -0
- polyadmin/core/page.py +62 -0
- polyadmin/core/pagination.py +70 -0
- polyadmin/core/query.py +243 -0
- polyadmin/core/relation.py +40 -0
- polyadmin/core/slug.py +57 -0
- polyadmin/core/template_context.py +679 -0
- polyadmin/core/widget.py +280 -0
- polyadmin/fastapi/__init__.py +3 -0
- polyadmin/fastapi/audit.py +52 -0
- polyadmin/fastapi/auth.py +112 -0
- polyadmin/fastapi/csrf.py +93 -0
- polyadmin/fastapi/deletes.py +76 -0
- polyadmin/fastapi/errors.py +93 -0
- polyadmin/fastapi/handlers.py +798 -0
- polyadmin/fastapi/inlines.py +177 -0
- polyadmin/fastapi/locale.py +128 -0
- polyadmin/fastapi/login.py +110 -0
- polyadmin/fastapi/pages.py +97 -0
- polyadmin/fastapi/relations.py +264 -0
- polyadmin/fastapi/responses.py +55 -0
- polyadmin/fastapi/router.py +174 -0
- polyadmin/fastapi/static.py +21 -0
- polyadmin/i18n/__init__.py +50 -0
- polyadmin/i18n/context.py +54 -0
- polyadmin/i18n/negotiation.py +56 -0
- polyadmin/i18n/setup.py +81 -0
- polyadmin/i18n/translator.py +124 -0
- polyadmin/locale/fr/LC_MESSAGES/polyadmin.mo +0 -0
- polyadmin/locale/fr/LC_MESSAGES/polyadmin.po +486 -0
- polyadmin/locale/polyadmin.pot +485 -0
- polyadmin/locale/ru/LC_MESSAGES/polyadmin.mo +0 -0
- polyadmin/locale/ru/LC_MESSAGES/polyadmin.po +496 -0
- polyadmin/templates/admin/base.html +91 -0
- polyadmin/templates/admin/components/action_confirm_modal.html +86 -0
- polyadmin/templates/admin/components/csrf-field.html +5 -0
- polyadmin/templates/admin/components/error_fragment.html +6 -0
- polyadmin/templates/admin/components/field.html +60 -0
- polyadmin/templates/admin/components/form_wrapper.html +131 -0
- polyadmin/templates/admin/components/icons.html +67 -0
- polyadmin/templates/admin/components/inline.html +251 -0
- polyadmin/templates/admin/components/inline_fragment.html +2 -0
- polyadmin/templates/admin/components/list_content.html +54 -0
- polyadmin/templates/admin/components/lookup_results.html +19 -0
- polyadmin/templates/admin/components/search.html +19 -0
- polyadmin/templates/admin/components/toasts.html +151 -0
- polyadmin/templates/admin/components/ui/breadcrumb.html +30 -0
- polyadmin/templates/admin/components/ui/bulk-actions.html +69 -0
- polyadmin/templates/admin/components/ui/calendar.html +175 -0
- polyadmin/templates/admin/components/ui/combobox.html +82 -0
- polyadmin/templates/admin/components/ui/delete-preview.html +37 -0
- polyadmin/templates/admin/components/ui/dropdown-menu.html +71 -0
- polyadmin/templates/admin/components/ui/field.html +110 -0
- polyadmin/templates/admin/components/ui/filter-panel.html +155 -0
- polyadmin/templates/admin/components/ui/locale-switcher.html +30 -0
- polyadmin/templates/admin/components/ui/multi-select.html +253 -0
- polyadmin/templates/admin/components/ui/pagination.html +81 -0
- polyadmin/templates/admin/components/ui/radio-group.html +28 -0
- polyadmin/templates/admin/components/ui/select.html +165 -0
- polyadmin/templates/admin/components/ui/sidebar.html +175 -0
- polyadmin/templates/admin/components/ui/slider.html +22 -0
- polyadmin/templates/admin/components/ui/switch.html +36 -0
- polyadmin/templates/admin/components/ui/table.html +221 -0
- polyadmin/templates/admin/components/ui/theme-toggle.html +33 -0
- polyadmin/templates/admin/dashboard.html +35 -0
- polyadmin/templates/admin/error.html +33 -0
- polyadmin/templates/admin/login.html +94 -0
- polyadmin/templates/admin/resource/delete.html +29 -0
- polyadmin/templates/admin/resource/delete_selected.html +49 -0
- polyadmin/templates/admin/resource/detail.html +78 -0
- polyadmin/templates/admin/resource/form.html +5 -0
- polyadmin/templates/admin/resource/list.html +5 -0
- polyadmin/templates/admin/theme.html +372 -0
- polyadmin/templates/admin/widgets/activity.html +8 -0
- polyadmin/templates/admin/widgets/chart.html +15 -0
- polyadmin/templates/admin/widgets/donut.html +59 -0
- polyadmin/templates/admin/widgets/metric.html +1 -0
- polyadmin/templates/admin/widgets/progress.html +7 -0
- polyadmin/templates/admin/widgets/stat.html +22 -0
- polyadmin/templates/admin/widgets/table.html +29 -0
- polyadmin/templates/admin/widgets/tabs.html +34 -0
- polyadmin/templates/admin/widgets/timeline.html +21 -0
- polyadmin/templating.py +528 -0
- polyadmin/ui.py +817 -0
- polyadmin-0.1.0b1.dist-info/METADATA +239 -0
- polyadmin-0.1.0b1.dist-info/RECORD +104 -0
- polyadmin-0.1.0b1.dist-info/WHEEL +4 -0
- polyadmin-0.1.0b1.dist-info/licenses/LICENSE +21 -0
polyadmin/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from polyadmin.core.admin import Admin
|
|
2
|
+
from polyadmin.core.delete import (
|
|
3
|
+
DELETE_PREVIEW_SAMPLE,
|
|
4
|
+
DeleteGroup,
|
|
5
|
+
DeletePreview,
|
|
6
|
+
DeletePreviewer,
|
|
7
|
+
)
|
|
8
|
+
from polyadmin.core.field import (
|
|
9
|
+
BooleanField,
|
|
10
|
+
DateField,
|
|
11
|
+
DateTimeField,
|
|
12
|
+
DecimalField,
|
|
13
|
+
EmailField,
|
|
14
|
+
EnumField,
|
|
15
|
+
Field,
|
|
16
|
+
IntegerField,
|
|
17
|
+
JSONField,
|
|
18
|
+
PasswordField,
|
|
19
|
+
StringField,
|
|
20
|
+
TextField,
|
|
21
|
+
URLField,
|
|
22
|
+
UUIDField,
|
|
23
|
+
)
|
|
24
|
+
from polyadmin.core.model_admin import ModelAdmin
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"DELETE_PREVIEW_SAMPLE",
|
|
28
|
+
"Admin",
|
|
29
|
+
"BooleanField",
|
|
30
|
+
"DateField",
|
|
31
|
+
"DateTimeField",
|
|
32
|
+
"DecimalField",
|
|
33
|
+
"DeleteGroup",
|
|
34
|
+
"DeletePreview",
|
|
35
|
+
"DeletePreviewer",
|
|
36
|
+
"EmailField",
|
|
37
|
+
"EnumField",
|
|
38
|
+
"Field",
|
|
39
|
+
"IntegerField",
|
|
40
|
+
"JSONField",
|
|
41
|
+
"ModelAdmin",
|
|
42
|
+
"PasswordField",
|
|
43
|
+
"StringField",
|
|
44
|
+
"TextField",
|
|
45
|
+
"URLField",
|
|
46
|
+
"UUIDField",
|
|
47
|
+
]
|
|
File without changes
|
polyadmin/core/_async.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Bridges a hook that may return a value or an awaitable.
|
|
2
|
+
|
|
3
|
+
The CRUD lifecycle (`get_queryset`, `get_object`, `create`, `update`,
|
|
4
|
+
`delete`, `list_page`) and `Authenticator.authenticate` may each be a plain
|
|
5
|
+
function or an `async def` -- a ModelAdmin backed by an async HTTP client
|
|
6
|
+
needs the latter. `maybe_await` lets one call site handle both without the
|
|
7
|
+
author declaring which upfront.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
from collections.abc import Awaitable
|
|
13
|
+
from typing import TypeVar
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def maybe_await(value: T | Awaitable[T]) -> T:
|
|
19
|
+
if inspect.isawaitable(value):
|
|
20
|
+
return await value
|
|
21
|
+
return value
|
polyadmin/core/action.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Actions: ModelAdmin methods applied to one or more records.
|
|
2
|
+
|
|
3
|
+
An action is invoked with a list of objects either way -- a "record" action
|
|
4
|
+
from the detail page passes a list of exactly one, a "bulk" action from the
|
|
5
|
+
list view's row-selection passes as many as were checked. There is
|
|
6
|
+
deliberately no separate record/bulk type: the same method serves both,
|
|
7
|
+
since it never needs to know which UI entry point invoked it.
|
|
8
|
+
|
|
9
|
+
Declare one by decorating a ModelAdmin method with `@action`. The decorator
|
|
10
|
+
only records options on the function; ModelAdmin.get_actions() resolves them
|
|
11
|
+
(see `collect_actions`).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Literal, TypeVar, overload
|
|
19
|
+
|
|
20
|
+
from polyadmin.core.auth import Principal
|
|
21
|
+
|
|
22
|
+
# Which pages offer an action: the list page's bulk bar, one record's detail
|
|
23
|
+
# page, or both.
|
|
24
|
+
ActionWhere = Literal["list", "detail", "both"]
|
|
25
|
+
ACTION_WHERE: tuple[str, ...] = ("list", "detail", "both")
|
|
26
|
+
|
|
27
|
+
# The bound method a ModelAdmin's action resolves to. It may be a coroutine
|
|
28
|
+
# function; the adapter awaits it when it is one.
|
|
29
|
+
ActionHandler = Callable[[Sequence[Any], Principal | None], Awaitable[str | None] | str | None]
|
|
30
|
+
|
|
31
|
+
# The built-in bulk delete's action name. Reserved: overriding the
|
|
32
|
+
# ModelAdmin.delete_selected method replaces the built-in, which is how you
|
|
33
|
+
# customise the confirmation text or the deletion itself.
|
|
34
|
+
DELETE_SELECTED_NAME = "delete_selected"
|
|
35
|
+
|
|
36
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
37
|
+
|
|
38
|
+
_OPTIONS_ATTR = "__polyadmin_action__"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ActionOptions:
|
|
43
|
+
"""What `@action` records on a method."""
|
|
44
|
+
|
|
45
|
+
label: str | None = None
|
|
46
|
+
# A confirmation prompt shown before running (the dialog in
|
|
47
|
+
# components/action_confirm_modal.html) -- None means no confirmation.
|
|
48
|
+
confirm: str | None = None
|
|
49
|
+
# Extra permission suffix checked via resource_permission(slug,
|
|
50
|
+
# permission) alongside the resource's `.view` -- None means no extra
|
|
51
|
+
# check beyond being able to see the resource at all.
|
|
52
|
+
permission: str | None = None
|
|
53
|
+
# Placement only, not authorization: the action route serves every action
|
|
54
|
+
# whichever page offered it, and checks `permission` there.
|
|
55
|
+
where: ActionWhere = "both"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Action:
|
|
60
|
+
"""One resolved action of one ModelAdmin instance. Built by
|
|
61
|
+
`collect_actions`; not something a host constructs."""
|
|
62
|
+
|
|
63
|
+
name: str
|
|
64
|
+
handler: ActionHandler
|
|
65
|
+
label: str
|
|
66
|
+
confirm: str | None = None
|
|
67
|
+
permission: str | None = None
|
|
68
|
+
where: ActionWhere = "both"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@overload
|
|
72
|
+
def action(func: F, /) -> F: ...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@overload
|
|
76
|
+
def action(
|
|
77
|
+
*,
|
|
78
|
+
label: str | None = None,
|
|
79
|
+
confirm: str | None = None,
|
|
80
|
+
permission: str | None = None,
|
|
81
|
+
where: ActionWhere = "both",
|
|
82
|
+
) -> Callable[[F], F]: ...
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def action(
|
|
86
|
+
func: F | None = None,
|
|
87
|
+
/,
|
|
88
|
+
*,
|
|
89
|
+
label: str | None = None,
|
|
90
|
+
confirm: str | None = None,
|
|
91
|
+
permission: str | None = None,
|
|
92
|
+
where: ActionWhere = "both",
|
|
93
|
+
) -> F | Callable[[F], F]:
|
|
94
|
+
"""Mark a ModelAdmin method as an action: `(self, objects, principal) -> str | None`.
|
|
95
|
+
|
|
96
|
+
The method's name is the action's name. The decorator returns the function
|
|
97
|
+
unchanged, so type checkers keep seeing its own signature.
|
|
98
|
+
"""
|
|
99
|
+
if where not in ACTION_WHERE:
|
|
100
|
+
raise ValueError(f"@action: where must be one of {ACTION_WHERE}, not {where!r}.")
|
|
101
|
+
options = ActionOptions(label=label, confirm=confirm, permission=permission, where=where)
|
|
102
|
+
|
|
103
|
+
def decorate(fn: F) -> F:
|
|
104
|
+
setattr(fn, _OPTIONS_ATTR, options)
|
|
105
|
+
return fn
|
|
106
|
+
|
|
107
|
+
return decorate(func) if func is not None else decorate
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def collect_actions(model_admin: Any) -> list[Action]:
|
|
111
|
+
"""The actions `model_admin` declares, in definition order.
|
|
112
|
+
|
|
113
|
+
The class hierarchy is walked base-first, so a subclass's actions follow
|
|
114
|
+
its base's. A subclass that re-decorates a name replaces that name's
|
|
115
|
+
options but keeps its position; one that overrides the method without
|
|
116
|
+
decorating it keeps the base's options and runs its own body, because the
|
|
117
|
+
handler is looked up on the instance.
|
|
118
|
+
"""
|
|
119
|
+
found: dict[str, ActionOptions] = {}
|
|
120
|
+
for klass in reversed(type(model_admin).__mro__):
|
|
121
|
+
for name, member in vars(klass).items():
|
|
122
|
+
options = getattr(member, _OPTIONS_ATTR, None)
|
|
123
|
+
if isinstance(options, ActionOptions):
|
|
124
|
+
found[name] = options
|
|
125
|
+
return [
|
|
126
|
+
Action(
|
|
127
|
+
name=name,
|
|
128
|
+
handler=getattr(model_admin, name),
|
|
129
|
+
label=options.label or name.replace("_", " ").title(),
|
|
130
|
+
confirm=options.confirm,
|
|
131
|
+
permission=options.permission,
|
|
132
|
+
where=options.where,
|
|
133
|
+
)
|
|
134
|
+
for name, options in found.items()
|
|
135
|
+
]
|
polyadmin/core/admin.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Admin: the root object mounted by the host application.
|
|
2
|
+
|
|
3
|
+
Owns the ModelAdmin registry, the AdminPage registry (custom routes
|
|
4
|
+
registered via `route()`), and the authenticator/authorizer/dashboard
|
|
5
|
+
wiring. CRUD/page route generation and template/static configuration
|
|
6
|
+
live in the framework adapters (e.g. polyadmin.fastapi), not here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from polyadmin.core.model_admin import ModelAdmin
|
|
16
|
+
from polyadmin.core.page import AdminPage, PageHandler
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Admin:
|
|
20
|
+
"""Root admin application: owns the ModelAdmin and AdminPage registries."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
model_admins: Iterable[ModelAdmin] = (),
|
|
25
|
+
*,
|
|
26
|
+
dashboard: Any | None = None,
|
|
27
|
+
authenticator: Any | None = None,
|
|
28
|
+
authorizer: Any | None = None,
|
|
29
|
+
site_title: str = "PolyAdmin",
|
|
30
|
+
site_logo_url: str | None = None,
|
|
31
|
+
disable_csrf: bool = False,
|
|
32
|
+
audit_logger: Any | None = None,
|
|
33
|
+
login_backend: Any | None = None,
|
|
34
|
+
default_locale: str = "en",
|
|
35
|
+
locales: Sequence[str] = (),
|
|
36
|
+
locale_resolver: Callable[[Any, Any], str | None] | None = None,
|
|
37
|
+
catalogs: Sequence[tuple[str | Path, str]] = (),
|
|
38
|
+
translator: Any | None = None,
|
|
39
|
+
locale_names: Mapping[str, str] | None = None,
|
|
40
|
+
locale_switcher: bool = True,
|
|
41
|
+
pseudo_locale: bool = False,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.dashboard = dashboard
|
|
44
|
+
self.authenticator = authenticator
|
|
45
|
+
self.authorizer = authorizer
|
|
46
|
+
self.site_title = site_title
|
|
47
|
+
self.site_logo_url = site_logo_url
|
|
48
|
+
# Opt-out, never opt-in: a security control that defaults to off
|
|
49
|
+
# is one nobody turns on. The token cookie is still minted when
|
|
50
|
+
# this is set, so templates and custom pages behave identically.
|
|
51
|
+
self.disable_csrf = disable_csrf
|
|
52
|
+
# When set, receives an entry for every create, update, delete
|
|
53
|
+
# and action. None means nothing is recorded -- the framework
|
|
54
|
+
# does not store a log itself. See core/audit.py.
|
|
55
|
+
self.audit_logger = audit_logger
|
|
56
|
+
# When set, mounts the built-in login page and makes an
|
|
57
|
+
# unauthenticated request redirect there instead of returning
|
|
58
|
+
# 401. None leaves both behaviours off -- see core/login.py.
|
|
59
|
+
self.login_backend = login_backend
|
|
60
|
+
# Internationalisation -- see polyadmin/i18n and docs/i18n.md. The
|
|
61
|
+
# defaults serve English plus every framework catalog (fr, ru),
|
|
62
|
+
# resolved per request, with the language switcher shown.
|
|
63
|
+
# locale_resolver(request, principal) gets the request's
|
|
64
|
+
# authenticated principal on every page, login and error pages
|
|
65
|
+
# included -- None only without a session or an authenticator.
|
|
66
|
+
self.default_locale = default_locale
|
|
67
|
+
self.locales = list(locales)
|
|
68
|
+
self.locale_resolver = locale_resolver
|
|
69
|
+
self.catalogs = list(catalogs)
|
|
70
|
+
self.translator = translator
|
|
71
|
+
self.locale_names = dict(locale_names or {})
|
|
72
|
+
self.locale_switcher = locale_switcher
|
|
73
|
+
self.pseudo_locale = pseudo_locale
|
|
74
|
+
self._registry: dict[str, ModelAdmin] = {}
|
|
75
|
+
self._pages: dict[str, AdminPage] = {}
|
|
76
|
+
for model_admin in model_admins:
|
|
77
|
+
self.register(model_admin)
|
|
78
|
+
|
|
79
|
+
def register(self, model_admin: ModelAdmin) -> None:
|
|
80
|
+
slug = model_admin.get_slug()
|
|
81
|
+
if slug in self._registry:
|
|
82
|
+
raise ValueError(f"A ModelAdmin is already registered for slug {slug!r}.")
|
|
83
|
+
model_admin.validate_detail_actions()
|
|
84
|
+
self._registry[slug] = model_admin
|
|
85
|
+
|
|
86
|
+
def get_model_admin(self, slug: str) -> ModelAdmin:
|
|
87
|
+
try:
|
|
88
|
+
return self._registry[slug]
|
|
89
|
+
except KeyError:
|
|
90
|
+
raise KeyError(f"No ModelAdmin registered for slug {slug!r}.") from None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def model_admins(self) -> list[ModelAdmin]:
|
|
94
|
+
return list(self._registry.values())
|
|
95
|
+
|
|
96
|
+
def route(
|
|
97
|
+
self,
|
|
98
|
+
path: str,
|
|
99
|
+
handler: PageHandler,
|
|
100
|
+
*,
|
|
101
|
+
label: str | None = None,
|
|
102
|
+
category: str | None = None,
|
|
103
|
+
icon: str = "collection",
|
|
104
|
+
permission: str | None = None,
|
|
105
|
+
methods: tuple[str, ...] = ("GET", "POST"),
|
|
106
|
+
show_in_nav: bool = True,
|
|
107
|
+
) -> AdminPage:
|
|
108
|
+
"""Register a custom admin page -- for functionality that isn't
|
|
109
|
+
resource CRUD (reports, wizards, internal tools). See
|
|
110
|
+
docs/routing.md.
|
|
111
|
+
"""
|
|
112
|
+
page = AdminPage(
|
|
113
|
+
path,
|
|
114
|
+
handler,
|
|
115
|
+
label=label,
|
|
116
|
+
category=category,
|
|
117
|
+
icon=icon,
|
|
118
|
+
permission=permission,
|
|
119
|
+
methods=methods,
|
|
120
|
+
show_in_nav=show_in_nav,
|
|
121
|
+
)
|
|
122
|
+
if page.path in self._pages:
|
|
123
|
+
raise ValueError(f"A page is already registered for path {page.path!r}.")
|
|
124
|
+
self._pages[page.path] = page
|
|
125
|
+
return page
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def pages(self) -> list[AdminPage]:
|
|
129
|
+
return list(self._pages.values())
|
polyadmin/core/audit.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Audit logging: who changed what, and where the record of it lives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
AUDIT_CREATE = "create"
|
|
10
|
+
AUDIT_UPDATE = "update"
|
|
11
|
+
AUDIT_DELETE = "delete"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class AuditEntry:
|
|
16
|
+
"""One recorded change: who did what to which record. Deliberately flat and
|
|
17
|
+
free of references into application state, since an entry outlives the
|
|
18
|
+
request that made it and a logger may serialise it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# When the change happened. Set by the framework, not the logger, so
|
|
22
|
+
# entries from several processes agree on what "now" meant.
|
|
23
|
+
at: datetime
|
|
24
|
+
# One of AUDIT_CREATE/AUDIT_UPDATE/AUDIT_DELETE, or an Action's name
|
|
25
|
+
# when a bulk or record action ran.
|
|
26
|
+
action: str
|
|
27
|
+
# The label is captured at write time because the record may not exist
|
|
28
|
+
# by the time anyone reads the log.
|
|
29
|
+
resource: str
|
|
30
|
+
object_pk: Any = None
|
|
31
|
+
object_label: str = ""
|
|
32
|
+
# Who made the change. None when no authenticator is configured,
|
|
33
|
+
# which is also the case in which an audit log is least meaningful.
|
|
34
|
+
principal: Any = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class AuditLogger(Protocol):
|
|
39
|
+
"""Receives an entry per change.
|
|
40
|
+
|
|
41
|
+
The framework never stores entries itself: it does not own persistence any
|
|
42
|
+
more than it owns identity, so where the log lives is the application's
|
|
43
|
+
decision.
|
|
44
|
+
|
|
45
|
+
`record` is called after the change has succeeded. An error is reported,
|
|
46
|
+
not swallowed, but never rolls the change back: the record is already
|
|
47
|
+
written, and failing the request would leave the user with an error next to
|
|
48
|
+
a change that did happen.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def record(self, entry: AuditEntry) -> None:
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@runtime_checkable
|
|
56
|
+
class AuditReader(Protocol):
|
|
57
|
+
"""The optional read side. A logger implementing it too gets a History section
|
|
58
|
+
on the record's detail page; one that does not simply records without
|
|
59
|
+
surfacing anything. Same optional-capability shape as list_page.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def history(self, resource: str, pk: Any, limit: int) -> list[AuditEntry]:
|
|
63
|
+
"""The most recent entries for one record, newest first, capped
|
|
64
|
+
at `limit`."""
|
|
65
|
+
...
|
polyadmin/core/auth.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Authenticator: Request -> Principal.
|
|
2
|
+
|
|
3
|
+
The admin doesn't care whether authentication comes from session
|
|
4
|
+
cookies, JWT, OAuth, or an existing IAM service -- only that something
|
|
5
|
+
can turn an inbound request into a Principal, or None if unauthenticated.
|
|
6
|
+
`request` is intentionally untyped here: whatever object the adapter
|
|
7
|
+
(e.g. a FastAPI Request) passes through is opaque to core.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
from polyadmin.i18n import N_
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Principal:
|
|
19
|
+
id: Any
|
|
20
|
+
display_name: str = ""
|
|
21
|
+
is_superuser: bool = False
|
|
22
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Authenticator(Protocol):
|
|
26
|
+
def authenticate(self, request: Any) -> Principal | None:
|
|
27
|
+
"""May also be `async def`, returning an Awaitable[Principal | None];
|
|
28
|
+
the FastAPI adapter awaits it via acached_principal when it is."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AllowAllAuthenticator:
|
|
33
|
+
"""Authenticates every request as the same Principal.
|
|
34
|
+
|
|
35
|
+
For local development and tests only -- never wire this into a
|
|
36
|
+
real deployment.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, principal: Principal | None = None) -> None:
|
|
40
|
+
# N_: the sidebar translates this default name (and only this one
|
|
41
|
+
# -- a principal's own name is never translated).
|
|
42
|
+
self._principal = principal or Principal(id="anonymous", display_name=N_("Anonymous"), is_superuser=True)
|
|
43
|
+
|
|
44
|
+
def authenticate(self, request: Any) -> Principal | None:
|
|
45
|
+
return self._principal
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DenyAllAuthenticator:
|
|
49
|
+
"""Authenticates nobody. Useful for asserting a login gate works."""
|
|
50
|
+
|
|
51
|
+
def authenticate(self, request: Any) -> Principal | None:
|
|
52
|
+
return None
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Authorizer: can(principal, permission, resource).
|
|
2
|
+
|
|
3
|
+
Standard permission names: `dashboard.view` and, per resource,
|
|
4
|
+
`{slug}.list` / `.view` / `.create` / `.update` / `.delete` / `.export`,
|
|
5
|
+
built by `resource_permission`. Applications add action-specific
|
|
6
|
+
permission strings of their own alongside these.
|
|
7
|
+
|
|
8
|
+
Authorization is always enforced server-side by the adapter --
|
|
9
|
+
`resource_permission` results also flow into templates so they can
|
|
10
|
+
hide controls the current principal can't use, but that's a UX
|
|
11
|
+
nicety, never the security boundary itself.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Protocol
|
|
16
|
+
|
|
17
|
+
DASHBOARD_VIEW = "dashboard.view"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resource_permission(slug: str, action: str) -> str:
|
|
21
|
+
return f"{slug}.{action}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Authorizer(Protocol):
|
|
25
|
+
def can(self, principal: Any, permission: str, resource: Any = None) -> bool: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AllowAllAuthorizer:
|
|
29
|
+
"""Grants every permission. For local development and tests only."""
|
|
30
|
+
|
|
31
|
+
def can(self, principal: Any, permission: str, resource: Any = None) -> bool:
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DenyAllAuthorizer:
|
|
36
|
+
"""Denies every permission. Useful for asserting a gate works."""
|
|
37
|
+
|
|
38
|
+
def can(self, principal: Any, permission: str, resource: Any = None) -> bool:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SuperuserAuthorizer:
|
|
43
|
+
"""Grants every permission to superusers, denies everyone else -- a
|
|
44
|
+
reasonable default before an application builds out granular roles.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def can(self, principal: Any, permission: str, resource: Any = None) -> bool:
|
|
48
|
+
return bool(getattr(principal, "is_superuser", False))
|
polyadmin/core/csrf.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""CSRF token primitives.
|
|
2
|
+
|
|
3
|
+
The wire names below are shared with both
|
|
4
|
+
adapters' templates. Changing one side without the other silently breaks
|
|
5
|
+
every form in the other language.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hmac
|
|
11
|
+
import secrets
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
CSRF_COOKIE_NAME = "admin_csrf"
|
|
15
|
+
CSRF_HEADER_NAME = "X-CSRF-Token"
|
|
16
|
+
CSRF_FIELD_NAME = "_csrf"
|
|
17
|
+
|
|
18
|
+
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def new_csrf_token() -> str:
|
|
22
|
+
"""32 crypto-random bytes as unpadded base64url (43 characters)."""
|
|
23
|
+
return secrets.token_urlsafe(32)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_safe_method(method: str) -> bool:
|
|
27
|
+
"""Whether a method is read-only per RFC 9110, and so needs no token."""
|
|
28
|
+
return method.upper() in _SAFE_METHODS
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def csrf_tokens_match(a: str | None, b: str | None) -> bool:
|
|
32
|
+
"""Constant-time compare.
|
|
33
|
+
|
|
34
|
+
Empty on either side is always False: "no cookie" and "no submitted
|
|
35
|
+
token" must fail closed rather than match each other.
|
|
36
|
+
"""
|
|
37
|
+
if not a or not b:
|
|
38
|
+
return False
|
|
39
|
+
return hmac.compare_digest(a, b)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def safe_redirect_path(referer: str | None, host: str, base_path: str, fallback: str) -> str:
|
|
43
|
+
"""Validate a client-supplied Referer before using it as a redirect
|
|
44
|
+
target, returning `fallback` when it cannot be trusted.
|
|
45
|
+
|
|
46
|
+
A raw Referer is attacker-controlled: without this, an action could be
|
|
47
|
+
made to bounce the signed-in admin to any site on the internet. The
|
|
48
|
+
return value is always a path, so the redirect can only ever land
|
|
49
|
+
inside this admin.
|
|
50
|
+
"""
|
|
51
|
+
if not referer:
|
|
52
|
+
return fallback
|
|
53
|
+
try:
|
|
54
|
+
parsed = urlsplit(referer)
|
|
55
|
+
except ValueError:
|
|
56
|
+
return fallback
|
|
57
|
+
# A non-empty host must be ours. This also rejects protocol-relative
|
|
58
|
+
# "//evil.example.com/admin", which parses with no scheme but a
|
|
59
|
+
# foreign netloc.
|
|
60
|
+
if parsed.netloc and parsed.netloc != host:
|
|
61
|
+
return fallback
|
|
62
|
+
# A path a browser reads as protocol-relative: "//evil.example/x", or
|
|
63
|
+
# "/\\evil.example/x" (browsers treat "\\" like "/"). Under a root
|
|
64
|
+
# mount every path passes the base check below, so refuse these first.
|
|
65
|
+
if parsed.path.startswith(("//", "/\\")):
|
|
66
|
+
return fallback
|
|
67
|
+
# Exact match, or a child path -- "/adminX" must not pass for "/admin".
|
|
68
|
+
if parsed.path != base_path and not parsed.path.startswith(base_path + "/"):
|
|
69
|
+
return fallback
|
|
70
|
+
return f"{parsed.path}?{parsed.query}" if parsed.query else parsed.path
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Dashboard: a separate first-class concept.
|
|
2
|
+
|
|
3
|
+
The dashboard route is `GET /admin`. It's independent of any single
|
|
4
|
+
ModelAdmin -- a Dashboard is just a collection of Widgets, each
|
|
5
|
+
deciding its own data and, optionally, its own extra permission.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from polyadmin.core.widget import Widget
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Dashboard:
|
|
17
|
+
def __init__(
|
|
18
|
+
self, *, title: str = "Dashboard", widgets: Sequence[Widget] = ()
|
|
19
|
+
) -> None:
|
|
20
|
+
self.title = title
|
|
21
|
+
self.widgets = list(widgets)
|
|
22
|
+
|
|
23
|
+
def get_widgets(
|
|
24
|
+
self, principal: Any = None, authorizer: Any = None
|
|
25
|
+
) -> list[Widget]:
|
|
26
|
+
"""Widgets visible to `principal`: a widget with no
|
|
27
|
+
`permission` is always shown; one that names a permission is
|
|
28
|
+
simply omitted -- not shown-disabled -- if the authorizer
|
|
29
|
+
denies it (or there's no authorizer to ask, in which case it's
|
|
30
|
+
shown, matching the rest of the framework's no-authorizer
|
|
31
|
+
default of permitting everything).
|
|
32
|
+
"""
|
|
33
|
+
visible = []
|
|
34
|
+
for widget in self.widgets:
|
|
35
|
+
if (
|
|
36
|
+
widget.permission is None
|
|
37
|
+
or authorizer is None
|
|
38
|
+
or authorizer.can(principal, widget.permission, widget)
|
|
39
|
+
):
|
|
40
|
+
visible.append(widget)
|
|
41
|
+
return visible
|