simple-module-settings 0.0.41__tar.gz → 0.0.42__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 (128) hide show
  1. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/PKG-INFO +4 -4
  2. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/package.json +4 -4
  3. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/pyproject.toml +4 -4
  4. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/_managed_keys.py +9 -1
  5. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/constants.py +1 -0
  6. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/deps.py +10 -0
  7. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/endpoints/_create_form.py +3 -0
  8. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/endpoints/api.py +16 -16
  9. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/endpoints/views.py +14 -9
  10. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/errors.py +9 -1
  11. simple_module_settings-0.0.42/settings/known_keys.py +47 -0
  12. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/components/ModuleForm.tsx +29 -2
  13. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/service.py +25 -1
  14. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_known_keys_meta.py +21 -53
  15. simple_module_settings-0.0.42/tests/test_settings_generic_module_guard.py +109 -0
  16. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_secret_urls.py +11 -11
  17. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests-js/settings_ModuleFormRevert.test.tsx +62 -0
  18. simple_module_settings-0.0.41/settings/known_keys.py +0 -86
  19. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/.gitignore +0 -0
  20. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/LICENSE +0 -0
  21. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/README.md +0 -0
  22. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/__init__.py +0 -0
  23. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/_announce.py +0 -0
  24. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/_field_meta.py +0 -0
  25. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/_listing.py +0 -0
  26. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/_module_settings.py +0 -0
  27. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/_module_settings_props.py +0 -0
  28. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/_row_masking.py +0 -0
  29. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/_secrets.py +0 -0
  30. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/_unique_write.py +0 -0
  31. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/browse_query.py +0 -0
  32. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/cli.py +0 -0
  33. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/contracts/__init__.py +0 -0
  34. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/contracts/accessor.py +0 -0
  35. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/contracts/events.py +0 -0
  36. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/contracts/invalidation.py +0 -0
  37. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/contracts/registry.py +0 -0
  38. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/contracts/schemas.py +0 -0
  39. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/endpoints/__init__.py +0 -0
  40. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/endpoints/module_api.py +0 -0
  41. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/endpoints/tenant_api.py +0 -0
  42. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/env_vars.py +0 -0
  43. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/exceptions.py +0 -0
  44. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/hydrate.py +0 -0
  45. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/invalidation.py +0 -0
  46. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/locales/de.json +0 -0
  47. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/locales/en.json +0 -0
  48. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/models.py +0 -0
  49. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/module.py +0 -0
  50. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/module_registry.py +0 -0
  51. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/Browse.tsx +0 -0
  52. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/Create.tsx +0 -0
  53. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/Edit.tsx +0 -0
  54. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/ModulesEdit.tsx +0 -0
  55. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/api.generated.ts +0 -0
  56. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/api.ts +0 -0
  57. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/components/FieldInput.tsx +0 -0
  58. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/components/FieldSource.tsx +0 -0
  59. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/components/JsonInput.tsx +0 -0
  60. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/components/KeyField.tsx +0 -0
  61. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/components/ModuleFieldRow.tsx +0 -0
  62. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/components/ResolvedValue.tsx +0 -0
  63. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/components/ScopeTabs.tsx +0 -0
  64. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/components/StoreCards.tsx +0 -0
  65. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/components/StoreTable.tsx +0 -0
  66. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/components/TestConnectionButton.tsx +0 -0
  67. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/components/ValueInput.tsx +0 -0
  68. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/components/last-test.ts +0 -0
  69. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/components/validation-errors.test.ts +0 -0
  70. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/components/validation-errors.ts +0 -0
  71. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/routes.ts +0 -0
  72. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/pages/types.ts +0 -0
  73. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/py.typed +0 -0
  74. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/registration.py +0 -0
  75. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/reload.py +0 -0
  76. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/services.py +0 -0
  77. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/settings.py +0 -0
  78. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/store.py +0 -0
  79. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/tenant_scope.py +0 -0
  80. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/settings/tenant_view.py +0 -0
  81. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_clear_route_per_scope.py +0 -0
  82. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_cli_import.py +0 -0
  83. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_concurrent_overrides.py +0 -0
  84. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_duplicate_and_race.py +0 -0
  85. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_env_pinned.py +0 -0
  86. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_hydrate.py +0 -0
  87. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_hydrate_related_fields.py +0 -0
  88. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_lenient_related_fields_validators.py +0 -0
  89. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_module_api.py +0 -0
  90. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_module_api_json_fields.py +0 -0
  91. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_module_registry.py +0 -0
  92. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_module_settings.py +0 -0
  93. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_module_settings_render.py +0 -0
  94. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_module_settings_serialize.py +0 -0
  95. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_registration.py +0 -0
  96. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_reload.py +0 -0
  97. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_accessor.py +0 -0
  98. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_api.py +0 -0
  99. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_commit_invalidation.py +0 -0
  100. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_default_clears_override.py +0 -0
  101. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_domain_errors.py +0 -0
  102. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_env_unprefixed.py +0 -0
  103. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_events.py +0 -0
  104. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_field_sources.py +0 -0
  105. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_framework_reexports.py +0 -0
  106. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_group_metadata.py +0 -0
  107. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_host_restart_flags.py +0 -0
  108. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_module.py +0 -0
  109. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_schemas.py +0 -0
  110. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_secret_json_roundtrip.py +0 -0
  111. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_secret_masking.py +0 -0
  112. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_service.py +0 -0
  113. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_typed_secret_masking.py +0 -0
  114. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_settings_view_authz.py +0 -0
  115. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_store.py +0 -0
  116. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_store_filters.py +0 -0
  117. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_store_secret_masking.py +0 -0
  118. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_tenant_settings.py +0 -0
  119. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_testable_checks.py +0 -0
  120. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests/test_upload_key_delete.py +0 -0
  121. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests-js/JsonFields.test.tsx +0 -0
  122. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests-js/ModuleForm.test.tsx +0 -0
  123. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests-js/ModulesEdit.test.tsx +0 -0
  124. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests-js/RestartHint.test.tsx +0 -0
  125. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests-js/ScopeTabs.test.tsx +0 -0
  126. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests-js/settings_ModuleFieldLayout.test.tsx +0 -0
  127. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tests-js/settings_TestConnectionButton.test.tsx +0 -0
  128. {simple_module_settings-0.0.41 → simple_module_settings-0.0.42}/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_settings
3
- Version: 0.0.41
3
+ Version: 0.0.42
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.41
25
- Requires-Dist: simple-module-db==0.0.41
26
- Requires-Dist: simple-module-hosting==0.0.41
24
+ Requires-Dist: simple-module-core==0.0.42
25
+ Requires-Dist: simple-module-db==0.0.42
26
+ Requires-Dist: simple-module-hosting==0.0.42
27
27
  Requires-Dist: typer<0.28,>=0.12
28
28
  Description-Content-Type: text/markdown
29
29
 
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@simple-module-py/settings",
3
- "version": "0.0.41",
3
+ "version": "0.0.42",
4
4
  "private": true,
5
5
  "description": "Frontend assets for the Settings module",
6
6
  "peerDependencies": {
7
+ "@inertiajs/react": "^3.9.1",
8
+ "@simple-module-py/ui": "*",
7
9
  "react": "^19.0.0",
8
- "react-dom": "^19.0.0",
9
- "@inertiajs/react": "^3.7.0",
10
- "@simple-module-py/ui": "*"
10
+ "react-dom": "^19.3.1"
11
11
  },
12
12
  "devDependencies": {
13
13
  "@simple-module-py/tsconfig": "*"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_settings"
3
- version = "0.0.41"
3
+ version = "0.0.42"
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.41",
25
- "simple_module_db==0.0.41",
26
- "simple_module_hosting==0.0.41",
24
+ "simple_module_core==0.0.42",
25
+ "simple_module_db==0.0.42",
26
+ "simple_module_hosting==0.0.42",
27
27
  "typer>=0.12,<0.28",
28
28
  ]
29
29
 
@@ -22,7 +22,7 @@ from __future__ import annotations
22
22
 
23
23
  from collections.abc import Awaitable, Callable
24
24
 
25
- from settings.constants import ERR_MANAGED_KEY_DELETE
25
+ from settings.constants import ERR_MANAGED_KEY_DELETE, ERR_MODULE_KEY_EDIT
26
26
  from settings.contracts.registry import SettingsRegistry, clear_route
27
27
  from settings.contracts.schemas import SettingScope
28
28
  from settings.exceptions import SettingsError
@@ -58,3 +58,11 @@ async def ensure_deletable(
58
58
  if orphaned:
59
59
  return
60
60
  raise ManagedKeyError(key, clear_route(definition, scope))
61
+
62
+
63
+ class ModuleKeyError(SettingsError):
64
+ """A generic mutation would bypass the module editor's validation."""
65
+
66
+ def __init__(self, package: str, edit_via: str) -> None:
67
+ self.edit_via = edit_via
68
+ super().__init__(ERR_MODULE_KEY_EDIT.format(package=package, edit_via=edit_via))
@@ -145,6 +145,7 @@ ERR_VALUE_MISMATCH: Final = "value does not parse as declared value_type"
145
145
  ERR_UNKNOWN_TENANT: Final = "Unknown tenant"
146
146
  ERR_NO_ACTIVE_TENANT: Final = "No active organisation for this request"
147
147
  ERR_MANAGED_KEY_DELETE: Final = "This setting is managed elsewhere; clear it at {clear_via}"
148
+ ERR_MODULE_KEY_EDIT: Final = "Module settings must be edited at {edit_via} (package: {package})"
148
149
  ERR_NOT_TENANT_OVERRIDABLE: Final = "This setting cannot be changed per organisation"
149
150
 
150
151
  # ── HTTP ─────────────────────────────────────────────────────────────
@@ -12,6 +12,7 @@ from typing import Annotated
12
12
 
13
13
  from fastapi import Depends, Request
14
14
  from simple_module_core.app_state import get_services
15
+ from simple_module_core.module_settings import get_module_settings_registry
15
16
  from simple_module_db.deps import get_db
16
17
  from sqlalchemy.ext.asyncio import AsyncSession
17
18
 
@@ -36,6 +37,15 @@ async def get_setting_service(
36
37
  )
37
38
 
38
39
 
40
+ async def get_generic_setting_service(
41
+ request: Request,
42
+ service: SettingService = Depends(get_setting_service),
43
+ ) -> SettingService:
44
+ """Raw store routes must send module configuration through its own editor."""
45
+ service.module_registry = get_module_settings_registry(request.app)
46
+ return service
47
+
48
+
39
49
  def get_settings_registry(request: Request) -> SettingsRegistry:
40
50
  """Return the app-wide settings registry populated during boot."""
41
51
  services = getattr(request.app.state, MODULE_PACKAGE)
@@ -7,6 +7,7 @@ from pydantic import ValidationError
7
7
  from simple_module_hosting.inertia_utils import redirect_back_with_errors, validation_errors_to_dict
8
8
  from starlette.responses import RedirectResponse
9
9
 
10
+ from settings._managed_keys import ModuleKeyError
10
11
  from settings._unique_write import DuplicateSettingError
11
12
  from settings.constants import ERR_SETTING_EXISTS
12
13
  from settings.contracts.schemas import SettingCreate, SettingScope
@@ -31,4 +32,6 @@ async def create_from_form(
31
32
  await service.create(data)
32
33
  except DuplicateSettingError:
33
34
  return redirect_back_with_errors(request, {"key": ERR_SETTING_EXISTS})
35
+ except ModuleKeyError as exc:
36
+ return redirect_back_with_errors(request, {"key": str(exc)})
34
37
  return RedirectResponse(redirect_to, status_code=303)
@@ -37,7 +37,7 @@ from settings.contracts.schemas import (
37
37
  SettingUpdate,
38
38
  SettingUpsert,
39
39
  )
40
- from settings.deps import get_setting_service
40
+ from settings.deps import get_generic_setting_service
41
41
  from settings.service import SettingService
42
42
  from settings.tenant_scope import (
43
43
  is_known_tenant,
@@ -69,7 +69,7 @@ def _not_found() -> HTTPException:
69
69
  async def list_settings(
70
70
  scope: SettingScope | None = Query(default=None, alias=QP_SCOPE),
71
71
  scope_id: str = Query(default=SYSTEM_SCOPE_ID, alias=QP_SCOPE_ID),
72
- service: SettingService = Depends(get_setting_service),
72
+ service: SettingService = Depends(get_generic_setting_service),
73
73
  ) -> list[SettingOut]:
74
74
  if scope is None:
75
75
  return await service.list_all()
@@ -84,7 +84,7 @@ async def resolve_setting(
84
84
  key: str,
85
85
  user_id: str | None = Query(default=None, alias=QP_USER_ID),
86
86
  tenant_id: str | None = Query(default=None, alias=QP_TENANT_ID),
87
- service: SettingService = Depends(get_setting_service),
87
+ service: SettingService = Depends(get_generic_setting_service),
88
88
  ) -> SettingOut:
89
89
  result = await service.resolve(key, user_id=user_id, tenant_id=tenant_id)
90
90
  if result is None:
@@ -97,7 +97,7 @@ async def resolve_setting(
97
97
 
98
98
  @router.get(API_SYSTEM_PATH, response_model=SettingOut, dependencies=_VIEW)
99
99
  async def get_system_setting(
100
- key: str, service: SettingService = Depends(get_setting_service)
100
+ key: str, service: SettingService = Depends(get_generic_setting_service)
101
101
  ) -> SettingOut:
102
102
  result = await service.get_scoped(SettingScope.SYSTEM, SYSTEM_SCOPE_ID, key)
103
103
  if result is None:
@@ -109,14 +109,14 @@ async def get_system_setting(
109
109
  async def upsert_system_setting(
110
110
  key: str,
111
111
  data: SettingUpsert,
112
- service: SettingService = Depends(get_setting_service),
112
+ service: SettingService = Depends(get_generic_setting_service),
113
113
  ) -> SettingOut:
114
114
  return await service.upsert_scoped(SettingScope.SYSTEM, SYSTEM_SCOPE_ID, key, data)
115
115
 
116
116
 
117
117
  @router.delete(API_SYSTEM_PATH, status_code=STATUS_NO_CONTENT, dependencies=_DELETE)
118
118
  async def delete_system_setting(
119
- key: str, service: SettingService = Depends(get_setting_service)
119
+ key: str, service: SettingService = Depends(get_generic_setting_service)
120
120
  ) -> None:
121
121
  if not await service.delete_scoped(SettingScope.SYSTEM, SYSTEM_SCOPE_ID, key):
122
122
  raise _not_found()
@@ -133,7 +133,7 @@ async def get_tenant_setting(
133
133
  scope_id: str,
134
134
  key: str,
135
135
  request: Request,
136
- service: SettingService = Depends(get_setting_service),
136
+ service: SettingService = Depends(get_generic_setting_service),
137
137
  ) -> SettingOut:
138
138
  await require_known_tenant(request, scope_id)
139
139
  result = await service.get_scoped(SettingScope.TENANT, scope_id, key)
@@ -148,7 +148,7 @@ async def upsert_tenant_setting(
148
148
  key: str,
149
149
  data: SettingUpsert,
150
150
  request: Request,
151
- service: SettingService = Depends(get_setting_service),
151
+ service: SettingService = Depends(get_generic_setting_service),
152
152
  ) -> SettingOut:
153
153
  await require_known_tenant(request, scope_id)
154
154
  await run_check(request, scope_id, key, data.value)
@@ -159,7 +159,7 @@ async def upsert_tenant_setting(
159
159
  async def delete_tenant_setting(
160
160
  scope_id: str,
161
161
  key: str,
162
- service: SettingService = Depends(get_setting_service),
162
+ service: SettingService = Depends(get_generic_setting_service),
163
163
  ) -> None:
164
164
  if not await service.delete_scoped(SettingScope.TENANT, scope_id, key):
165
165
  raise _not_found()
@@ -169,7 +169,7 @@ async def delete_tenant_setting(
169
169
  async def get_user_setting(
170
170
  scope_id: str,
171
171
  key: str,
172
- service: SettingService = Depends(get_setting_service),
172
+ service: SettingService = Depends(get_generic_setting_service),
173
173
  ) -> SettingOut:
174
174
  result = await service.get_scoped(SettingScope.USER, scope_id, key)
175
175
  if result is None:
@@ -182,7 +182,7 @@ async def upsert_user_setting(
182
182
  scope_id: str,
183
183
  key: str,
184
184
  data: SettingUpsert,
185
- service: SettingService = Depends(get_setting_service),
185
+ service: SettingService = Depends(get_generic_setting_service),
186
186
  ) -> SettingOut:
187
187
  return await service.upsert_scoped(SettingScope.USER, scope_id, key, data)
188
188
 
@@ -191,7 +191,7 @@ async def upsert_user_setting(
191
191
  async def delete_user_setting(
192
192
  scope_id: str,
193
193
  key: str,
194
- service: SettingService = Depends(get_setting_service),
194
+ service: SettingService = Depends(get_generic_setting_service),
195
195
  ) -> None:
196
196
  if not await service.delete_scoped(SettingScope.USER, scope_id, key):
197
197
  raise _not_found()
@@ -204,7 +204,7 @@ async def delete_user_setting(
204
204
  async def create_setting(
205
205
  data: SettingCreate,
206
206
  request: Request,
207
- service: SettingService = Depends(get_setting_service),
207
+ service: SettingService = Depends(get_generic_setting_service),
208
208
  ) -> SettingOut:
209
209
  if data.scope is SettingScope.TENANT:
210
210
  # A body field, not a path segment: an unknown tenant is invalid input.
@@ -219,7 +219,7 @@ async def create_setting(
219
219
 
220
220
  @router.get(API_BY_ID_PATH, response_model=SettingOut, dependencies=_VIEW)
221
221
  async def get_setting(
222
- setting_id: int, service: SettingService = Depends(get_setting_service)
222
+ setting_id: int, service: SettingService = Depends(get_generic_setting_service)
223
223
  ) -> SettingOut:
224
224
  result = await service.get_by_id(setting_id)
225
225
  if result is None:
@@ -232,7 +232,7 @@ async def update_setting(
232
232
  setting_id: int,
233
233
  data: SettingUpdate,
234
234
  request: Request,
235
- service: SettingService = Depends(get_setting_service),
235
+ service: SettingService = Depends(get_generic_setting_service),
236
236
  ) -> SettingOut:
237
237
  current = await service.get_by_id(setting_id)
238
238
  if current is not None and current.scope is SettingScope.TENANT and data.value is not None:
@@ -245,7 +245,7 @@ async def update_setting(
245
245
 
246
246
  @router.delete(API_BY_ID_PATH, status_code=STATUS_NO_CONTENT, dependencies=_DELETE)
247
247
  async def delete_setting(
248
- setting_id: int, service: SettingService = Depends(get_setting_service)
248
+ setting_id: int, service: SettingService = Depends(get_generic_setting_service)
249
249
  ) -> None:
250
250
  if not await service.delete(setting_id):
251
251
  raise _not_found()
@@ -22,7 +22,7 @@ from simple_module_inertia import InertiaResponse
22
22
  from starlette.responses import RedirectResponse
23
23
 
24
24
  from settings import browse_query, known_keys
25
- from settings._managed_keys import ManagedKeyError
25
+ from settings._managed_keys import ManagedKeyError, ModuleKeyError
26
26
  from settings._module_settings import (
27
27
  _package_of,
28
28
  collect_module_settings,
@@ -53,7 +53,7 @@ from settings.constants import (
53
53
  VIEW_STORE_PATH,
54
54
  )
55
55
  from settings.contracts.schemas import SettingUpdate
56
- from settings.deps import get_setting_service
56
+ from settings.deps import get_generic_setting_service
57
57
  from settings.endpoints._create_form import create_from_form
58
58
  from settings.service import SettingService
59
59
  from settings.tenant_scope import tenant_update_error
@@ -81,7 +81,7 @@ router = APIRouter(dependencies=[Depends(RequiresPermission(PERM_VIEW))])
81
81
  @router.get(VIEW_STORE_PATH, response_model=None)
82
82
  async def browse(
83
83
  inertia: InertiaDep,
84
- service: SettingService = Depends(get_setting_service),
84
+ service: SettingService = Depends(get_generic_setting_service),
85
85
  scope: str = SCOPE_ALL,
86
86
  q: str = "",
87
87
  page: browse_query.PageParam = 1,
@@ -139,7 +139,7 @@ async def create_view(request: Request, inertia: InertiaDep) -> InertiaResponse:
139
139
  async def edit_view(
140
140
  setting_id: int,
141
141
  inertia: InertiaDep,
142
- service: SettingService = Depends(get_setting_service),
142
+ service: SettingService = Depends(get_generic_setting_service),
143
143
  ) -> InertiaResponse:
144
144
  item = await service.get_by_id(setting_id)
145
145
  if item is None:
@@ -159,7 +159,7 @@ async def edit_view(
159
159
  )
160
160
  async def create_action(
161
161
  request: Request,
162
- service: SettingService = Depends(get_setting_service),
162
+ service: SettingService = Depends(get_generic_setting_service),
163
163
  ) -> RedirectResponse:
164
164
  return await create_from_form(request, service, _REDIRECT_SETTINGS)
165
165
 
@@ -172,7 +172,7 @@ async def create_action(
172
172
  async def update_action(
173
173
  setting_id: int,
174
174
  request: Request,
175
- service: SettingService = Depends(get_setting_service),
175
+ service: SettingService = Depends(get_generic_setting_service),
176
176
  ) -> RedirectResponse:
177
177
  body = await request.json()
178
178
  try:
@@ -181,7 +181,10 @@ async def update_action(
181
181
  return redirect_back_with_errors(request, validation_errors_to_dict(exc))
182
182
  if error := await tenant_update_error(request, await service.get_by_id(setting_id), data.value):
183
183
  return redirect_back_with_errors(request, {"value": error})
184
- await service.update(setting_id, data)
184
+ try:
185
+ await service.update(setting_id, data)
186
+ except ModuleKeyError as exc:
187
+ return redirect_back_with_errors(request, {"value": str(exc)})
185
188
  return RedirectResponse(_REDIRECT_SETTINGS, status_code=303)
186
189
 
187
190
 
@@ -194,7 +197,7 @@ async def delete_action(
194
197
  setting_id: int,
195
198
  request: Request,
196
199
  t: TranslatorDep,
197
- service: SettingService = Depends(get_setting_service),
200
+ service: SettingService = Depends(get_generic_setting_service),
198
201
  ) -> RedirectResponse:
199
202
  try:
200
203
  await service.delete(setting_id)
@@ -204,6 +207,8 @@ async def delete_action(
204
207
  "settings.browse.delete_managed_error", setting=exc.key, clear_via=exc.clear_via
205
208
  )
206
209
  return redirect_back_with_errors(request, {"delete": message})
210
+ except ModuleKeyError as exc:
211
+ return redirect_back_with_errors(request, {"delete": str(exc)})
207
212
  return RedirectResponse(_REDIRECT_SETTINGS, status_code=303)
208
213
 
209
214
 
@@ -211,7 +216,7 @@ async def delete_action(
211
216
  async def modules_view(
212
217
  request: Request,
213
218
  inertia: InertiaDep,
214
- service: SettingService = Depends(get_setting_service),
219
+ service: SettingService = Depends(get_generic_setting_service),
215
220
  ) -> InertiaResponse:
216
221
  """Read-only view of every module's pydantic ``BaseSettings`` instance.
217
222
 
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from fastapi import FastAPI, Request
6
6
  from fastapi.responses import JSONResponse, Response
7
7
 
8
- from settings._managed_keys import ManagedKeyError
8
+ from settings._managed_keys import ManagedKeyError, ModuleKeyError
9
9
  from settings.constants import STATUS_UNPROCESSABLE
10
10
 
11
11
 
@@ -16,5 +16,13 @@ async def _managed_key(request: Request, exc: Exception) -> Response:
16
16
  )
17
17
 
18
18
 
19
+ async def _module_key(request: Request, exc: Exception) -> Response:
20
+ assert isinstance(exc, ModuleKeyError)
21
+ return JSONResponse(
22
+ {"detail": str(exc), "edit_via": exc.edit_via}, status_code=STATUS_UNPROCESSABLE
23
+ )
24
+
25
+
19
26
  def install_exception_handlers(app: FastAPI) -> None:
20
27
  app.add_exception_handler(ManagedKeyError, _managed_key)
28
+ app.add_exception_handler(ModuleKeyError, _module_key)
@@ -0,0 +1,47 @@
1
+ """Autocomplete for declared generic keys; module settings use their own editor.
2
+
3
+ Defaults are masked here because Browse renders them in its resolved-value panel.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from fastapi import FastAPI
11
+ from fastapi.encoders import jsonable_encoder
12
+ from simple_module_core.module_settings import get_module_settings_registry
13
+ from simple_module_core.secret_fields import conceals_secret, mask
14
+
15
+
16
+ def _from_definition(definition) -> dict[str, Any]:
17
+ """Mask declarations directly, as module fields are no longer suggested."""
18
+ value_type = str(definition.value_type)
19
+ default = jsonable_encoder(definition.default)
20
+ secret = conceals_secret(definition.key, default, value_type)
21
+ return {
22
+ "key": definition.key,
23
+ "type": value_type,
24
+ "env_var": "",
25
+ "env_readable": False,
26
+ "env_set": False,
27
+ "env_value": None,
28
+ "default": mask(default) if secret else default,
29
+ "requires_restart": False,
30
+ "is_secret": secret,
31
+ "choices": None,
32
+ }
33
+
34
+
35
+ def build(app: FastAPI) -> list[dict[str, Any]]:
36
+ """Declared generic keys only; registered module packages use their editor."""
37
+ suggestions: dict[str, dict[str, Any]] = {}
38
+ modules = get_module_settings_registry(app)
39
+ registry = getattr(getattr(app.state, "settings", None), "registry", None)
40
+ if registry is not None:
41
+ for definition in registry.all_definitions:
42
+ package, sep, _ = definition.key.partition(".")
43
+ if sep and modules is not None and modules.get(package) is not None:
44
+ continue
45
+ suggestions[definition.key] = _from_definition(definition)
46
+
47
+ return [suggestions[key] for key in sorted(suggestions)]
@@ -46,6 +46,7 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
46
46
  const [errors, setErrors] = useState<Record<string, string>>({});
47
47
  const [busy, setBusy] = useState(false);
48
48
  const [saved, setSaved] = useState(false);
49
+ const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
49
50
  const [formErrors, setFormErrors] = useState<string[]>([]);
50
51
 
51
52
  // Refresh saved values without discarding drafts in other fields (#637).
@@ -61,6 +62,7 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
61
62
  }));
62
63
  setErrors({});
63
64
  setSaved(false);
65
+ setSavedOverrides({});
64
66
  }, [initial]);
65
67
 
66
68
  const modifiedFields = useMemo(() => {
@@ -113,6 +115,14 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
113
115
  // Keeping the form mounted lets the confirmation remain visible instead
114
116
  // of being discarded by an immediate Inertia reload.
115
117
  setBuffer((prev) => ({ ...prev, baseline: { ...values } }));
118
+ setSavedOverrides((prev) => ({
119
+ ...prev,
120
+ ...Object.fromEntries(
121
+ m.fields
122
+ .filter((f) => f.name in changed)
123
+ .map((f) => [f.name, notEqual(changed[f.name], f.default)]),
124
+ ),
125
+ }));
116
126
  setSaved(true);
117
127
  }
118
128
  setBusy(false);
@@ -126,11 +136,28 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
126
136
  params: { path: { package: m.package, field: name } },
127
137
  }),
128
138
  );
139
+ setSavedOverrides((prev) => ({ ...prev, [name]: false }));
129
140
  setBuffer((prev) => ({
130
141
  ...prev,
131
142
  values: { ...prev.values, [name]: prev.baseline[name] },
132
143
  }));
133
- router.reload({ only: ['modules'] });
144
+ // Take the field's live value from the reload itself: when the page never
145
+ // reloaded after a save, the fresh props equal the old ones and the
146
+ // [initial] effect does not fire, leaving the reverted value on screen.
147
+ router.reload({
148
+ only: ['modules'],
149
+ onSuccess: (page) => {
150
+ const modules = (page.props as { modules?: ModuleView[] }).modules ?? [];
151
+ const fresh = modules
152
+ .find((other) => other.package === m.package)
153
+ ?.fields.find((f) => f.name === name);
154
+ if (!fresh) return;
155
+ setBuffer((prev) => ({
156
+ values: { ...prev.values, [name]: fresh.value },
157
+ baseline: { ...prev.baseline, [name]: fresh.value },
158
+ }));
159
+ },
160
+ });
134
161
  }
135
162
 
136
163
  return (
@@ -188,7 +215,7 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
188
215
  {fields.map((f) => (
189
216
  <ModuleFieldRow
190
217
  key={f.name}
191
- field={f}
218
+ field={{ ...f, db_override: savedOverrides[f.name] ?? f.db_override }}
192
219
  package={m.package}
193
220
  value={values[f.name]}
194
221
  modified={modifiedFields.has(f.name)}
@@ -11,9 +11,10 @@ from sqlmodel import col
11
11
 
12
12
  from settings._announce import announce
13
13
  from settings._listing import SettingListing
14
- from settings._managed_keys import TenantIsLive, ensure_deletable
14
+ from settings._managed_keys import ModuleKeyError, TenantIsLive, ensure_deletable
15
15
  from settings._row_masking import drop_placeholder_write, is_placeholder_write, out
16
16
  from settings._unique_write import DuplicateSettingError, insert_if_free
17
+ from settings.constants import VIEW_PREFIX
17
18
  from settings.contracts.schemas import (
18
19
  SettingCreate,
19
20
  SettingOut,
@@ -24,6 +25,7 @@ from settings.contracts.schemas import (
24
25
 
25
26
  if TYPE_CHECKING:
26
27
  from simple_module_core.invalidation import InvalidationBus
28
+ from simple_module_core.module_settings import ModuleSettingsRegistry
27
29
 
28
30
  from settings.contracts.registry import SettingsRegistry
29
31
 
@@ -43,6 +45,7 @@ class SettingService(SettingListing):
43
45
  tenant_is_live: TenantIsLive | None = None,
44
46
  ) -> None:
45
47
  self.db = db
48
+ self.module_registry: ModuleSettingsRegistry | None = None
46
49
  # With a registry, ``delete``/``delete_scoped`` refuse a ``clear_via``
47
50
  # key (see ``_managed_keys``); ``tenant_is_live`` lets a deleted
48
51
  # tenant's leftover row through.
@@ -55,6 +58,22 @@ class SettingService(SettingListing):
55
58
  def _changed(self, entity: Setting) -> None:
56
59
  announce(self.db, self.invalidation, entity.scope, entity.scope_id, entity.key)
57
60
 
61
+ def _ensure_generic_key(self, scope: SettingScope | str, key: str) -> None:
62
+ """Refuse a system row for a module field: only its editor validates and reloads.
63
+
64
+ Tenant (and user) rows stay writable here: a ``tenant_overridable``
65
+ module key's per-tenant override has no other platform route (#690).
66
+ """
67
+ if SettingScope(scope) is not SettingScope.SYSTEM:
68
+ return
69
+ package, sep, field = key.partition(".")
70
+ registry = self.module_registry
71
+ settings_cls = registry.get(package) if sep and registry is not None else None
72
+ # An orphaned row (no such field any more) has no editor to clear it, so
73
+ # it stays deletable here.
74
+ if settings_cls is not None and field in settings_cls.model_fields:
75
+ raise ModuleKeyError(package, registry.manage_url(package) or f"{VIEW_PREFIX}/")
76
+
58
77
  # ── Lookup ──────────────────────────────────────────────────────
59
78
 
60
79
  async def get_by_id(self, setting_id: int) -> SettingOut | None:
@@ -115,6 +134,7 @@ class SettingService(SettingListing):
115
134
  # ── Mutations ───────────────────────────────────────────────────
116
135
 
117
136
  async def create(self, data: SettingCreate) -> SettingOut:
137
+ self._ensure_generic_key(data.scope, data.key)
118
138
  entity = Setting(**data.model_dump())
119
139
  if not await insert_if_free(self.db, entity):
120
140
  raise DuplicateSettingError(f"{data.scope.value}/{data.scope_id}/{data.key}")
@@ -126,6 +146,7 @@ class SettingService(SettingListing):
126
146
  entity = await self.db.get(Setting, setting_id)
127
147
  if entity is None:
128
148
  return None
149
+ self._ensure_generic_key(entity.scope, entity.key)
129
150
  changes = data.model_dump(exclude_unset=True)
130
151
  drop_placeholder_write(entity, changes)
131
152
  for field, value in changes.items():
@@ -142,6 +163,7 @@ class SettingService(SettingListing):
142
163
  key: str,
143
164
  data: SettingUpsert,
144
165
  ) -> SettingOut:
166
+ self._ensure_generic_key(scope, key)
145
167
  entity = await self._find(scope, scope_id, key)
146
168
  if entity is None:
147
169
  entity = Setting(
@@ -194,9 +216,11 @@ class SettingService(SettingListing):
194
216
 
195
217
  async def _remove(self, entity: Setting, as_owner: bool) -> None:
196
218
  if not as_owner:
219
+ # A managed key's own clear route is the more specific refusal.
197
220
  await ensure_deletable(
198
221
  self.registry, self.tenant_is_live, entity.scope, entity.scope_id, entity.key
199
222
  )
223
+ self._ensure_generic_key(entity.scope, entity.key)
200
224
  self._changed(entity)
201
225
  await self.db.delete(entity)
202
226
  await self.db.flush()