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.
Files changed (104) hide show
  1. polyadmin/__init__.py +47 -0
  2. polyadmin/core/__init__.py +0 -0
  3. polyadmin/core/_async.py +21 -0
  4. polyadmin/core/action.py +135 -0
  5. polyadmin/core/admin.py +129 -0
  6. polyadmin/core/audit.py +65 -0
  7. polyadmin/core/auth.py +52 -0
  8. polyadmin/core/authorization.py +48 -0
  9. polyadmin/core/csrf.py +70 -0
  10. polyadmin/core/dashboard.py +41 -0
  11. polyadmin/core/delete.py +146 -0
  12. polyadmin/core/exporter.py +136 -0
  13. polyadmin/core/field.py +201 -0
  14. polyadmin/core/filter.py +305 -0
  15. polyadmin/core/inline.py +97 -0
  16. polyadmin/core/login.py +110 -0
  17. polyadmin/core/model_admin.py +349 -0
  18. polyadmin/core/page.py +62 -0
  19. polyadmin/core/pagination.py +70 -0
  20. polyadmin/core/query.py +243 -0
  21. polyadmin/core/relation.py +40 -0
  22. polyadmin/core/slug.py +57 -0
  23. polyadmin/core/template_context.py +679 -0
  24. polyadmin/core/widget.py +280 -0
  25. polyadmin/fastapi/__init__.py +3 -0
  26. polyadmin/fastapi/audit.py +52 -0
  27. polyadmin/fastapi/auth.py +112 -0
  28. polyadmin/fastapi/csrf.py +93 -0
  29. polyadmin/fastapi/deletes.py +76 -0
  30. polyadmin/fastapi/errors.py +93 -0
  31. polyadmin/fastapi/handlers.py +798 -0
  32. polyadmin/fastapi/inlines.py +177 -0
  33. polyadmin/fastapi/locale.py +128 -0
  34. polyadmin/fastapi/login.py +110 -0
  35. polyadmin/fastapi/pages.py +97 -0
  36. polyadmin/fastapi/relations.py +264 -0
  37. polyadmin/fastapi/responses.py +55 -0
  38. polyadmin/fastapi/router.py +174 -0
  39. polyadmin/fastapi/static.py +21 -0
  40. polyadmin/i18n/__init__.py +50 -0
  41. polyadmin/i18n/context.py +54 -0
  42. polyadmin/i18n/negotiation.py +56 -0
  43. polyadmin/i18n/setup.py +81 -0
  44. polyadmin/i18n/translator.py +124 -0
  45. polyadmin/locale/fr/LC_MESSAGES/polyadmin.mo +0 -0
  46. polyadmin/locale/fr/LC_MESSAGES/polyadmin.po +486 -0
  47. polyadmin/locale/polyadmin.pot +485 -0
  48. polyadmin/locale/ru/LC_MESSAGES/polyadmin.mo +0 -0
  49. polyadmin/locale/ru/LC_MESSAGES/polyadmin.po +496 -0
  50. polyadmin/templates/admin/base.html +91 -0
  51. polyadmin/templates/admin/components/action_confirm_modal.html +86 -0
  52. polyadmin/templates/admin/components/csrf-field.html +5 -0
  53. polyadmin/templates/admin/components/error_fragment.html +6 -0
  54. polyadmin/templates/admin/components/field.html +60 -0
  55. polyadmin/templates/admin/components/form_wrapper.html +131 -0
  56. polyadmin/templates/admin/components/icons.html +67 -0
  57. polyadmin/templates/admin/components/inline.html +251 -0
  58. polyadmin/templates/admin/components/inline_fragment.html +2 -0
  59. polyadmin/templates/admin/components/list_content.html +54 -0
  60. polyadmin/templates/admin/components/lookup_results.html +19 -0
  61. polyadmin/templates/admin/components/search.html +19 -0
  62. polyadmin/templates/admin/components/toasts.html +151 -0
  63. polyadmin/templates/admin/components/ui/breadcrumb.html +30 -0
  64. polyadmin/templates/admin/components/ui/bulk-actions.html +69 -0
  65. polyadmin/templates/admin/components/ui/calendar.html +175 -0
  66. polyadmin/templates/admin/components/ui/combobox.html +82 -0
  67. polyadmin/templates/admin/components/ui/delete-preview.html +37 -0
  68. polyadmin/templates/admin/components/ui/dropdown-menu.html +71 -0
  69. polyadmin/templates/admin/components/ui/field.html +110 -0
  70. polyadmin/templates/admin/components/ui/filter-panel.html +155 -0
  71. polyadmin/templates/admin/components/ui/locale-switcher.html +30 -0
  72. polyadmin/templates/admin/components/ui/multi-select.html +253 -0
  73. polyadmin/templates/admin/components/ui/pagination.html +81 -0
  74. polyadmin/templates/admin/components/ui/radio-group.html +28 -0
  75. polyadmin/templates/admin/components/ui/select.html +165 -0
  76. polyadmin/templates/admin/components/ui/sidebar.html +175 -0
  77. polyadmin/templates/admin/components/ui/slider.html +22 -0
  78. polyadmin/templates/admin/components/ui/switch.html +36 -0
  79. polyadmin/templates/admin/components/ui/table.html +221 -0
  80. polyadmin/templates/admin/components/ui/theme-toggle.html +33 -0
  81. polyadmin/templates/admin/dashboard.html +35 -0
  82. polyadmin/templates/admin/error.html +33 -0
  83. polyadmin/templates/admin/login.html +94 -0
  84. polyadmin/templates/admin/resource/delete.html +29 -0
  85. polyadmin/templates/admin/resource/delete_selected.html +49 -0
  86. polyadmin/templates/admin/resource/detail.html +78 -0
  87. polyadmin/templates/admin/resource/form.html +5 -0
  88. polyadmin/templates/admin/resource/list.html +5 -0
  89. polyadmin/templates/admin/theme.html +372 -0
  90. polyadmin/templates/admin/widgets/activity.html +8 -0
  91. polyadmin/templates/admin/widgets/chart.html +15 -0
  92. polyadmin/templates/admin/widgets/donut.html +59 -0
  93. polyadmin/templates/admin/widgets/metric.html +1 -0
  94. polyadmin/templates/admin/widgets/progress.html +7 -0
  95. polyadmin/templates/admin/widgets/stat.html +22 -0
  96. polyadmin/templates/admin/widgets/table.html +29 -0
  97. polyadmin/templates/admin/widgets/tabs.html +34 -0
  98. polyadmin/templates/admin/widgets/timeline.html +21 -0
  99. polyadmin/templating.py +528 -0
  100. polyadmin/ui.py +817 -0
  101. polyadmin-0.1.0b1.dist-info/METADATA +239 -0
  102. polyadmin-0.1.0b1.dist-info/RECORD +104 -0
  103. polyadmin-0.1.0b1.dist-info/WHEEL +4 -0
  104. 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
@@ -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
@@ -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
+ ]
@@ -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())
@@ -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