simple-module-settings 0.0.30__tar.gz → 0.0.32__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_settings-0.0.30 → simple_module_settings-0.0.32}/.gitignore +1 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/PKG-INFO +5 -5
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/pyproject.toml +4 -4
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/_module_settings.py +113 -10
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/constants.py +6 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/endpoints/module_api.py +10 -2
- simple_module_settings-0.0.32/settings/endpoints/views.py +272 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/locales/en.json +13 -2
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/module.py +17 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/pages/Create.tsx +16 -14
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/pages/ModulesEdit.tsx +10 -4
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/pages/components/FieldInput.tsx +6 -0
- simple_module_settings-0.0.32/settings/pages/components/FieldSource.tsx +59 -0
- simple_module_settings-0.0.32/settings/pages/components/KeyField.tsx +104 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/pages/components/ModuleForm.tsx +20 -10
- simple_module_settings-0.0.32/settings/pages/components/TestConnectionButton.tsx +70 -0
- simple_module_settings-0.0.32/settings/pages/routes.ts +10 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/store.py +16 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_module_settings.py +1 -0
- simple_module_settings-0.0.32/tests/test_module_settings_render.py +90 -0
- simple_module_settings-0.0.32/tests/test_module_settings_serialize.py +92 -0
- simple_module_settings-0.0.32/tests/test_settings_field_sources.py +122 -0
- simple_module_settings-0.0.32/tests/test_settings_view_authz.py +61 -0
- simple_module_settings-0.0.30/settings/endpoints/views.py +0 -124
- simple_module_settings-0.0.30/settings/pages/routes.ts +0 -7
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/LICENSE +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/README.md +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/package.json +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/__init__.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/cli.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/contracts/__init__.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/contracts/accessor.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/contracts/events.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/contracts/registry.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/contracts/schemas.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/deps.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/endpoints/__init__.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/endpoints/api.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/env_vars.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/hydrate.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/models.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/module_registry.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/pages/Browse.tsx +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/pages/Edit.tsx +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/pages/components/ValueInput.tsx +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/py.typed +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/registration.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/reload.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/service.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/services.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/settings.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_cli_import.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_concurrent_overrides.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_hydrate.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_module_api.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_module_registry.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_registration.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_reload.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_settings_accessor.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_settings_api.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_settings_events.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_settings_module.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_settings_schemas.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_settings_service.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_store.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tsconfig.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_settings
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.32
|
|
4
4
|
Summary: Runtime settings UI — modules plug their own settings panels into a shared admin view
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -21,9 +21,9 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
21
21
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
|
-
Requires-Dist: simple-module-core==0.0.
|
|
25
|
-
Requires-Dist: simple-module-db==0.0.
|
|
26
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
24
|
+
Requires-Dist: simple-module-core==0.0.32
|
|
25
|
+
Requires-Dist: simple-module-db==0.0.32
|
|
26
|
+
Requires-Dist: simple-module-hosting==0.0.32
|
|
27
27
|
Requires-Dist: typer>=0.12
|
|
28
28
|
Description-Content-Type: text/markdown
|
|
29
29
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_settings"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.32"
|
|
4
4
|
description = "Runtime settings UI — modules plug their own settings panels into a shared admin view"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -21,9 +21,9 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"simple_module_core==0.0.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
24
|
+
"simple_module_core==0.0.32",
|
|
25
|
+
"simple_module_db==0.0.32",
|
|
26
|
+
"simple_module_hosting==0.0.32",
|
|
27
27
|
"typer>=0.12",
|
|
28
28
|
]
|
|
29
29
|
|
{simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/_module_settings.py
RENAMED
|
@@ -6,15 +6,18 @@ Each module stores a services dataclass on ``app.state.<package>`` whose
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import os
|
|
9
10
|
import re
|
|
10
11
|
from dataclasses import dataclass
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
13
14
|
from fastapi import FastAPI
|
|
15
|
+
from fastapi.encoders import jsonable_encoder
|
|
14
16
|
from pydantic_settings import BaseSettings
|
|
15
17
|
|
|
16
18
|
from settings.env_vars import env_prefix_for
|
|
17
19
|
from settings.hydrate import value_type_for_field
|
|
20
|
+
from settings.service import SettingService
|
|
18
21
|
|
|
19
22
|
# We intentionally DON'T match the bare word "token" (would mask
|
|
20
23
|
# `verification_token_lifetime_seconds` — just an int) or "key" alone (would
|
|
@@ -41,6 +44,37 @@ class ModuleSettingField:
|
|
|
41
44
|
type: str
|
|
42
45
|
requires_restart: bool
|
|
43
46
|
group: str | None
|
|
47
|
+
env_set: bool = False
|
|
48
|
+
"""This field is genuinely env-readable *and* its env var is set.
|
|
49
|
+
|
|
50
|
+
Deliberately not "the ``SM_*`` label below is present in ``os.environ``".
|
|
51
|
+
The bundled module settings classes declare no ``env_prefix`` — they are
|
|
52
|
+
constructed from pydantic defaults and hydrated from the DB — so their
|
|
53
|
+
``SM_*`` vars are never consulted. Reporting a leftover
|
|
54
|
+
``SM_USERS_SMTP_HOST`` as the live source would invert the very question
|
|
55
|
+
this screen answers. Classes that *do* declare one (the host's ``Settings``
|
|
56
|
+
with ``SM_``, and anything from the module scaffold) report ``env`` for
|
|
57
|
+
real, because for them pydantic really does read it.
|
|
58
|
+
"""
|
|
59
|
+
db_override: bool = False
|
|
60
|
+
"""A stored setting overrides this field."""
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def source(self) -> str:
|
|
64
|
+
"""Where the live value came from: ``db``, ``env`` or ``default``.
|
|
65
|
+
|
|
66
|
+
Mirrors the precedence in ``hydrate_settings``: DB overrides are passed
|
|
67
|
+
to the constructor explicitly, so they beat anything pydantic reads for
|
|
68
|
+
fields left unset, which in turn beats the field default. ``env`` only
|
|
69
|
+
appears for settings classes that actually declare an ``env_prefix``;
|
|
70
|
+
see :attr:`env_set`. Showing this is the difference between "why is
|
|
71
|
+
this not taking effect" being a five-minute question and an afternoon.
|
|
72
|
+
"""
|
|
73
|
+
if self.db_override:
|
|
74
|
+
return "db"
|
|
75
|
+
if self.env_set:
|
|
76
|
+
return "env"
|
|
77
|
+
return "default"
|
|
44
78
|
|
|
45
79
|
|
|
46
80
|
@dataclass(frozen=True, slots=True)
|
|
@@ -93,16 +127,43 @@ def _resolve_default(info) -> Any:
|
|
|
93
127
|
return None
|
|
94
128
|
|
|
95
129
|
|
|
96
|
-
def
|
|
130
|
+
def _env_readable_var(settings: BaseSettings, name: str) -> str | None:
|
|
131
|
+
"""Env var pydantic would actually read for ``name``, or ``None``.
|
|
132
|
+
|
|
133
|
+
``env_var`` on the view is a *label* — the ``SM_<PACKAGE>_<FIELD>`` name the
|
|
134
|
+
``smpy settings import-from-env`` CLI looks for, kept from before settings
|
|
135
|
+
moved into the DB. It is not evidence that pydantic reads it: the bundled
|
|
136
|
+
module settings classes declare ``SettingsConfigDict(extra="ignore")`` with
|
|
137
|
+
no ``env_prefix``, so ``SM_FILE_STORAGE_BACKEND`` has no effect on
|
|
138
|
+
``FileStorageSettings()``. Deriving env-readability from the class's own
|
|
139
|
+
``env_prefix`` keeps the "From environment" badge honest, and works as-is
|
|
140
|
+
for the classes that do declare one — the host's ``Settings`` (``SM_``) and
|
|
141
|
+
every module built from the scaffold, whose template ships
|
|
142
|
+
``env_prefix="SM_<PACKAGE>_"``.
|
|
143
|
+
"""
|
|
144
|
+
env_prefix = str(type(settings).model_config.get("env_prefix") or "")
|
|
145
|
+
if not env_prefix:
|
|
146
|
+
return None
|
|
147
|
+
return f"{env_prefix}{name.upper()}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _field_view(
|
|
151
|
+
name: str,
|
|
152
|
+
settings: BaseSettings,
|
|
153
|
+
prefix: str,
|
|
154
|
+
overridden: frozenset[str] = frozenset(),
|
|
155
|
+
) -> ModuleSettingField:
|
|
97
156
|
cls = type(settings)
|
|
98
157
|
info = cls.model_fields[name]
|
|
99
158
|
raw_value = getattr(settings, name)
|
|
100
159
|
secret = is_secret_field(name)
|
|
101
160
|
extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
|
|
102
161
|
default = _resolve_default(info)
|
|
162
|
+
env_var = f"{prefix}{name.upper()}"
|
|
163
|
+
live_env_var = _env_readable_var(settings, name)
|
|
103
164
|
return ModuleSettingField(
|
|
104
165
|
name=name,
|
|
105
|
-
env_var=
|
|
166
|
+
env_var=env_var,
|
|
106
167
|
value=_mask(raw_value) if secret else raw_value,
|
|
107
168
|
default=_mask(default) if secret else default,
|
|
108
169
|
description=info.description or "",
|
|
@@ -110,16 +171,26 @@ def _field_view(name: str, settings: BaseSettings, prefix: str) -> ModuleSetting
|
|
|
110
171
|
type=value_type_for_field(cls, name),
|
|
111
172
|
requires_restart=bool(extra.get("requires_restart", False)),
|
|
112
173
|
group=extra.get("group"),
|
|
174
|
+
env_set=live_env_var is not None and live_env_var in os.environ,
|
|
175
|
+
db_override=name in overridden,
|
|
113
176
|
)
|
|
114
177
|
|
|
115
178
|
|
|
116
|
-
def collect_module_settings(
|
|
179
|
+
def collect_module_settings(
|
|
180
|
+
app: FastAPI,
|
|
181
|
+
overrides: dict[str, frozenset[str]] | None = None,
|
|
182
|
+
) -> list[ModuleSettingsView]:
|
|
117
183
|
"""Return a sorted, serializable view of every module's BaseSettings.
|
|
118
184
|
|
|
119
185
|
Folds in both ``app.state.sm.modules`` (plugin modules) and additional
|
|
120
186
|
packages registered via ``app.state.settings.module_registry`` (e.g.
|
|
121
187
|
``"host"``) that aren't backed by a ``ModuleBase`` instance.
|
|
188
|
+
|
|
189
|
+
``overrides`` maps package -> field names carrying a stored override. It
|
|
190
|
+
is passed in rather than read here because fetching it is async and this
|
|
191
|
+
function is not; callers without it get ``db_override=False`` throughout.
|
|
122
192
|
"""
|
|
193
|
+
by_package = overrides or {}
|
|
123
194
|
views: list[ModuleSettingsView] = []
|
|
124
195
|
seen: set[str] = set()
|
|
125
196
|
|
|
@@ -128,7 +199,7 @@ def collect_module_settings(app: FastAPI) -> list[ModuleSettingsView]:
|
|
|
128
199
|
settings = _extract_settings(app, package)
|
|
129
200
|
if settings is None:
|
|
130
201
|
continue
|
|
131
|
-
views.append(_build_view(mod.meta.name, package, settings))
|
|
202
|
+
views.append(_build_view(mod.meta.name, package, settings, by_package))
|
|
132
203
|
seen.add(package)
|
|
133
204
|
|
|
134
205
|
settings_services = getattr(app.state, "settings", None)
|
|
@@ -140,16 +211,24 @@ def collect_module_settings(app: FastAPI) -> list[ModuleSettingsView]:
|
|
|
140
211
|
settings = _extract_settings(app, package)
|
|
141
212
|
if settings is None:
|
|
142
213
|
continue
|
|
143
|
-
views.append(_build_view(package.title(), package, settings))
|
|
214
|
+
views.append(_build_view(package.title(), package, settings, by_package))
|
|
144
215
|
seen.add(package)
|
|
145
216
|
|
|
146
217
|
views.sort(key=lambda v: v.module_name)
|
|
147
218
|
return views
|
|
148
219
|
|
|
149
220
|
|
|
150
|
-
def _build_view(
|
|
221
|
+
def _build_view(
|
|
222
|
+
module_name: str,
|
|
223
|
+
package: str,
|
|
224
|
+
settings: BaseSettings,
|
|
225
|
+
overrides: dict[str, frozenset[str]] | None = None,
|
|
226
|
+
) -> ModuleSettingsView:
|
|
151
227
|
prefix = env_prefix_for(package)
|
|
152
|
-
|
|
228
|
+
overridden = (overrides or {}).get(package, frozenset())
|
|
229
|
+
fields = [
|
|
230
|
+
_field_view(name, settings, prefix, overridden) for name in type(settings).model_fields
|
|
231
|
+
]
|
|
153
232
|
return ModuleSettingsView(
|
|
154
233
|
module_name=module_name,
|
|
155
234
|
package=package,
|
|
@@ -159,8 +238,29 @@ def _build_view(module_name: str, package: str, settings: BaseSettings) -> Modul
|
|
|
159
238
|
)
|
|
160
239
|
|
|
161
240
|
|
|
241
|
+
async def overrides_by_package(service: SettingService) -> dict[str, frozenset[str]]:
|
|
242
|
+
"""Map package -> field names carrying a stored override.
|
|
243
|
+
|
|
244
|
+
Reads the SYSTEM scope once and buckets by key prefix. Packages with no
|
|
245
|
+
overrides are simply absent, which :func:`collect_module_settings` already
|
|
246
|
+
treats as "nothing overridden". Shared by both the Inertia view and the
|
|
247
|
+
JSON API so the two never drift on what "overridden" means.
|
|
248
|
+
"""
|
|
249
|
+
from settings.store import SettingsStore
|
|
250
|
+
|
|
251
|
+
return await SettingsStore(service).all_override_fields()
|
|
252
|
+
|
|
253
|
+
|
|
162
254
|
def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
|
|
163
|
-
"""Convert dataclass views to plain dicts for Inertia props.
|
|
255
|
+
"""Convert dataclass views to plain dicts for Inertia props.
|
|
256
|
+
|
|
257
|
+
Field values arrive as whatever type the module declared — pydantic has
|
|
258
|
+
already coerced ``media_root: Path`` to a ``PosixPath``, ``timeout:
|
|
259
|
+
timedelta`` to a ``timedelta`` — and this screen reflects every installed
|
|
260
|
+
module's settings, so the set of types is open-ended by design. They are
|
|
261
|
+
encoded here rather than handed on as-is: this is the boundary where a
|
|
262
|
+
settings object stops being Python and becomes a prop.
|
|
263
|
+
"""
|
|
164
264
|
return [
|
|
165
265
|
{
|
|
166
266
|
"module_name": v.module_name,
|
|
@@ -171,13 +271,16 @@ def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
|
|
|
171
271
|
{
|
|
172
272
|
"name": f.name,
|
|
173
273
|
"env_var": f.env_var,
|
|
174
|
-
"value": f.value,
|
|
175
|
-
"default": f.default,
|
|
274
|
+
"value": jsonable_encoder(f.value),
|
|
275
|
+
"default": jsonable_encoder(f.default),
|
|
176
276
|
"description": f.description,
|
|
177
277
|
"is_secret": f.is_secret,
|
|
178
278
|
"type": f.type,
|
|
179
279
|
"requires_restart": f.requires_restart,
|
|
180
280
|
"group": f.group,
|
|
281
|
+
"env_set": f.env_set,
|
|
282
|
+
"db_override": f.db_override,
|
|
283
|
+
"source": f.source,
|
|
181
284
|
}
|
|
182
285
|
for f in v.fields
|
|
183
286
|
],
|
|
@@ -48,6 +48,12 @@ VIEW_PREFIX: Final = "/settings"
|
|
|
48
48
|
VIEW_CREATE_PATH: Final = "/create"
|
|
49
49
|
VIEW_EDIT_PATH: Final = "/{setting_id}/edit"
|
|
50
50
|
VIEW_MODULES_PATH: Final = "/modules"
|
|
51
|
+
"""Legacy path for the per-module forms. Those now live at the section root;
|
|
52
|
+
this redirects, so existing links and bookmarks keep working."""
|
|
53
|
+
|
|
54
|
+
VIEW_STORE_PATH: Final = "/store"
|
|
55
|
+
"""Raw key/value store. Demoted from the section root: it is a database view,
|
|
56
|
+
and an admin looking for "settings" almost always wants the module forms."""
|
|
51
57
|
API_BY_ID_PATH: Final = "/{setting_id}"
|
|
52
58
|
API_BY_KEY_PATH: Final = "/by-key/{key}"
|
|
53
59
|
API_RESOLVE_PATH: Final = "/resolve/{key}"
|
{simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/endpoints/module_api.py
RENAMED
|
@@ -17,6 +17,7 @@ from settings._module_settings import (
|
|
|
17
17
|
SECRET_MASK,
|
|
18
18
|
collect_module_settings,
|
|
19
19
|
is_secret_field,
|
|
20
|
+
overrides_by_package,
|
|
20
21
|
serialize,
|
|
21
22
|
)
|
|
22
23
|
from settings.constants import MODULE_PACKAGE, PERM_DELETE, PERM_EDIT, PERM_VIEW
|
|
@@ -47,8 +48,15 @@ def _strip_mask_sentinels(changes: dict[str, Any]) -> dict[str, Any]:
|
|
|
47
48
|
|
|
48
49
|
|
|
49
50
|
@router.get("", dependencies=_VIEW)
|
|
50
|
-
async def list_modules(
|
|
51
|
-
|
|
51
|
+
async def list_modules(
|
|
52
|
+
request: Request,
|
|
53
|
+
service: SettingService = Depends(get_setting_service),
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
# Match the Inertia ModulesEdit view: without the overrides map, every
|
|
56
|
+
# field's `source`/`db_override` would report env/default even when a
|
|
57
|
+
# stored override is actually in force.
|
|
58
|
+
overrides = await overrides_by_package(service)
|
|
59
|
+
views = collect_module_settings(request.app, overrides)
|
|
52
60
|
return {"modules": serialize(views)}
|
|
53
61
|
|
|
54
62
|
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Inertia view endpoints for the Settings module.
|
|
2
|
+
|
|
3
|
+
Page component identifiers are file-private module constants — same pattern
|
|
4
|
+
the dashboard/users/feature_flags views use. The diagnostic ``SM003``
|
|
5
|
+
matches the resolved literal value, not the source-level expression, so a
|
|
6
|
+
named constant is fine; ``test_settings_module.py`` cross-checks that the
|
|
7
|
+
strings below match what ``pages/*.tsx`` declare.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
15
|
+
from inertia import InertiaResponse
|
|
16
|
+
from pydantic import ValidationError
|
|
17
|
+
from simple_module_hosting.inertia_deps import InertiaDep
|
|
18
|
+
from simple_module_hosting.inertia_utils import redirect_back_with_errors, validation_errors_to_dict
|
|
19
|
+
from simple_module_hosting.permissions import RequiresPermission
|
|
20
|
+
from starlette.responses import RedirectResponse
|
|
21
|
+
|
|
22
|
+
from settings._module_settings import (
|
|
23
|
+
_package_of,
|
|
24
|
+
collect_module_settings,
|
|
25
|
+
overrides_by_package,
|
|
26
|
+
serialize,
|
|
27
|
+
)
|
|
28
|
+
from settings.constants import (
|
|
29
|
+
ERR_SETTING_NOT_FOUND,
|
|
30
|
+
PERM_CREATE,
|
|
31
|
+
PERM_DELETE,
|
|
32
|
+
PERM_EDIT,
|
|
33
|
+
PERM_VIEW,
|
|
34
|
+
PROP_ERROR,
|
|
35
|
+
PROP_MODULES,
|
|
36
|
+
PROP_SETTING,
|
|
37
|
+
PROP_SETTINGS,
|
|
38
|
+
VIEW_CREATE_PATH,
|
|
39
|
+
VIEW_EDIT_PATH,
|
|
40
|
+
VIEW_MODULES_PATH,
|
|
41
|
+
VIEW_STORE_PATH,
|
|
42
|
+
)
|
|
43
|
+
from settings.contracts.schemas import SettingCreate, SettingUpdate
|
|
44
|
+
from settings.deps import get_setting_service
|
|
45
|
+
from settings.service import SettingService
|
|
46
|
+
|
|
47
|
+
_PAGE_BROWSE = "Settings/Browse"
|
|
48
|
+
_PAGE_CREATE = "Settings/Create"
|
|
49
|
+
_PAGE_EDIT = "Settings/Edit"
|
|
50
|
+
_PAGE_MODULES_EDIT = "Settings/ModulesEdit"
|
|
51
|
+
|
|
52
|
+
# Row-level actions return to the raw store they were performed in, not to
|
|
53
|
+
# the module forms that now own the section root.
|
|
54
|
+
_REDIRECT_SETTINGS = "/settings/store"
|
|
55
|
+
_REDIRECT_MODULES = "/settings/"
|
|
56
|
+
|
|
57
|
+
# Every screen in this section reads configuration: module field values,
|
|
58
|
+
# their env var names, and now which of the two is in force. The matching JSON
|
|
59
|
+
# API (``/api/settings/...``) has always required ``settings.view``, so leaving
|
|
60
|
+
# these unguarded let any signed-in account read the same data by asking for
|
|
61
|
+
# the page instead. Mutating routes add their own stricter guard on top.
|
|
62
|
+
router = APIRouter(dependencies=[Depends(RequiresPermission(PERM_VIEW))])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@router.get(VIEW_STORE_PATH, response_model=None)
|
|
66
|
+
async def browse(
|
|
67
|
+
inertia: InertiaDep,
|
|
68
|
+
service: SettingService = Depends(get_setting_service),
|
|
69
|
+
) -> InertiaResponse:
|
|
70
|
+
"""The raw key/value store.
|
|
71
|
+
|
|
72
|
+
Moved off the section root: it is a database view, and an admin who clicks
|
|
73
|
+
"Settings" is nearly always after a module's form, not a table of rows
|
|
74
|
+
keyed by dotted strings.
|
|
75
|
+
"""
|
|
76
|
+
items = await service.list_all()
|
|
77
|
+
return await inertia.render(
|
|
78
|
+
_PAGE_BROWSE,
|
|
79
|
+
{PROP_SETTINGS: [item.model_dump(mode="json") for item in items]},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.get(VIEW_MODULES_PATH, response_model=None)
|
|
84
|
+
async def modules_redirect() -> RedirectResponse:
|
|
85
|
+
"""The per-module forms moved to the section root; keep old links alive."""
|
|
86
|
+
return RedirectResponse(_REDIRECT_MODULES, status_code=308)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.get(VIEW_CREATE_PATH, response_model=None)
|
|
90
|
+
async def create_view(request: Request, inertia: InertiaDep) -> InertiaResponse:
|
|
91
|
+
return await inertia.render(_PAGE_CREATE, {"known_keys": _known_keys(request)})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _known_keys(request: Request) -> list[dict[str, str]]:
|
|
95
|
+
"""Every ``<package>.<field>`` a module actually reads, for autocomplete.
|
|
96
|
+
|
|
97
|
+
The key field is free text, and a typo produces a row that looks saved and
|
|
98
|
+
is silently never read — the failure gives no feedback at all. Suggesting
|
|
99
|
+
the registered keys makes the common case unmissable without forbidding
|
|
100
|
+
the uncommon one: keys outside this list stay valid, since a module can
|
|
101
|
+
read settings the settings module cannot enumerate.
|
|
102
|
+
"""
|
|
103
|
+
suggestions: list[dict[str, str]] = []
|
|
104
|
+
for view in collect_module_settings(request.app):
|
|
105
|
+
for field in view.fields:
|
|
106
|
+
suggestions.append(
|
|
107
|
+
{
|
|
108
|
+
"key": f"{view.package}.{field.name}",
|
|
109
|
+
"type": field.type,
|
|
110
|
+
"description": field.description,
|
|
111
|
+
"module": view.module_name,
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
return sorted(suggestions, key=lambda s: s["key"])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@router.get(VIEW_EDIT_PATH, response_model=None)
|
|
118
|
+
async def edit_view(
|
|
119
|
+
setting_id: int,
|
|
120
|
+
inertia: InertiaDep,
|
|
121
|
+
service: SettingService = Depends(get_setting_service),
|
|
122
|
+
) -> InertiaResponse:
|
|
123
|
+
item = await service.get_by_id(setting_id)
|
|
124
|
+
if item is None:
|
|
125
|
+
return await inertia.render(_PAGE_BROWSE, {PROP_ERROR: ERR_SETTING_NOT_FOUND})
|
|
126
|
+
return await inertia.render(_PAGE_EDIT, {PROP_SETTING: item.model_dump(mode="json")})
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ── Form actions (POST/PUT/DELETE → redirect) ─────────────────
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# Posts to the store collection, which is where the rows live now that the
|
|
133
|
+
# section root renders the module forms.
|
|
134
|
+
@router.post(
|
|
135
|
+
VIEW_STORE_PATH,
|
|
136
|
+
response_model=None,
|
|
137
|
+
dependencies=[Depends(RequiresPermission(PERM_CREATE))],
|
|
138
|
+
)
|
|
139
|
+
async def create_action(
|
|
140
|
+
request: Request,
|
|
141
|
+
service: SettingService = Depends(get_setting_service),
|
|
142
|
+
) -> RedirectResponse:
|
|
143
|
+
body = await request.json()
|
|
144
|
+
try:
|
|
145
|
+
data = SettingCreate(**body)
|
|
146
|
+
except ValidationError as exc:
|
|
147
|
+
return redirect_back_with_errors(request, validation_errors_to_dict(exc))
|
|
148
|
+
await service.create(data)
|
|
149
|
+
return RedirectResponse(_REDIRECT_SETTINGS, status_code=303)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@router.put(
|
|
153
|
+
"/{setting_id}",
|
|
154
|
+
response_model=None,
|
|
155
|
+
dependencies=[Depends(RequiresPermission(PERM_EDIT))],
|
|
156
|
+
)
|
|
157
|
+
async def update_action(
|
|
158
|
+
setting_id: int,
|
|
159
|
+
request: Request,
|
|
160
|
+
service: SettingService = Depends(get_setting_service),
|
|
161
|
+
) -> RedirectResponse:
|
|
162
|
+
body = await request.json()
|
|
163
|
+
try:
|
|
164
|
+
data = SettingUpdate(**body)
|
|
165
|
+
except ValidationError as exc:
|
|
166
|
+
return redirect_back_with_errors(request, validation_errors_to_dict(exc))
|
|
167
|
+
await service.update(setting_id, data)
|
|
168
|
+
return RedirectResponse(_REDIRECT_SETTINGS, status_code=303)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@router.delete(
|
|
172
|
+
"/{setting_id}",
|
|
173
|
+
response_model=None,
|
|
174
|
+
dependencies=[Depends(RequiresPermission(PERM_DELETE))],
|
|
175
|
+
)
|
|
176
|
+
async def delete_action(
|
|
177
|
+
setting_id: int,
|
|
178
|
+
service: SettingService = Depends(get_setting_service),
|
|
179
|
+
) -> RedirectResponse:
|
|
180
|
+
await service.delete(setting_id)
|
|
181
|
+
return RedirectResponse(_REDIRECT_SETTINGS, status_code=303)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@router.get("/", response_model=None)
|
|
185
|
+
async def modules_view(
|
|
186
|
+
request: Request,
|
|
187
|
+
inertia: InertiaDep,
|
|
188
|
+
service: SettingService = Depends(get_setting_service),
|
|
189
|
+
) -> InertiaResponse:
|
|
190
|
+
"""Read-only view of every module's pydantic ``BaseSettings`` instance.
|
|
191
|
+
|
|
192
|
+
Auto-discovered from ``app.state.sm.modules``; secrets are masked server-side.
|
|
193
|
+
Each field also reports where its live value came from — a stored override,
|
|
194
|
+
an ``SM_*`` env var, or the field default — so a setting that "isn't taking
|
|
195
|
+
effect" explains itself.
|
|
196
|
+
"""
|
|
197
|
+
overrides = await overrides_by_package(service)
|
|
198
|
+
views = collect_module_settings(request.app, overrides)
|
|
199
|
+
return await inertia.render(
|
|
200
|
+
_PAGE_MODULES_EDIT,
|
|
201
|
+
{
|
|
202
|
+
PROP_MODULES: serialize(views),
|
|
203
|
+
# Which packages can be connection-tested, so the page only offers
|
|
204
|
+
# the button where something is actually reachable.
|
|
205
|
+
"testable": _testable_packages(request),
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _testable_packages(request: Request) -> list[str]:
|
|
211
|
+
"""Packages whose module registered at least one health check.
|
|
212
|
+
|
|
213
|
+
"Test connection" is just that module's health checks run on demand —
|
|
214
|
+
reusing the registry means settings never learns what an SMTP or an S3
|
|
215
|
+
connection is.
|
|
216
|
+
"""
|
|
217
|
+
registry = request.app.state.sm.health_registry
|
|
218
|
+
owners = {c.module for c in registry.all_checks if c.module}
|
|
219
|
+
return sorted(
|
|
220
|
+
{
|
|
221
|
+
_package_of(mod)
|
|
222
|
+
for mod in getattr(request.app.state.sm, "modules", ())
|
|
223
|
+
if mod.meta.name in owners
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@router.post(
|
|
229
|
+
"/test-connection/{package}",
|
|
230
|
+
response_model=None,
|
|
231
|
+
# Guarded, unlike the read-only view routes around it: this one makes the
|
|
232
|
+
# server open outbound connections on demand (SMTP AUTH, S3) and hands the
|
|
233
|
+
# raw failure text — hostnames, bucket names, auth errors — back to the
|
|
234
|
+
# caller. Only someone allowed to change these settings should be able to.
|
|
235
|
+
dependencies=[Depends(RequiresPermission(PERM_EDIT))],
|
|
236
|
+
)
|
|
237
|
+
async def test_connection(package: str, request: Request) -> dict:
|
|
238
|
+
"""Run one module's health checks now and report each result.
|
|
239
|
+
|
|
240
|
+
Returns 200 with per-check results even when a check fails: an admin
|
|
241
|
+
testing a connection expects to read the failure, not to get an error
|
|
242
|
+
status with the reason buried.
|
|
243
|
+
"""
|
|
244
|
+
modules = getattr(request.app.state.sm, "modules", ())
|
|
245
|
+
owner = next((m for m in modules if _package_of(m) == package), None)
|
|
246
|
+
if owner is None:
|
|
247
|
+
raise HTTPException(status_code=404, detail=f"Unknown module package: {package}")
|
|
248
|
+
|
|
249
|
+
checks = [
|
|
250
|
+
c for c in request.app.state.sm.health_registry.all_checks if c.module == owner.meta.name
|
|
251
|
+
]
|
|
252
|
+
if not checks:
|
|
253
|
+
raise HTTPException(status_code=404, detail=f"{owner.meta.name} has no connection to test")
|
|
254
|
+
|
|
255
|
+
# Run independently of one another — a module can register several checks
|
|
256
|
+
# (e.g. SMTP + primary storage + backup storage), and sequentially awaiting
|
|
257
|
+
# each one would block the request for the sum of their latencies instead
|
|
258
|
+
# of the slowest one, the same reasoning dashboard.stats._run_health_checks
|
|
259
|
+
# already applies to probe checks.
|
|
260
|
+
async def _run_one(check) -> dict:
|
|
261
|
+
try:
|
|
262
|
+
outcome = await check.check()
|
|
263
|
+
return {
|
|
264
|
+
"name": check.name,
|
|
265
|
+
"status": outcome.status.value,
|
|
266
|
+
"detail": outcome.detail or "",
|
|
267
|
+
}
|
|
268
|
+
except Exception as exc:
|
|
269
|
+
return {"name": check.name, "status": "unhealthy", "detail": str(exc)}
|
|
270
|
+
|
|
271
|
+
results = await asyncio.gather(*[_run_one(c) for c in checks])
|
|
272
|
+
return {"module": owner.meta.name, "checks": results}
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"value_label": "Value",
|
|
40
40
|
"value_placeholder": "Enter a value",
|
|
41
41
|
"description_label": "Description",
|
|
42
|
-
"description_placeholder": "Optional description"
|
|
42
|
+
"description_placeholder": "Optional description",
|
|
43
|
+
"key_unknown_warning": "No installed module declares this key. It will be stored, but nothing will read it unless a module looks it up."
|
|
43
44
|
},
|
|
44
45
|
"create": {
|
|
45
46
|
"title": "New Setting",
|
|
@@ -66,6 +67,16 @@
|
|
|
66
67
|
"value": "Value",
|
|
67
68
|
"default": "Default",
|
|
68
69
|
"description": "Description"
|
|
69
|
-
}
|
|
70
|
+
},
|
|
71
|
+
"test_connection": "Test connection",
|
|
72
|
+
"testing": "Testing…",
|
|
73
|
+
"env_var_hint": "Environment variable name for this field, used by \"smpy settings import-from-env\"",
|
|
74
|
+
"source_db": "Stored override",
|
|
75
|
+
"source_db_hint": "A stored setting supplies this value",
|
|
76
|
+
"source_db_over_env": "Stored (shadows env)",
|
|
77
|
+
"source_db_shadows_env": "A stored setting overrides {env_var}; the environment value is ignored",
|
|
78
|
+
"source_env": "From environment",
|
|
79
|
+
"source_env_hint": "{env_var} is set in this deployment",
|
|
80
|
+
"source_default": "Default"
|
|
70
81
|
}
|
|
71
82
|
}
|
|
@@ -6,6 +6,7 @@ import importlib.resources
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
from fastapi import APIRouter, FastAPI
|
|
9
|
+
from simple_module_core.audit_links import AuditLink, AuditLinkRegistry
|
|
9
10
|
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
|
|
10
11
|
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
11
12
|
from simple_module_core.permissions import PermissionRegistry
|
|
@@ -21,6 +22,7 @@ from settings.constants import (
|
|
|
21
22
|
MODULE_NAME,
|
|
22
23
|
MODULE_PACKAGE,
|
|
23
24
|
PERM_GROUP,
|
|
25
|
+
PERM_VIEW,
|
|
24
26
|
VIEW_PREFIX,
|
|
25
27
|
)
|
|
26
28
|
|
|
@@ -67,12 +69,27 @@ class SettingsModule(ModuleBase):
|
|
|
67
69
|
order=MENU_ORDER,
|
|
68
70
|
section=MenuSection.SIDEBAR,
|
|
69
71
|
group="System",
|
|
72
|
+
# Mirrors the view router's guard, so the entry is not offered
|
|
73
|
+
# to accounts whose click would 403.
|
|
74
|
+
permissions=[PERM_VIEW],
|
|
70
75
|
)
|
|
71
76
|
)
|
|
72
77
|
|
|
73
78
|
def register_permissions(self, registry: PermissionRegistry) -> None:
|
|
74
79
|
registry.add_group(PERM_GROUP, list(ALL_PERMISSIONS))
|
|
75
80
|
|
|
81
|
+
def register_audit_links(self, registry: AuditLinkRegistry) -> None:
|
|
82
|
+
from settings.models import Setting
|
|
83
|
+
|
|
84
|
+
registry.register(
|
|
85
|
+
AuditLink(
|
|
86
|
+
# Class name, not __tablename__ — see AuditLink.entity_type.
|
|
87
|
+
entity_type=Setting.__name__,
|
|
88
|
+
url_template=f"{VIEW_PREFIX}/{{id}}/edit",
|
|
89
|
+
label="Setting",
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
76
93
|
def locale_dirs(self) -> dict[str, Path]:
|
|
77
94
|
base = Path(str(importlib.resources.files(__package__) / "locales"))
|
|
78
95
|
return {LOCALE_NAMESPACE: base}
|