simple-module-core 0.0.32__tar.gz → 0.0.33__tar.gz
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.
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/PKG-INFO +1 -1
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/pyproject.toml +1 -1
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/__init__.py +5 -1
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/audit_links.py +47 -4
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/__init__.py +2 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_pages.py +67 -9
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/i18n.py +22 -2
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/menu.py +34 -2
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/module.py +34 -29
- simple_module_core-0.0.33/simple_module_core/module_meta.py +53 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/permissions.py +15 -2
- simple_module_core-0.0.33/simple_module_core/redirect_safety.py +78 -0
- simple_module_core-0.0.33/simple_module_core/services.py +95 -0
- simple_module_core-0.0.33/simple_module_core/settings_base.py +47 -0
- simple_module_core-0.0.33/simple_module_core/setup_steps.py +141 -0
- simple_module_core-0.0.33/tests/test_audit_links.py +147 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_i18n.py +35 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_menu.py +59 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_module_diagnostics.py +0 -36
- simple_module_core-0.0.33/tests/test_redirect_safety.py +59 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_services.py +6 -1
- simple_module_core-0.0.33/tests/test_settings_base.py +75 -0
- simple_module_core-0.0.33/tests/test_sm003_page_render.py +122 -0
- simple_module_core-0.0.32/simple_module_core/services.py +0 -51
- simple_module_core-0.0.32/tests/test_audit_links.py +0 -57
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/.gitignore +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/LICENSE +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/README.md +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/__main__.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/csp.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/design_packs.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_coupling.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_css.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_i18n.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_inertia_api.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_js_workspace.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_migration.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_module.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_runner.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_types.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/discovery.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/dotenv.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/environments.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/events.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/exceptions.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/feature_flags.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/health.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/public_routes.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/py.typed +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/versioning.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_auth_provider_selection.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_csp_registry.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_css_diagnostics.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_css_scanner.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_design_packs.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_diagnostics.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_discovery.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_dotenv.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_environments.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_events.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_feature_flags.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_feature_flags_decorator.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_health_registry.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_i18n_diagnostics.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_module_base.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_permissions.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_public_routes.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_versioning.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_core
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.33
|
|
4
4
|
Summary: Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""SimpleModule Core - Module system, menu, permissions, events, and diagnostics."""
|
|
2
2
|
|
|
3
|
-
from simple_module_core.audit_links import AuditLink, AuditLinkRegistry
|
|
3
|
+
from simple_module_core.audit_links import AuditLink, AuditLinkRegistry, LabelResolver
|
|
4
4
|
from simple_module_core.csp import CspSourceError, CspSourceRegistry
|
|
5
5
|
from simple_module_core.design_packs import DesignPack, DesignPackRegistry
|
|
6
6
|
from simple_module_core.diagnostics import (
|
|
@@ -41,6 +41,7 @@ from simple_module_core.module import ModuleBase, ModuleMeta
|
|
|
41
41
|
from simple_module_core.permissions import PermissionRegistry
|
|
42
42
|
from simple_module_core.public_routes import PublicRoute, PublicRouteRegistry
|
|
43
43
|
from simple_module_core.services import Services
|
|
44
|
+
from simple_module_core.setup_steps import SetupRegistry, SetupStep
|
|
44
45
|
from simple_module_core.versioning import FRAMEWORK_API_VERSION, check_framework_compatibility
|
|
45
46
|
|
|
46
47
|
__all__ = [
|
|
@@ -65,6 +66,7 @@ __all__ = [
|
|
|
65
66
|
"HealthStatus",
|
|
66
67
|
"I18nRegistry",
|
|
67
68
|
"InvalidModuleError",
|
|
69
|
+
"LabelResolver",
|
|
68
70
|
"MenuItem",
|
|
69
71
|
"MenuRegistry",
|
|
70
72
|
"MenuSection",
|
|
@@ -77,6 +79,8 @@ __all__ = [
|
|
|
77
79
|
"PublicRoute",
|
|
78
80
|
"PublicRouteRegistry",
|
|
79
81
|
"Services",
|
|
82
|
+
"SetupRegistry",
|
|
83
|
+
"SetupStep",
|
|
80
84
|
"Translator",
|
|
81
85
|
"ValidationError",
|
|
82
86
|
"check_framework_compatibility",
|
|
@@ -19,7 +19,23 @@ the target route as usual.
|
|
|
19
19
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
22
|
-
from
|
|
22
|
+
from collections.abc import Awaitable, Callable
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
LabelResolver = Callable[[Any, list[str]], Awaitable[dict[str, str]]]
|
|
27
|
+
"""Batch "what is this row called" for one entity type.
|
|
28
|
+
|
|
29
|
+
Called with the request's database session and every entity id of that type on
|
|
30
|
+
the page, and returns ``{entity_id: display name}``. Ids it cannot name are
|
|
31
|
+
simply absent — the caller falls back to the id, which is what the audit row
|
|
32
|
+
actually stored.
|
|
33
|
+
|
|
34
|
+
The session is typed ``Any`` because ``simple_module_core`` must not depend on
|
|
35
|
+
SQLAlchemy; in practice it is the ``AsyncSession`` the request is already
|
|
36
|
+
using, so a resolver adds one query per entity type per page and no session
|
|
37
|
+
management of its own.
|
|
38
|
+
"""
|
|
23
39
|
|
|
24
40
|
_ID_PLACEHOLDER = "{id}"
|
|
25
41
|
|
|
@@ -36,24 +52,51 @@ class AuditLink:
|
|
|
36
52
|
unmatched lookup falls back to showing ``entity_type`` as the
|
|
37
53
|
label, a table-name key looks like it worked.
|
|
38
54
|
url_template: Path containing ``{id}``, substituted with the entity id
|
|
39
|
-
(e.g. ``"/admin/users/{id}/edit"``).
|
|
55
|
+
(e.g. ``"/admin/users/{id}/edit"``). Empty means *these rows have
|
|
56
|
+
no screen* — join tables and stored files are audited but have
|
|
57
|
+
nowhere to open — and the cell renders the name unlinked. A module
|
|
58
|
+
still registers those, because it is the only thing that knows what
|
|
59
|
+
the table is called and what its rows are named.
|
|
40
60
|
label: Human-readable name for the entity kind, shown instead of the
|
|
41
61
|
raw class name (e.g. ``"User account"``).
|
|
62
|
+
label_key: Catalog key for ``label``. Empty, or unresolved, falls back
|
|
63
|
+
to ``label`` — a missing translation shows English rather than a
|
|
64
|
+
raw dotted key. Rows are rendered server-side, so the audit view
|
|
65
|
+
translates these before they reach the page.
|
|
66
|
+
table_name: ``__tablename__`` of the table these rows live in
|
|
67
|
+
(``"users_user"``), shown as the type tag beside the row's name.
|
|
68
|
+
Not derivable from ``entity_type`` in either direction, so the
|
|
69
|
+
module that owns the table states both. ``None`` falls back to the
|
|
70
|
+
class name.
|
|
71
|
+
label_resolver: Optional :data:`LabelResolver` naming individual rows.
|
|
72
|
+
Without it a reader gets a primary key and has to paste it into
|
|
73
|
+
another screen to learn which record changed; only the owning
|
|
74
|
+
module knows that a ``Setting`` is named by its key and a ``User``
|
|
75
|
+
by ``full_name or email``. Excluded from equality: it is typically
|
|
76
|
+
a closure, and two boots would build two unequal objects out of one
|
|
77
|
+
module — a registry conflict is about two modules claiming one
|
|
78
|
+
entity type, which the identity fields already express.
|
|
42
79
|
"""
|
|
43
80
|
|
|
44
81
|
entity_type: str
|
|
45
82
|
url_template: str
|
|
46
83
|
label: str = ""
|
|
84
|
+
label_key: str = ""
|
|
85
|
+
table_name: str | None = None
|
|
86
|
+
label_resolver: LabelResolver | None = field(default=None, compare=False)
|
|
47
87
|
|
|
48
88
|
def __post_init__(self) -> None:
|
|
49
|
-
if _ID_PLACEHOLDER not in self.url_template:
|
|
89
|
+
if self.url_template and _ID_PLACEHOLDER not in self.url_template:
|
|
50
90
|
raise ValueError(
|
|
51
91
|
f"AuditLink for {self.entity_type!r} has url_template "
|
|
52
92
|
f"{self.url_template!r}, which contains no {_ID_PLACEHOLDER} — "
|
|
53
93
|
f"every row would link to the same page"
|
|
54
94
|
)
|
|
55
95
|
|
|
56
|
-
def url_for(self, entity_id: str) -> str:
|
|
96
|
+
def url_for(self, entity_id: str) -> str | None:
|
|
97
|
+
"""The row's page, or ``None`` when this kind of row has no screen."""
|
|
98
|
+
if not self.url_template:
|
|
99
|
+
return None
|
|
57
100
|
return self.url_template.replace(_ID_PLACEHOLDER, entity_id)
|
|
58
101
|
|
|
59
102
|
|
{simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/__init__.py
RENAMED
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
from simple_module_core.diagnostics._i18n import I18nDiagnostics
|
|
11
11
|
from simple_module_core.diagnostics._migration import MigrationDiagnostics
|
|
12
12
|
from simple_module_core.diagnostics._module import ModuleDiagnostics
|
|
13
|
+
from simple_module_core.diagnostics._pages import collect_tsx_pages
|
|
13
14
|
from simple_module_core.diagnostics._runner import print_diagnostics, run_diagnostics
|
|
14
15
|
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
|
|
15
16
|
|
|
@@ -19,6 +20,7 @@ __all__ = [
|
|
|
19
20
|
"I18nDiagnostics",
|
|
20
21
|
"MigrationDiagnostics",
|
|
21
22
|
"ModuleDiagnostics",
|
|
23
|
+
"collect_tsx_pages",
|
|
22
24
|
"print_diagnostics",
|
|
23
25
|
"run_diagnostics",
|
|
24
26
|
]
|
{simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_pages.py
RENAMED
|
@@ -12,17 +12,62 @@ if TYPE_CHECKING:
|
|
|
12
12
|
from simple_module_core.module import ModuleBase
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
def _assignment(s: ast.stmt) -> tuple[ast.Name, ast.expr] | None:
|
|
16
|
+
"""Return ``(target, value)`` for a single-target top-level assignment.
|
|
17
|
+
|
|
18
|
+
Covers both ``NAME = ...`` and the annotated ``NAME: Final = ...`` form
|
|
19
|
+
module constants are conventionally written in.
|
|
20
|
+
"""
|
|
21
|
+
if isinstance(s, ast.Assign) and len(s.targets) == 1 and isinstance(s.targets[0], ast.Name):
|
|
22
|
+
return s.targets[0], s.value
|
|
23
|
+
if isinstance(s, ast.AnnAssign) and isinstance(s.target, ast.Name) and s.value is not None:
|
|
24
|
+
return s.target, s.value
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
15
28
|
def _module_level_str_consts(tree: ast.Module) -> dict[str, str]:
|
|
16
29
|
"""Return ``{name: literal}`` for top-level ``NAME = "string"`` assignments."""
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
consts: dict[str, str] = {}
|
|
31
|
+
for s in tree.body:
|
|
32
|
+
assignment = _assignment(s)
|
|
33
|
+
if assignment is None:
|
|
34
|
+
continue
|
|
35
|
+
target, value = assignment
|
|
36
|
+
if isinstance(value, ast.Constant) and isinstance(value.value, str):
|
|
37
|
+
consts[target.id] = value.value
|
|
38
|
+
return consts
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_fstring_consts(tree: ast.Module, consts: dict[str, str]) -> dict[str, str]:
|
|
42
|
+
"""Resolve top-level ``NAME = f"{CONST}/lit"`` against already-known consts.
|
|
43
|
+
|
|
44
|
+
Only plain interpolations of known string constants count — a conversion,
|
|
45
|
+
format spec, or unknown name makes the value non-static, so it is skipped
|
|
46
|
+
rather than guessed at.
|
|
47
|
+
"""
|
|
48
|
+
resolved: dict[str, str] = {}
|
|
49
|
+
for s in tree.body:
|
|
50
|
+
assignment = _assignment(s)
|
|
51
|
+
if assignment is None or not isinstance(assignment[1], ast.JoinedStr):
|
|
52
|
+
continue
|
|
53
|
+
target, value = assignment
|
|
54
|
+
parts: list[str] = []
|
|
55
|
+
for piece in value.values:
|
|
56
|
+
if isinstance(piece, ast.Constant) and isinstance(piece.value, str):
|
|
57
|
+
parts.append(piece.value)
|
|
58
|
+
elif (
|
|
59
|
+
isinstance(piece, ast.FormattedValue)
|
|
60
|
+
and isinstance(piece.value, ast.Name)
|
|
61
|
+
and piece.value.id in consts
|
|
62
|
+
and piece.conversion == -1
|
|
63
|
+
and piece.format_spec is None
|
|
64
|
+
):
|
|
65
|
+
parts.append(consts[piece.value.id])
|
|
66
|
+
else:
|
|
67
|
+
break
|
|
68
|
+
else:
|
|
69
|
+
resolved[target.id] = "".join(parts)
|
|
70
|
+
return resolved
|
|
26
71
|
|
|
27
72
|
|
|
28
73
|
def _iter_render_components(tree: ast.Module, consts: dict[str, str]) -> list[str]:
|
|
@@ -79,6 +124,19 @@ def find_render_calls(mod: ModuleBase, src_dir: Path) -> set[str]:
|
|
|
79
124
|
consts: dict[str, str] = {}
|
|
80
125
|
for tree in trees:
|
|
81
126
|
consts.update(_module_level_str_consts(tree))
|
|
127
|
+
# Then f-string constants built from the plain ones above
|
|
128
|
+
# (``PAGE = f"{MODULE_NAME}/Browse"`` is the conventional shape). Repeat to
|
|
129
|
+
# a fixed point so a chain — ``PREFIX = f"{NAME}/sub"`` then
|
|
130
|
+
# ``PAGE = f"{PREFIX}/Browse"`` — resolves too; one pass would only learn
|
|
131
|
+
# the first hop and report the page as an SM003 orphan.
|
|
132
|
+
while True:
|
|
133
|
+
resolved: dict[str, str] = {}
|
|
134
|
+
for tree in trees:
|
|
135
|
+
resolved.update(_resolve_fstring_consts(tree, consts))
|
|
136
|
+
new = {k: v for k, v in resolved.items() if consts.get(k) != v}
|
|
137
|
+
if not new:
|
|
138
|
+
break
|
|
139
|
+
consts.update(new)
|
|
82
140
|
|
|
83
141
|
prefix = f"{mod.meta.name}/"
|
|
84
142
|
rendered: set[str] = set()
|
|
@@ -145,8 +145,18 @@ class I18nRegistry:
|
|
|
145
145
|
# Plain-dict snapshots for serialization callers. ``dict(msgs)`` runs
|
|
146
146
|
# once here rather than on every Inertia render. The public variant
|
|
147
147
|
# (admin namespaces excluded) is what anonymous visitors receive.
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
#
|
|
149
|
+
# Each non-default locale layers over the default one, so a key it has
|
|
150
|
+
# not translated yet resolves to the default language instead of
|
|
151
|
+
# reaching the browser missing. The client cannot recover on its own:
|
|
152
|
+
# i18next is initialised with ``fallbackLng`` equal to the active
|
|
153
|
+
# locale, so an absent key renders as the raw dotted key — a partially
|
|
154
|
+
# translated locale would put "dashboard.home.health" on screen. The
|
|
155
|
+
# server-side ``Translator`` has always fallen back this way; this
|
|
156
|
+
# makes the payload behave the same, and makes partial translations a
|
|
157
|
+
# safe, incremental state to be in.
|
|
158
|
+
self._message_snapshots = self._layered_snapshots(self._messages)
|
|
159
|
+
self._public_snapshots = self._layered_snapshots(public_messages)
|
|
150
160
|
self._available_locales = tuple(locale for locale, msgs in self._messages.items() if msgs)
|
|
151
161
|
self._available_locales_list = list(self._available_locales)
|
|
152
162
|
self._loaded = True
|
|
@@ -162,6 +172,16 @@ class I18nRegistry:
|
|
|
162
172
|
return self._available_locales_list
|
|
163
173
|
return [locale for locale, msgs in self._messages.items() if msgs]
|
|
164
174
|
|
|
175
|
+
def _layered_snapshots(self, messages: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]:
|
|
176
|
+
"""Snapshot per locale, each layered over the default locale's."""
|
|
177
|
+
base = messages.get(self.default_locale, {})
|
|
178
|
+
return {
|
|
179
|
+
locale: dict(msgs)
|
|
180
|
+
if locale == self.default_locale
|
|
181
|
+
else {**base, **msgs} # translated entries win; the rest read as default
|
|
182
|
+
for locale, msgs in messages.items()
|
|
183
|
+
}
|
|
184
|
+
|
|
165
185
|
def messages(self, locale: str) -> Mapping[str, str]:
|
|
166
186
|
"""Flat dotted-key map for the given locale. Empty mapping if unknown.
|
|
167
187
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections.abc import Callable
|
|
5
6
|
from dataclasses import dataclass, field
|
|
6
7
|
from enum import StrEnum
|
|
7
8
|
from typing import Literal
|
|
@@ -24,6 +25,15 @@ class MenuItem:
|
|
|
24
25
|
|
|
25
26
|
label: str
|
|
26
27
|
url: str
|
|
28
|
+
label_key: str = ""
|
|
29
|
+
"""Catalog key for ``label``. Empty = ship ``label`` verbatim.
|
|
30
|
+
|
|
31
|
+
Menu labels are rendered on every page, so they are translated on the
|
|
32
|
+
server — the payload carries finished text and every render site (sidebar,
|
|
33
|
+
topbar, command palette) keeps working untouched. A key that resolves to
|
|
34
|
+
nothing falls back to ``label``, so a missing translation degrades to
|
|
35
|
+
English rather than to a raw dotted key on screen.
|
|
36
|
+
"""
|
|
27
37
|
icon: str = ""
|
|
28
38
|
order: int = 0
|
|
29
39
|
section: MenuSection = MenuSection.SIDEBAR
|
|
@@ -42,6 +52,14 @@ class MenuItem:
|
|
|
42
52
|
method: MenuItemMethod = "get"
|
|
43
53
|
"""HTTP method used when the item is activated. ``"post"`` renders as an
|
|
44
54
|
Inertia form submission so the target endpoint can be POST-only (e.g. logout)."""
|
|
55
|
+
group_key: str = ""
|
|
56
|
+
"""Catalog key for ``group``, with the same fallback rule as ``label_key``.
|
|
57
|
+
|
|
58
|
+
Group headers are shared vocabulary — several modules file entries under
|
|
59
|
+
"Administration" — so they live in the ``ui`` namespace. Letting each
|
|
60
|
+
module invent its own key would let one translation drift from another and
|
|
61
|
+
split a single header in two.
|
|
62
|
+
"""
|
|
45
63
|
group: str = ""
|
|
46
64
|
"""Sidebar group label. Empty = ungrouped (renders flat, no header).
|
|
47
65
|
Items in the same section that share a group are visually clustered under a
|
|
@@ -79,6 +97,7 @@ class MenuRegistry:
|
|
|
79
97
|
is_authenticated: bool,
|
|
80
98
|
roles: list[str] | None = None,
|
|
81
99
|
permissions: list[str] | None = None,
|
|
100
|
+
translate: Callable[[str], str] | None = None,
|
|
82
101
|
) -> dict[str, list[dict]]:
|
|
83
102
|
"""Return menu items grouped by section, filtered by auth/roles/permissions.
|
|
84
103
|
|
|
@@ -86,12 +105,25 @@ class MenuRegistry:
|
|
|
86
105
|
wildcards). Items declaring permissions the caller lacks are dropped,
|
|
87
106
|
so the sidebar never offers a screen that will 403 on click.
|
|
88
107
|
|
|
108
|
+
``translate`` resolves ``label_key``/``group_key`` against the request's
|
|
109
|
+
locale. Omitting it (or omitting the keys) ships the literal ``label``
|
|
110
|
+
and ``group``, which is what third-party modules predating the keys do.
|
|
111
|
+
|
|
89
112
|
Returns a dict ready to be serialized into Inertia shared props.
|
|
90
113
|
"""
|
|
91
114
|
roles = roles or []
|
|
92
115
|
granted = set(permissions or [])
|
|
93
116
|
result: dict[str, list[dict]] = {s.value: [] for s in MenuSection}
|
|
94
117
|
|
|
118
|
+
def render(key: str, fallback: str) -> str:
|
|
119
|
+
# Translator.t() echoes the key back when the catalog has no entry.
|
|
120
|
+
# Showing "users.nav.users" in the sidebar would be worse than the
|
|
121
|
+
# English it replaced, so an unresolved key keeps the literal.
|
|
122
|
+
if not key or translate is None:
|
|
123
|
+
return fallback
|
|
124
|
+
translated = translate(key)
|
|
125
|
+
return fallback if translated == key else translated
|
|
126
|
+
|
|
95
127
|
for item in self.all_items:
|
|
96
128
|
if item.requires_auth and not is_authenticated:
|
|
97
129
|
continue
|
|
@@ -103,11 +135,11 @@ class MenuRegistry:
|
|
|
103
135
|
continue
|
|
104
136
|
result[item.section.value].append(
|
|
105
137
|
{
|
|
106
|
-
"label": item.label,
|
|
138
|
+
"label": render(item.label_key, item.label),
|
|
107
139
|
"url": item.url,
|
|
108
140
|
"icon": item.icon,
|
|
109
141
|
"method": item.method,
|
|
110
|
-
"group": item.group,
|
|
142
|
+
"group": render(item.group_key, item.group),
|
|
111
143
|
}
|
|
112
144
|
)
|
|
113
145
|
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
"""Module base class
|
|
1
|
+
"""Module lifecycle base class. ``ModuleMeta`` lives in ``module_meta``."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from abc import ABC
|
|
6
|
-
from dataclasses import dataclass, field
|
|
7
6
|
from pathlib import Path
|
|
8
7
|
from typing import TYPE_CHECKING
|
|
9
8
|
|
|
9
|
+
from simple_module_core.module_meta import ModuleMeta
|
|
10
|
+
|
|
10
11
|
if TYPE_CHECKING:
|
|
11
12
|
from fastapi import APIRouter, FastAPI
|
|
12
13
|
|
|
@@ -19,33 +20,7 @@ if TYPE_CHECKING:
|
|
|
19
20
|
from simple_module_core.menu import MenuRegistry
|
|
20
21
|
from simple_module_core.permissions import PermissionRegistry
|
|
21
22
|
from simple_module_core.public_routes import PublicRouteRegistry
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@dataclass(frozen=True)
|
|
25
|
-
class ModuleMeta:
|
|
26
|
-
"""Metadata describing a module."""
|
|
27
|
-
|
|
28
|
-
name: str
|
|
29
|
-
route_prefix: str = ""
|
|
30
|
-
view_prefix: str = ""
|
|
31
|
-
depends_on: list[str] = field(default_factory=list)
|
|
32
|
-
version: str = "1.0.0"
|
|
33
|
-
requires_framework: str | None = None
|
|
34
|
-
"""PEP 440 specifier for the framework API version this module supports.
|
|
35
|
-
|
|
36
|
-
Example: ``">=1.0,<2.0"``. When set, the module is rejected at boot if the
|
|
37
|
-
installed ``simple_module_core.FRAMEWORK_API_VERSION`` does not satisfy it.
|
|
38
|
-
When ``None``, no compatibility check is performed (legacy modules).
|
|
39
|
-
"""
|
|
40
|
-
i18n_audience: str = "public"
|
|
41
|
-
"""Who this module's locale catalog is shipped to: ``"public"`` or ``"admin"``.
|
|
42
|
-
|
|
43
|
-
``"public"`` (the default) ships the catalog in every Inertia payload.
|
|
44
|
-
``"admin"`` ships it only to authenticated users — declare it on modules
|
|
45
|
-
whose UI sits entirely behind login (settings, permissions, dashboards) so
|
|
46
|
-
anonymous visitors don't download admin form labels on every public page.
|
|
47
|
-
The catalog is always available server-side (``Translator``) either way.
|
|
48
|
-
"""
|
|
23
|
+
from simple_module_core.setup_steps import SetupRegistry
|
|
49
24
|
|
|
50
25
|
|
|
51
26
|
class ModuleBase(ABC):
|
|
@@ -111,6 +86,19 @@ class ModuleBase(ABC):
|
|
|
111
86
|
) -> None:
|
|
112
87
|
"""Register API endpoints and Inertia view routes."""
|
|
113
88
|
|
|
89
|
+
def register_admin_routes(self, admin_router: APIRouter) -> None:
|
|
90
|
+
"""Register admin-only view routes, mounted at ``meta.admin_view_prefix``.
|
|
91
|
+
|
|
92
|
+
Only needed by modules that serve both public and administrative
|
|
93
|
+
pages — see ``ModuleMeta.admin_view_prefix``. A module whose views are
|
|
94
|
+
all administrative should point ``view_prefix`` at ``/admin/...`` and
|
|
95
|
+
keep using ``register_routes``.
|
|
96
|
+
|
|
97
|
+
Nothing here is gated automatically: the prefix is a URL convention,
|
|
98
|
+
not a permission. Guard these routes with the same dependencies you
|
|
99
|
+
would use anywhere else.
|
|
100
|
+
"""
|
|
101
|
+
|
|
114
102
|
def register_menu_items(self, registry: MenuRegistry) -> None:
|
|
115
103
|
"""Contribute menu items visible in the UI."""
|
|
116
104
|
|
|
@@ -131,6 +119,20 @@ class ModuleBase(ABC):
|
|
|
131
119
|
def register_health_checks(self, registry: HealthRegistry) -> None:
|
|
132
120
|
"""Contribute health checks for the ``/health/ready`` endpoint."""
|
|
133
121
|
|
|
122
|
+
def register_setup_steps(self, registry: SetupRegistry) -> None:
|
|
123
|
+
"""Declare what this module needs before the install is usable.
|
|
124
|
+
|
|
125
|
+
While any required step reports incomplete, ``SetupMiddleware`` serves
|
|
126
|
+
the first-run wizard instead of the app::
|
|
127
|
+
|
|
128
|
+
registry.add(SetupStep(id="users.administrator", ...))
|
|
129
|
+
|
|
130
|
+
A module that registers nothing never gates the app — that is how an
|
|
131
|
+
external identity provider opts out. See docs/module-authoring.md.
|
|
132
|
+
|
|
133
|
+
Called once at boot, in dependency order.
|
|
134
|
+
"""
|
|
135
|
+
|
|
134
136
|
def register_public_routes(self, registry: PublicRouteRegistry) -> None:
|
|
135
137
|
"""Declare routes that must bypass authentication (anonymous access).
|
|
136
138
|
|
|
@@ -272,3 +274,6 @@ class ModuleBase(ABC):
|
|
|
272
274
|
|
|
273
275
|
async def on_shutdown(self, app: FastAPI) -> None:
|
|
274
276
|
"""Called during app shutdown."""
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
__all__ = ["ModuleBase", "ModuleMeta"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Module metadata — the declarative half of a module definition.
|
|
2
|
+
|
|
3
|
+
Split from ``module.py`` so that file holds only the lifecycle base class.
|
|
4
|
+
``ModuleMeta`` is what a module *declares* about itself (name, mount
|
|
5
|
+
points, dependencies, compatibility); ``ModuleBase`` is what it *does*.
|
|
6
|
+
They change for different reasons and are read by different callers —
|
|
7
|
+
discovery reads the metadata, the host invokes the hooks.
|
|
8
|
+
|
|
9
|
+
Re-exported from ``module.py``, so every existing import still works.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ModuleMeta:
|
|
19
|
+
"""Metadata describing a module."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
route_prefix: str = ""
|
|
23
|
+
view_prefix: str = ""
|
|
24
|
+
admin_view_prefix: str = ""
|
|
25
|
+
"""Mount point for admin-only view routes, e.g. ``"/admin/users"``.
|
|
26
|
+
|
|
27
|
+
A module gets exactly one ``view_prefix``, which is a problem for modules
|
|
28
|
+
that are only *partly* administrative: ``users`` serves ``/users/login``
|
|
29
|
+
and the user-management CRUD from the same package, and those belong in
|
|
30
|
+
different places in the URL space. Declaring this gives such a module a
|
|
31
|
+
second view router mounted here, populated by ``register_admin_routes``.
|
|
32
|
+
|
|
33
|
+
Modules that are administrative end to end don't need it — they just point
|
|
34
|
+
``view_prefix`` at ``/admin/...`` directly.
|
|
35
|
+
"""
|
|
36
|
+
depends_on: list[str] = field(default_factory=list)
|
|
37
|
+
version: str = "1.0.0"
|
|
38
|
+
requires_framework: str | None = None
|
|
39
|
+
"""PEP 440 specifier for the framework API version this module supports.
|
|
40
|
+
|
|
41
|
+
Example: ``">=1.0,<2.0"``. When set, the module is rejected at boot if the
|
|
42
|
+
installed ``simple_module_core.FRAMEWORK_API_VERSION`` does not satisfy it.
|
|
43
|
+
When ``None``, no compatibility check is performed (legacy modules).
|
|
44
|
+
"""
|
|
45
|
+
i18n_audience: str = "public"
|
|
46
|
+
"""Who this module's locale catalog is shipped to: ``"public"`` or ``"admin"``.
|
|
47
|
+
|
|
48
|
+
``"public"`` (the default) ships the catalog in every Inertia payload.
|
|
49
|
+
``"admin"`` ships it only to authenticated users — declare it on modules
|
|
50
|
+
whose UI sits entirely behind login (settings, permissions, dashboards) so
|
|
51
|
+
anonymous visitors don't download admin form labels on every public page.
|
|
52
|
+
The catalog is always available server-side (``Translator``) either way.
|
|
53
|
+
"""
|
|
@@ -6,13 +6,26 @@ from dataclasses import dataclass, field
|
|
|
6
6
|
|
|
7
7
|
WILDCARD = "*"
|
|
8
8
|
|
|
9
|
+
ADMIN_ROLE = "admin"
|
|
10
|
+
"""The one role name the framework itself knows.
|
|
11
|
+
|
|
12
|
+
Everything else about roles is module-owned, but the framework needs this to
|
|
13
|
+
resolve the wildcard grant below and to decide who can still reach the app
|
|
14
|
+
while maintenance mode is on.
|
|
15
|
+
"""
|
|
16
|
+
|
|
9
17
|
# Default role→permission mapping. Admin gets all permissions via the wildcard.
|
|
10
18
|
# Additional mappings are added at registration time via PermissionRegistry.map_role.
|
|
11
19
|
DEFAULT_ROLE_PERMISSIONS: dict[str, list[str]] = {
|
|
12
|
-
|
|
20
|
+
ADMIN_ROLE: [WILDCARD],
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
|
|
24
|
+
def is_admin(roles: list[str] | None) -> bool:
|
|
25
|
+
"""Whether ``roles`` carries the framework's admin role."""
|
|
26
|
+
return bool(roles) and ADMIN_ROLE in roles
|
|
27
|
+
|
|
28
|
+
|
|
16
29
|
@dataclass
|
|
17
30
|
class PermissionGroup:
|
|
18
31
|
"""A named group of related permissions (typically one per module)."""
|
|
@@ -111,7 +124,7 @@ class PermissionRegistry:
|
|
|
111
124
|
other roles get none. Override for richer mapping.
|
|
112
125
|
"""
|
|
113
126
|
if role_permission_map is None:
|
|
114
|
-
if
|
|
127
|
+
if is_admin(roles):
|
|
115
128
|
return set(self.all_permissions)
|
|
116
129
|
return set()
|
|
117
130
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Safety net for user-influenced redirect targets.
|
|
2
|
+
|
|
3
|
+
Several flows park "where the visitor was heading" somewhere a browser can
|
|
4
|
+
reach — ``AuthMiddleware`` stashes it in the session before bouncing an
|
|
5
|
+
anonymous visitor to login, ``site_lock`` puts it in the unlock page's query
|
|
6
|
+
string. Anything replayed into a ``Location`` header is an open-redirect
|
|
7
|
+
surface, so every producer and consumer funnels through :func:`safe_next`.
|
|
8
|
+
|
|
9
|
+
This lives in the framework rather than in whichever module needed it first:
|
|
10
|
+
it encodes no plugin knowledge, and duplicating URL-safety rules per module is
|
|
11
|
+
how one copy ends up missing a case.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
DEFAULT_FALLBACK = "/"
|
|
17
|
+
|
|
18
|
+
SESSION_NEXT_KEY = "next"
|
|
19
|
+
"""Session key holding the post-login destination.
|
|
20
|
+
|
|
21
|
+
This is the contract between ``AuthMiddleware`` (which writes it when it
|
|
22
|
+
bounces an anonymous visitor) and whichever provider completes the login and
|
|
23
|
+
sends the visitor onward. It is shared rather than redeclared per module
|
|
24
|
+
because a provider that reads a *different* key silently loses every deep
|
|
25
|
+
link — which is exactly how the local provider drifted from the Keycloak one.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def safe_next(raw: str | None, *, fallback: str = DEFAULT_FALLBACK) -> str:
|
|
30
|
+
"""Return ``raw`` if it is a same-site absolute path, else ``fallback``.
|
|
31
|
+
|
|
32
|
+
Rejects protocol-relative (``//host``) and backslash-prefixed (``/\\host``)
|
|
33
|
+
targets — browsers resolve both off-site — plus anything carrying CR/LF,
|
|
34
|
+
which could otherwise be smuggled into the redirect header.
|
|
35
|
+
"""
|
|
36
|
+
if not raw or not raw.startswith("/"):
|
|
37
|
+
return fallback
|
|
38
|
+
if raw.startswith(("//", "/\\")):
|
|
39
|
+
return fallback
|
|
40
|
+
if "\r" in raw or "\n" in raw:
|
|
41
|
+
return fallback
|
|
42
|
+
return raw
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def safe_next_or_none(raw: str | None) -> str | None:
|
|
46
|
+
"""Like :func:`safe_next`, but ``None`` when ``raw`` is unusable.
|
|
47
|
+
|
|
48
|
+
Callers that fall back to a *configured* destination (rather than ``/``)
|
|
49
|
+
need to tell "no target" apart from "the target was ``/``" — returning the
|
|
50
|
+
fallback would silently outrank a configured ``login_redirect_url``.
|
|
51
|
+
"""
|
|
52
|
+
result = safe_next(raw, fallback="")
|
|
53
|
+
return result or None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def non_empty_redirect(value: str, *, default: str) -> str:
|
|
57
|
+
"""Normalise a configured redirect destination, never returning ``""``.
|
|
58
|
+
|
|
59
|
+
Any auth provider can expose a ``login_redirect_url``-style setting, and
|
|
60
|
+
nothing stops an admin clearing it in the generic module-settings editor.
|
|
61
|
+
Every consumer treats the value as a destination — Inertia's
|
|
62
|
+
``router.visit("")`` silently reloads the current page, and an empty
|
|
63
|
+
``Location`` header is a broken redirect — so providers normalise on their
|
|
64
|
+
settings class, where hydration and ``apply_changes_and_reload`` both run.
|
|
65
|
+
It lives here rather than in one provider because the providers must not
|
|
66
|
+
import each other (cross-module coupling), and this is the same concern as
|
|
67
|
+
the rest of this module.
|
|
68
|
+
"""
|
|
69
|
+
return value.strip() or default
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
"DEFAULT_FALLBACK",
|
|
74
|
+
"SESSION_NEXT_KEY",
|
|
75
|
+
"non_empty_redirect",
|
|
76
|
+
"safe_next",
|
|
77
|
+
"safe_next_or_none",
|
|
78
|
+
]
|