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.
Files changed (66) hide show
  1. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/.gitignore +1 -0
  2. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/PKG-INFO +5 -5
  3. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/pyproject.toml +4 -4
  4. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/_module_settings.py +113 -10
  5. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/constants.py +6 -0
  6. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/endpoints/module_api.py +10 -2
  7. simple_module_settings-0.0.32/settings/endpoints/views.py +272 -0
  8. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/locales/en.json +13 -2
  9. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/module.py +17 -0
  10. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/pages/Create.tsx +16 -14
  11. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/pages/ModulesEdit.tsx +10 -4
  12. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/pages/components/FieldInput.tsx +6 -0
  13. simple_module_settings-0.0.32/settings/pages/components/FieldSource.tsx +59 -0
  14. simple_module_settings-0.0.32/settings/pages/components/KeyField.tsx +104 -0
  15. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/pages/components/ModuleForm.tsx +20 -10
  16. simple_module_settings-0.0.32/settings/pages/components/TestConnectionButton.tsx +70 -0
  17. simple_module_settings-0.0.32/settings/pages/routes.ts +10 -0
  18. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/store.py +16 -0
  19. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_module_settings.py +1 -0
  20. simple_module_settings-0.0.32/tests/test_module_settings_render.py +90 -0
  21. simple_module_settings-0.0.32/tests/test_module_settings_serialize.py +92 -0
  22. simple_module_settings-0.0.32/tests/test_settings_field_sources.py +122 -0
  23. simple_module_settings-0.0.32/tests/test_settings_view_authz.py +61 -0
  24. simple_module_settings-0.0.30/settings/endpoints/views.py +0 -124
  25. simple_module_settings-0.0.30/settings/pages/routes.ts +0 -7
  26. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/LICENSE +0 -0
  27. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/README.md +0 -0
  28. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/package.json +0 -0
  29. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/__init__.py +0 -0
  30. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/cli.py +0 -0
  31. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/contracts/__init__.py +0 -0
  32. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/contracts/accessor.py +0 -0
  33. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/contracts/events.py +0 -0
  34. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/contracts/registry.py +0 -0
  35. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/contracts/schemas.py +0 -0
  36. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/deps.py +0 -0
  37. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/endpoints/__init__.py +0 -0
  38. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/endpoints/api.py +0 -0
  39. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/env_vars.py +0 -0
  40. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/hydrate.py +0 -0
  41. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/models.py +0 -0
  42. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/module_registry.py +0 -0
  43. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/pages/Browse.tsx +0 -0
  44. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/pages/Edit.tsx +0 -0
  45. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/pages/components/ValueInput.tsx +0 -0
  46. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/py.typed +0 -0
  47. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/registration.py +0 -0
  48. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/reload.py +0 -0
  49. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/service.py +0 -0
  50. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/services.py +0 -0
  51. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/settings/settings.py +0 -0
  52. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_cli_import.py +0 -0
  53. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_concurrent_overrides.py +0 -0
  54. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_hydrate.py +0 -0
  55. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_module_api.py +0 -0
  56. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_module_registry.py +0 -0
  57. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_registration.py +0 -0
  58. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_reload.py +0 -0
  59. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_settings_accessor.py +0 -0
  60. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_settings_api.py +0 -0
  61. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_settings_events.py +0 -0
  62. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_settings_module.py +0 -0
  63. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_settings_schemas.py +0 -0
  64. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_settings_service.py +0 -0
  65. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/tests/test_store.py +0 -0
  66. {simple_module_settings-0.0.30 → simple_module_settings-0.0.32}/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.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.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.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.30"
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.30",
25
- "simple_module_db==0.0.30",
26
- "simple_module_hosting==0.0.30",
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
 
@@ -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 _field_view(name: str, settings: BaseSettings, prefix: str) -> ModuleSettingField:
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=f"{prefix}{name.upper()}",
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(app: FastAPI) -> list[ModuleSettingsView]:
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(module_name: str, package: str, settings: BaseSettings) -> ModuleSettingsView:
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
- fields = [_field_view(name, settings, prefix) for name in type(settings).model_fields]
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}"
@@ -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(request: Request) -> dict[str, Any]:
51
- views = collect_module_settings(request.app)
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}