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.
Files changed (64) hide show
  1. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/.gitignore +1 -0
  2. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/PKG-INFO +5 -5
  3. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/pyproject.toml +4 -4
  4. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/_module_settings.py +87 -7
  5. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/constants.py +6 -0
  6. simple_module_settings-0.0.31/settings/endpoints/views.py +269 -0
  7. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/locales/en.json +13 -2
  8. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/module.py +17 -0
  9. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/pages/Create.tsx +16 -14
  10. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/pages/ModulesEdit.tsx +7 -3
  11. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/pages/components/FieldInput.tsx +6 -0
  12. simple_module_settings-0.0.31/settings/pages/components/FieldSource.tsx +59 -0
  13. simple_module_settings-0.0.31/settings/pages/components/KeyField.tsx +104 -0
  14. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/pages/components/ModuleForm.tsx +20 -10
  15. simple_module_settings-0.0.31/settings/pages/components/TestConnectionButton.tsx +70 -0
  16. simple_module_settings-0.0.31/settings/pages/routes.ts +10 -0
  17. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/store.py +16 -0
  18. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_module_settings.py +1 -0
  19. simple_module_settings-0.0.31/tests/test_settings_field_sources.py +122 -0
  20. simple_module_settings-0.0.31/tests/test_settings_view_authz.py +61 -0
  21. simple_module_settings-0.0.30/settings/endpoints/views.py +0 -124
  22. simple_module_settings-0.0.30/settings/pages/routes.ts +0 -7
  23. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/LICENSE +0 -0
  24. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/README.md +0 -0
  25. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/package.json +0 -0
  26. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/__init__.py +0 -0
  27. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/cli.py +0 -0
  28. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/contracts/__init__.py +0 -0
  29. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/contracts/accessor.py +0 -0
  30. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/contracts/events.py +0 -0
  31. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/contracts/registry.py +0 -0
  32. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/contracts/schemas.py +0 -0
  33. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/deps.py +0 -0
  34. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/endpoints/__init__.py +0 -0
  35. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/endpoints/api.py +0 -0
  36. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/endpoints/module_api.py +0 -0
  37. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/env_vars.py +0 -0
  38. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/hydrate.py +0 -0
  39. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/models.py +0 -0
  40. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/module_registry.py +0 -0
  41. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/pages/Browse.tsx +0 -0
  42. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/pages/Edit.tsx +0 -0
  43. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/pages/components/ValueInput.tsx +0 -0
  44. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/py.typed +0 -0
  45. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/registration.py +0 -0
  46. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/reload.py +0 -0
  47. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/service.py +0 -0
  48. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/services.py +0 -0
  49. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/settings/settings.py +0 -0
  50. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_cli_import.py +0 -0
  51. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_concurrent_overrides.py +0 -0
  52. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_hydrate.py +0 -0
  53. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_module_api.py +0 -0
  54. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_module_registry.py +0 -0
  55. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_registration.py +0 -0
  56. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_reload.py +0 -0
  57. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_settings_accessor.py +0 -0
  58. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_settings_api.py +0 -0
  59. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_settings_events.py +0 -0
  60. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_settings_module.py +0 -0
  61. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_settings_schemas.py +0 -0
  62. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_settings_service.py +0 -0
  63. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tests/test_store.py +0 -0
  64. {simple_module_settings-0.0.30 → simple_module_settings-0.0.31}/tsconfig.json +0 -0
@@ -63,3 +63,4 @@ Thumbs.db
63
63
  host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
+ .verify/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: simple_module_settings
3
- Version: 0.0.30
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.30
25
- Requires-Dist: simple-module-db==0.0.30
26
- Requires-Dist: simple-module-hosting==0.0.30
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.30"
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.30",
25
- "simple_module_db==0.0.30",
26
- "simple_module_hosting==0.0.30",
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
 
@@ -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 _field_view(name: str, settings: BaseSettings, prefix: str) -> ModuleSettingField:
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=f"{prefix}{name.upper()}",
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(app: FastAPI) -> list[ModuleSettingsView]:
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(module_name: str, package: str, settings: BaseSettings) -> ModuleSettingsView:
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
- fields = [_field_view(name, settings, prefix) for name in type(settings).model_fields]
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
- function Create() {
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
- <div className="space-y-2 sm:col-span-2">
87
- <Label className="text-sm font-medium text-muted-foreground">
88
- {t(keys.settings.form.key_label)}
89
- </Label>
90
- <Input
91
- value={data.key}
92
- onChange={(e) => setData('key', e.target.value)}
93
- required
94
- placeholder={t(keys.settings.form.key_placeholder)}
95
- className="font-mono"
96
- />
97
- {errors.key && <p className="text-xs text-destructive">{errors.key}</p>}
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">
@@ -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 = { modules: ModuleView[] };
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 = {