simple-module-settings 0.0.30__tar.gz → 0.0.31__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.31}/.gitignore +1 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/PKG-INFO +5 -5
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/pyproject.toml +4 -4
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/_module_settings.py +87 -7
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/constants.py +6 -0
- simple_module_settings-0.0.31/settings/endpoints/views.py +269 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/locales/en.json +13 -2
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/module.py +17 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/pages/Create.tsx +16 -14
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/pages/ModulesEdit.tsx +7 -3
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/pages/components/FieldInput.tsx +6 -0
- simple_module_settings-0.0.31/settings/pages/components/FieldSource.tsx +59 -0
- simple_module_settings-0.0.31/settings/pages/components/KeyField.tsx +104 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/pages/components/ModuleForm.tsx +20 -10
- simple_module_settings-0.0.31/settings/pages/components/TestConnectionButton.tsx +70 -0
- simple_module_settings-0.0.31/settings/pages/routes.ts +10 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/store.py +16 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_module_settings.py +1 -0
- simple_module_settings-0.0.31/tests/test_settings_field_sources.py +122 -0
- simple_module_settings-0.0.31/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.31}/LICENSE +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/README.md +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/package.json +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/__init__.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/cli.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/contracts/__init__.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/contracts/accessor.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/contracts/events.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/contracts/registry.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/contracts/schemas.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/deps.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/endpoints/__init__.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/endpoints/api.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/endpoints/module_api.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/env_vars.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/hydrate.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/models.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/module_registry.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/pages/Browse.tsx +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/pages/Edit.tsx +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/pages/components/ValueInput.tsx +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/py.typed +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/registration.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/reload.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/service.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/services.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/settings.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_cli_import.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_concurrent_overrides.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_hydrate.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_module_api.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_module_registry.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_registration.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_reload.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_settings_accessor.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_settings_api.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_settings_events.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_settings_module.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_settings_schemas.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_settings_service.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_store.py +0 -0
- {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/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.31
|
|
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.31
|
|
25
|
+
Requires-Dist: simple-module-db==0.0.31
|
|
26
|
+
Requires-Dist: simple-module-hosting==0.0.31
|
|
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.31"
|
|
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.31",
|
|
25
|
+
"simple_module_db==0.0.31",
|
|
26
|
+
"simple_module_hosting==0.0.31",
|
|
27
27
|
"typer>=0.12",
|
|
28
28
|
]
|
|
29
29
|
|
{simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/_module_settings.py
RENAMED
|
@@ -6,6 +6,7 @@ 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
|
|
@@ -41,6 +42,37 @@ class ModuleSettingField:
|
|
|
41
42
|
type: str
|
|
42
43
|
requires_restart: bool
|
|
43
44
|
group: str | None
|
|
45
|
+
env_set: bool = False
|
|
46
|
+
"""This field is genuinely env-readable *and* its env var is set.
|
|
47
|
+
|
|
48
|
+
Deliberately not "the ``SM_*`` label below is present in ``os.environ``".
|
|
49
|
+
The bundled module settings classes declare no ``env_prefix`` — they are
|
|
50
|
+
constructed from pydantic defaults and hydrated from the DB — so their
|
|
51
|
+
``SM_*`` vars are never consulted. Reporting a leftover
|
|
52
|
+
``SM_USERS_SMTP_HOST`` as the live source would invert the very question
|
|
53
|
+
this screen answers. Classes that *do* declare one (the host's ``Settings``
|
|
54
|
+
with ``SM_``, and anything from the module scaffold) report ``env`` for
|
|
55
|
+
real, because for them pydantic really does read it.
|
|
56
|
+
"""
|
|
57
|
+
db_override: bool = False
|
|
58
|
+
"""A stored setting overrides this field."""
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def source(self) -> str:
|
|
62
|
+
"""Where the live value came from: ``db``, ``env`` or ``default``.
|
|
63
|
+
|
|
64
|
+
Mirrors the precedence in ``hydrate_settings``: DB overrides are passed
|
|
65
|
+
to the constructor explicitly, so they beat anything pydantic reads for
|
|
66
|
+
fields left unset, which in turn beats the field default. ``env`` only
|
|
67
|
+
appears for settings classes that actually declare an ``env_prefix``;
|
|
68
|
+
see :attr:`env_set`. Showing this is the difference between "why is
|
|
69
|
+
this not taking effect" being a five-minute question and an afternoon.
|
|
70
|
+
"""
|
|
71
|
+
if self.db_override:
|
|
72
|
+
return "db"
|
|
73
|
+
if self.env_set:
|
|
74
|
+
return "env"
|
|
75
|
+
return "default"
|
|
44
76
|
|
|
45
77
|
|
|
46
78
|
@dataclass(frozen=True, slots=True)
|
|
@@ -93,16 +125,43 @@ def _resolve_default(info) -> Any:
|
|
|
93
125
|
return None
|
|
94
126
|
|
|
95
127
|
|
|
96
|
-
def
|
|
128
|
+
def _env_readable_var(settings: BaseSettings, name: str) -> str | None:
|
|
129
|
+
"""Env var pydantic would actually read for ``name``, or ``None``.
|
|
130
|
+
|
|
131
|
+
``env_var`` on the view is a *label* — the ``SM_<PACKAGE>_<FIELD>`` name the
|
|
132
|
+
``smpy settings import-from-env`` CLI looks for, kept from before settings
|
|
133
|
+
moved into the DB. It is not evidence that pydantic reads it: the bundled
|
|
134
|
+
module settings classes declare ``SettingsConfigDict(extra="ignore")`` with
|
|
135
|
+
no ``env_prefix``, so ``SM_FILE_STORAGE_BACKEND`` has no effect on
|
|
136
|
+
``FileStorageSettings()``. Deriving env-readability from the class's own
|
|
137
|
+
``env_prefix`` keeps the "From environment" badge honest, and works as-is
|
|
138
|
+
for the classes that do declare one — the host's ``Settings`` (``SM_``) and
|
|
139
|
+
every module built from the scaffold, whose template ships
|
|
140
|
+
``env_prefix="SM_<PACKAGE>_"``.
|
|
141
|
+
"""
|
|
142
|
+
env_prefix = str(type(settings).model_config.get("env_prefix") or "")
|
|
143
|
+
if not env_prefix:
|
|
144
|
+
return None
|
|
145
|
+
return f"{env_prefix}{name.upper()}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _field_view(
|
|
149
|
+
name: str,
|
|
150
|
+
settings: BaseSettings,
|
|
151
|
+
prefix: str,
|
|
152
|
+
overridden: frozenset[str] = frozenset(),
|
|
153
|
+
) -> ModuleSettingField:
|
|
97
154
|
cls = type(settings)
|
|
98
155
|
info = cls.model_fields[name]
|
|
99
156
|
raw_value = getattr(settings, name)
|
|
100
157
|
secret = is_secret_field(name)
|
|
101
158
|
extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
|
|
102
159
|
default = _resolve_default(info)
|
|
160
|
+
env_var = f"{prefix}{name.upper()}"
|
|
161
|
+
live_env_var = _env_readable_var(settings, name)
|
|
103
162
|
return ModuleSettingField(
|
|
104
163
|
name=name,
|
|
105
|
-
env_var=
|
|
164
|
+
env_var=env_var,
|
|
106
165
|
value=_mask(raw_value) if secret else raw_value,
|
|
107
166
|
default=_mask(default) if secret else default,
|
|
108
167
|
description=info.description or "",
|
|
@@ -110,16 +169,26 @@ def _field_view(name: str, settings: BaseSettings, prefix: str) -> ModuleSetting
|
|
|
110
169
|
type=value_type_for_field(cls, name),
|
|
111
170
|
requires_restart=bool(extra.get("requires_restart", False)),
|
|
112
171
|
group=extra.get("group"),
|
|
172
|
+
env_set=live_env_var is not None and live_env_var in os.environ,
|
|
173
|
+
db_override=name in overridden,
|
|
113
174
|
)
|
|
114
175
|
|
|
115
176
|
|
|
116
|
-
def collect_module_settings(
|
|
177
|
+
def collect_module_settings(
|
|
178
|
+
app: FastAPI,
|
|
179
|
+
overrides: dict[str, frozenset[str]] | None = None,
|
|
180
|
+
) -> list[ModuleSettingsView]:
|
|
117
181
|
"""Return a sorted, serializable view of every module's BaseSettings.
|
|
118
182
|
|
|
119
183
|
Folds in both ``app.state.sm.modules`` (plugin modules) and additional
|
|
120
184
|
packages registered via ``app.state.settings.module_registry`` (e.g.
|
|
121
185
|
``"host"``) that aren't backed by a ``ModuleBase`` instance.
|
|
186
|
+
|
|
187
|
+
``overrides`` maps package -> field names carrying a stored override. It
|
|
188
|
+
is passed in rather than read here because fetching it is async and this
|
|
189
|
+
function is not; callers without it get ``db_override=False`` throughout.
|
|
122
190
|
"""
|
|
191
|
+
by_package = overrides or {}
|
|
123
192
|
views: list[ModuleSettingsView] = []
|
|
124
193
|
seen: set[str] = set()
|
|
125
194
|
|
|
@@ -128,7 +197,7 @@ def collect_module_settings(app: FastAPI) -> list[ModuleSettingsView]:
|
|
|
128
197
|
settings = _extract_settings(app, package)
|
|
129
198
|
if settings is None:
|
|
130
199
|
continue
|
|
131
|
-
views.append(_build_view(mod.meta.name, package, settings))
|
|
200
|
+
views.append(_build_view(mod.meta.name, package, settings, by_package))
|
|
132
201
|
seen.add(package)
|
|
133
202
|
|
|
134
203
|
settings_services = getattr(app.state, "settings", None)
|
|
@@ -140,16 +209,24 @@ def collect_module_settings(app: FastAPI) -> list[ModuleSettingsView]:
|
|
|
140
209
|
settings = _extract_settings(app, package)
|
|
141
210
|
if settings is None:
|
|
142
211
|
continue
|
|
143
|
-
views.append(_build_view(package.title(), package, settings))
|
|
212
|
+
views.append(_build_view(package.title(), package, settings, by_package))
|
|
144
213
|
seen.add(package)
|
|
145
214
|
|
|
146
215
|
views.sort(key=lambda v: v.module_name)
|
|
147
216
|
return views
|
|
148
217
|
|
|
149
218
|
|
|
150
|
-
def _build_view(
|
|
219
|
+
def _build_view(
|
|
220
|
+
module_name: str,
|
|
221
|
+
package: str,
|
|
222
|
+
settings: BaseSettings,
|
|
223
|
+
overrides: dict[str, frozenset[str]] | None = None,
|
|
224
|
+
) -> ModuleSettingsView:
|
|
151
225
|
prefix = env_prefix_for(package)
|
|
152
|
-
|
|
226
|
+
overridden = (overrides or {}).get(package, frozenset())
|
|
227
|
+
fields = [
|
|
228
|
+
_field_view(name, settings, prefix, overridden) for name in type(settings).model_fields
|
|
229
|
+
]
|
|
153
230
|
return ModuleSettingsView(
|
|
154
231
|
module_name=module_name,
|
|
155
232
|
package=package,
|
|
@@ -178,6 +255,9 @@ def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
|
|
|
178
255
|
"type": f.type,
|
|
179
256
|
"requires_restart": f.requires_restart,
|
|
180
257
|
"group": f.group,
|
|
258
|
+
"env_set": f.env_set,
|
|
259
|
+
"db_override": f.db_override,
|
|
260
|
+
"source": f.source,
|
|
181
261
|
}
|
|
182
262
|
for f in v.fields
|
|
183
263
|
],
|
|
@@ -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}"
|
|
@@ -0,0 +1,269 @@
|
|
|
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
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
13
|
+
from inertia import InertiaResponse
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
from simple_module_hosting.inertia_deps import InertiaDep
|
|
16
|
+
from simple_module_hosting.inertia_utils import redirect_back_with_errors, validation_errors_to_dict
|
|
17
|
+
from simple_module_hosting.permissions import RequiresPermission
|
|
18
|
+
from starlette.responses import RedirectResponse
|
|
19
|
+
|
|
20
|
+
from settings._module_settings import _package_of, collect_module_settings, serialize
|
|
21
|
+
from settings.constants import (
|
|
22
|
+
ERR_SETTING_NOT_FOUND,
|
|
23
|
+
PERM_CREATE,
|
|
24
|
+
PERM_DELETE,
|
|
25
|
+
PERM_EDIT,
|
|
26
|
+
PERM_VIEW,
|
|
27
|
+
PROP_ERROR,
|
|
28
|
+
PROP_MODULES,
|
|
29
|
+
PROP_SETTING,
|
|
30
|
+
PROP_SETTINGS,
|
|
31
|
+
VIEW_CREATE_PATH,
|
|
32
|
+
VIEW_EDIT_PATH,
|
|
33
|
+
VIEW_MODULES_PATH,
|
|
34
|
+
VIEW_STORE_PATH,
|
|
35
|
+
)
|
|
36
|
+
from settings.contracts.schemas import SettingCreate, SettingUpdate
|
|
37
|
+
from settings.deps import get_setting_service
|
|
38
|
+
from settings.service import SettingService
|
|
39
|
+
|
|
40
|
+
_PAGE_BROWSE = "Settings/Browse"
|
|
41
|
+
_PAGE_CREATE = "Settings/Create"
|
|
42
|
+
_PAGE_EDIT = "Settings/Edit"
|
|
43
|
+
_PAGE_MODULES_EDIT = "Settings/ModulesEdit"
|
|
44
|
+
|
|
45
|
+
# Row-level actions return to the raw store they were performed in, not to
|
|
46
|
+
# the module forms that now own the section root.
|
|
47
|
+
_REDIRECT_SETTINGS = "/settings/store"
|
|
48
|
+
_REDIRECT_MODULES = "/settings/"
|
|
49
|
+
|
|
50
|
+
# Every screen in this section reads configuration: module field values,
|
|
51
|
+
# their env var names, and now which of the two is in force. The matching JSON
|
|
52
|
+
# API (``/api/settings/...``) has always required ``settings.view``, so leaving
|
|
53
|
+
# these unguarded let any signed-in account read the same data by asking for
|
|
54
|
+
# the page instead. Mutating routes add their own stricter guard on top.
|
|
55
|
+
router = APIRouter(dependencies=[Depends(RequiresPermission(PERM_VIEW))])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.get(VIEW_STORE_PATH, response_model=None)
|
|
59
|
+
async def browse(
|
|
60
|
+
inertia: InertiaDep,
|
|
61
|
+
service: SettingService = Depends(get_setting_service),
|
|
62
|
+
) -> InertiaResponse:
|
|
63
|
+
"""The raw key/value store.
|
|
64
|
+
|
|
65
|
+
Moved off the section root: it is a database view, and an admin who clicks
|
|
66
|
+
"Settings" is nearly always after a module's form, not a table of rows
|
|
67
|
+
keyed by dotted strings.
|
|
68
|
+
"""
|
|
69
|
+
items = await service.list_all()
|
|
70
|
+
return await inertia.render(
|
|
71
|
+
_PAGE_BROWSE,
|
|
72
|
+
{PROP_SETTINGS: [item.model_dump(mode="json") for item in items]},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.get(VIEW_MODULES_PATH, response_model=None)
|
|
77
|
+
async def modules_redirect() -> RedirectResponse:
|
|
78
|
+
"""The per-module forms moved to the section root; keep old links alive."""
|
|
79
|
+
return RedirectResponse(_REDIRECT_MODULES, status_code=308)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.get(VIEW_CREATE_PATH, response_model=None)
|
|
83
|
+
async def create_view(request: Request, inertia: InertiaDep) -> InertiaResponse:
|
|
84
|
+
return await inertia.render(_PAGE_CREATE, {"known_keys": _known_keys(request)})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _known_keys(request: Request) -> list[dict[str, str]]:
|
|
88
|
+
"""Every ``<package>.<field>`` a module actually reads, for autocomplete.
|
|
89
|
+
|
|
90
|
+
The key field is free text, and a typo produces a row that looks saved and
|
|
91
|
+
is silently never read — the failure gives no feedback at all. Suggesting
|
|
92
|
+
the registered keys makes the common case unmissable without forbidding
|
|
93
|
+
the uncommon one: keys outside this list stay valid, since a module can
|
|
94
|
+
read settings the settings module cannot enumerate.
|
|
95
|
+
"""
|
|
96
|
+
suggestions: list[dict[str, str]] = []
|
|
97
|
+
for view in collect_module_settings(request.app):
|
|
98
|
+
for field in view.fields:
|
|
99
|
+
suggestions.append(
|
|
100
|
+
{
|
|
101
|
+
"key": f"{view.package}.{field.name}",
|
|
102
|
+
"type": field.type,
|
|
103
|
+
"description": field.description,
|
|
104
|
+
"module": view.module_name,
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
return sorted(suggestions, key=lambda s: s["key"])
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.get(VIEW_EDIT_PATH, response_model=None)
|
|
111
|
+
async def edit_view(
|
|
112
|
+
setting_id: int,
|
|
113
|
+
inertia: InertiaDep,
|
|
114
|
+
service: SettingService = Depends(get_setting_service),
|
|
115
|
+
) -> InertiaResponse:
|
|
116
|
+
item = await service.get_by_id(setting_id)
|
|
117
|
+
if item is None:
|
|
118
|
+
return await inertia.render(_PAGE_BROWSE, {PROP_ERROR: ERR_SETTING_NOT_FOUND})
|
|
119
|
+
return await inertia.render(_PAGE_EDIT, {PROP_SETTING: item.model_dump(mode="json")})
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── Form actions (POST/PUT/DELETE → redirect) ─────────────────
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Posts to the store collection, which is where the rows live now that the
|
|
126
|
+
# section root renders the module forms.
|
|
127
|
+
@router.post(
|
|
128
|
+
VIEW_STORE_PATH,
|
|
129
|
+
response_model=None,
|
|
130
|
+
dependencies=[Depends(RequiresPermission(PERM_CREATE))],
|
|
131
|
+
)
|
|
132
|
+
async def create_action(
|
|
133
|
+
request: Request,
|
|
134
|
+
service: SettingService = Depends(get_setting_service),
|
|
135
|
+
) -> RedirectResponse:
|
|
136
|
+
body = await request.json()
|
|
137
|
+
try:
|
|
138
|
+
data = SettingCreate(**body)
|
|
139
|
+
except ValidationError as exc:
|
|
140
|
+
return redirect_back_with_errors(request, validation_errors_to_dict(exc))
|
|
141
|
+
await service.create(data)
|
|
142
|
+
return RedirectResponse(_REDIRECT_SETTINGS, status_code=303)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.put(
|
|
146
|
+
"/{setting_id}",
|
|
147
|
+
response_model=None,
|
|
148
|
+
dependencies=[Depends(RequiresPermission(PERM_EDIT))],
|
|
149
|
+
)
|
|
150
|
+
async def update_action(
|
|
151
|
+
setting_id: int,
|
|
152
|
+
request: Request,
|
|
153
|
+
service: SettingService = Depends(get_setting_service),
|
|
154
|
+
) -> RedirectResponse:
|
|
155
|
+
body = await request.json()
|
|
156
|
+
try:
|
|
157
|
+
data = SettingUpdate(**body)
|
|
158
|
+
except ValidationError as exc:
|
|
159
|
+
return redirect_back_with_errors(request, validation_errors_to_dict(exc))
|
|
160
|
+
await service.update(setting_id, data)
|
|
161
|
+
return RedirectResponse(_REDIRECT_SETTINGS, status_code=303)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@router.delete(
|
|
165
|
+
"/{setting_id}",
|
|
166
|
+
response_model=None,
|
|
167
|
+
dependencies=[Depends(RequiresPermission(PERM_DELETE))],
|
|
168
|
+
)
|
|
169
|
+
async def delete_action(
|
|
170
|
+
setting_id: int,
|
|
171
|
+
service: SettingService = Depends(get_setting_service),
|
|
172
|
+
) -> RedirectResponse:
|
|
173
|
+
await service.delete(setting_id)
|
|
174
|
+
return RedirectResponse(_REDIRECT_SETTINGS, status_code=303)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@router.get("/", response_model=None)
|
|
178
|
+
async def modules_view(
|
|
179
|
+
request: Request,
|
|
180
|
+
inertia: InertiaDep,
|
|
181
|
+
service: SettingService = Depends(get_setting_service),
|
|
182
|
+
) -> InertiaResponse:
|
|
183
|
+
"""Read-only view of every module's pydantic ``BaseSettings`` instance.
|
|
184
|
+
|
|
185
|
+
Auto-discovered from ``app.state.sm.modules``; secrets are masked server-side.
|
|
186
|
+
Each field also reports where its live value came from — a stored override,
|
|
187
|
+
an ``SM_*`` env var, or the field default — so a setting that "isn't taking
|
|
188
|
+
effect" explains itself.
|
|
189
|
+
"""
|
|
190
|
+
overrides = await _overrides_by_package(service)
|
|
191
|
+
views = collect_module_settings(request.app, overrides)
|
|
192
|
+
return await inertia.render(
|
|
193
|
+
_PAGE_MODULES_EDIT,
|
|
194
|
+
{
|
|
195
|
+
PROP_MODULES: serialize(views),
|
|
196
|
+
# Which packages can be connection-tested, so the page only offers
|
|
197
|
+
# the button where something is actually reachable.
|
|
198
|
+
"testable": _testable_packages(request),
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def _overrides_by_package(service: SettingService) -> dict[str, frozenset[str]]:
|
|
204
|
+
"""Map package -> field names carrying a stored override.
|
|
205
|
+
|
|
206
|
+
Reads the SYSTEM scope once and buckets by key prefix. Packages with no
|
|
207
|
+
overrides are simply absent, which ``collect_module_settings`` already
|
|
208
|
+
treats as "nothing overridden".
|
|
209
|
+
"""
|
|
210
|
+
from settings.store import SettingsStore
|
|
211
|
+
|
|
212
|
+
return await SettingsStore(service).all_override_fields()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _testable_packages(request: Request) -> list[str]:
|
|
216
|
+
"""Packages whose module registered at least one health check.
|
|
217
|
+
|
|
218
|
+
"Test connection" is just that module's health checks run on demand —
|
|
219
|
+
reusing the registry means settings never learns what an SMTP or an S3
|
|
220
|
+
connection is.
|
|
221
|
+
"""
|
|
222
|
+
registry = request.app.state.sm.health_registry
|
|
223
|
+
owners = {c.module for c in registry.all_checks if c.module}
|
|
224
|
+
return sorted(
|
|
225
|
+
{
|
|
226
|
+
_package_of(mod)
|
|
227
|
+
for mod in getattr(request.app.state.sm, "modules", ())
|
|
228
|
+
if mod.meta.name in owners
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@router.post(
|
|
234
|
+
"/test-connection/{package}",
|
|
235
|
+
response_model=None,
|
|
236
|
+
# Guarded, unlike the read-only view routes around it: this one makes the
|
|
237
|
+
# server open outbound connections on demand (SMTP AUTH, S3) and hands the
|
|
238
|
+
# raw failure text — hostnames, bucket names, auth errors — back to the
|
|
239
|
+
# caller. Only someone allowed to change these settings should be able to.
|
|
240
|
+
dependencies=[Depends(RequiresPermission(PERM_EDIT))],
|
|
241
|
+
)
|
|
242
|
+
async def test_connection(package: str, request: Request) -> dict:
|
|
243
|
+
"""Run one module's health checks now and report each result.
|
|
244
|
+
|
|
245
|
+
Returns 200 with per-check results even when a check fails: an admin
|
|
246
|
+
testing a connection expects to read the failure, not to get an error
|
|
247
|
+
status with the reason buried.
|
|
248
|
+
"""
|
|
249
|
+
modules = getattr(request.app.state.sm, "modules", ())
|
|
250
|
+
owner = next((m for m in modules if _package_of(m) == package), None)
|
|
251
|
+
if owner is None:
|
|
252
|
+
raise HTTPException(status_code=404, detail=f"Unknown module package: {package}")
|
|
253
|
+
|
|
254
|
+
checks = [
|
|
255
|
+
c for c in request.app.state.sm.health_registry.all_checks if c.module == owner.meta.name
|
|
256
|
+
]
|
|
257
|
+
if not checks:
|
|
258
|
+
raise HTTPException(status_code=404, detail=f"{owner.meta.name} has no connection to test")
|
|
259
|
+
|
|
260
|
+
results = []
|
|
261
|
+
for check in checks:
|
|
262
|
+
try:
|
|
263
|
+
outcome = await check.check()
|
|
264
|
+
results.append(
|
|
265
|
+
{"name": check.name, "status": outcome.status.value, "detail": outcome.detail or ""}
|
|
266
|
+
)
|
|
267
|
+
except Exception as exc:
|
|
268
|
+
results.append({"name": check.name, "status": "unhealthy", "detail": str(exc)})
|
|
269
|
+
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}
|
|
@@ -15,13 +15,16 @@ import {
|
|
|
15
15
|
import { Textarea } from '@simple-module-py/ui/components/ui/textarea';
|
|
16
16
|
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
17
17
|
import type React from 'react';
|
|
18
|
+
import { KeyField, type KnownKey } from './components/KeyField';
|
|
18
19
|
import ValueInput, { VALUE_TYPES, type ValueType } from './components/ValueInput';
|
|
19
20
|
import { ROUTES } from './routes';
|
|
20
21
|
|
|
21
22
|
const SCOPES = ['system', 'tenant', 'user'] as const;
|
|
22
23
|
type Scope = (typeof SCOPES)[number];
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
type Props = { known_keys?: KnownKey[] };
|
|
26
|
+
|
|
27
|
+
function Create({ known_keys }: Props) {
|
|
25
28
|
const { t } = useT();
|
|
26
29
|
const { data, setData, post, processing, errors } = useForm({
|
|
27
30
|
scope: 'system' as Scope,
|
|
@@ -83,19 +86,18 @@ function Create() {
|
|
|
83
86
|
{errors.scope_id && <p className="text-xs text-destructive">{errors.scope_id}</p>}
|
|
84
87
|
</div>
|
|
85
88
|
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
</div>
|
|
89
|
+
<KeyField
|
|
90
|
+
value={data.key}
|
|
91
|
+
knownKeys={known_keys ?? []}
|
|
92
|
+
error={errors.key}
|
|
93
|
+
onChange={(key, type) => {
|
|
94
|
+
setData((prev) => ({
|
|
95
|
+
...prev,
|
|
96
|
+
key,
|
|
97
|
+
...(type ? { value_type: type as ValueType } : {}),
|
|
98
|
+
}));
|
|
99
|
+
}}
|
|
100
|
+
/>
|
|
99
101
|
|
|
100
102
|
<div className="space-y-2">
|
|
101
103
|
<Label className="text-sm font-medium text-muted-foreground">
|
{simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/pages/ModulesEdit.tsx
RENAMED
|
@@ -9,9 +9,13 @@ import { useMemo, useState } from 'react';
|
|
|
9
9
|
import { ModuleForm, type ModuleView } from './components/ModuleForm';
|
|
10
10
|
import { ROUTES } from './routes';
|
|
11
11
|
|
|
12
|
-
type Props = {
|
|
12
|
+
type Props = {
|
|
13
|
+
modules: ModuleView[];
|
|
14
|
+
/** Packages whose module registered health checks, so "Test connection" applies. */
|
|
15
|
+
testable?: string[];
|
|
16
|
+
};
|
|
13
17
|
|
|
14
|
-
function ModulesEdit({ modules }: Props) {
|
|
18
|
+
function ModulesEdit({ modules, testable = [] }: Props) {
|
|
15
19
|
const { t } = useT();
|
|
16
20
|
const [selected, setSelected] = useState(modules[0]?.package);
|
|
17
21
|
const [q, setQ] = useState('');
|
|
@@ -92,7 +96,7 @@ function ModulesEdit({ modules }: Props) {
|
|
|
92
96
|
<main className="flex-1 overflow-y-auto p-6">
|
|
93
97
|
{current ? (
|
|
94
98
|
<Card className="border-border p-6">
|
|
95
|
-
<ModuleForm module={current} />
|
|
99
|
+
<ModuleForm module={current} testable={testable.includes(current.package)} />
|
|
96
100
|
</Card>
|
|
97
101
|
) : (
|
|
98
102
|
<p className="text-muted-foreground">{t(keys.settings.modules.empty_title)}</p>
|
|
@@ -12,6 +12,12 @@ export type FieldMeta = {
|
|
|
12
12
|
requires_restart: boolean;
|
|
13
13
|
group: string | null;
|
|
14
14
|
env_var: string;
|
|
15
|
+
/** The field's SM_* env var is present in the process environment. */
|
|
16
|
+
env_set?: boolean;
|
|
17
|
+
/** A stored setting overrides this field. */
|
|
18
|
+
db_override?: boolean;
|
|
19
|
+
/** Where the live value came from. Mirrors hydrate_settings' precedence. */
|
|
20
|
+
source?: 'db' | 'env' | 'default';
|
|
15
21
|
};
|
|
16
22
|
|
|
17
23
|
type Props = {
|