simple-module-settings 0.0.40__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.40 → simple_module_settings-0.0.42}/PKG-INFO +4 -4
  2. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/package.json +4 -4
  3. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/pyproject.toml +4 -4
  4. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/_managed_keys.py +9 -1
  5. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/constants.py +1 -0
  6. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/deps.py +10 -0
  7. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/endpoints/_create_form.py +3 -0
  8. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/endpoints/api.py +16 -16
  9. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/endpoints/views.py +14 -9
  10. {simple_module_settings-0.0.40 → 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.40 → simple_module_settings-0.0.42}/settings/pages/components/FieldSource.tsx +3 -3
  13. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/components/ModuleFieldRow.tsx +11 -11
  14. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/components/ModuleForm.tsx +31 -4
  15. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/service.py +25 -1
  16. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_known_keys_meta.py +21 -53
  17. simple_module_settings-0.0.42/tests/test_settings_generic_module_guard.py +109 -0
  18. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_secret_urls.py +11 -11
  19. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests-js/ModuleForm.test.tsx +1 -1
  20. simple_module_settings-0.0.42/tests-js/settings_ModuleFieldLayout.test.tsx +62 -0
  21. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests-js/settings_ModuleFormRevert.test.tsx +62 -0
  22. simple_module_settings-0.0.40/settings/known_keys.py +0 -86
  23. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/.gitignore +0 -0
  24. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/LICENSE +0 -0
  25. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/README.md +0 -0
  26. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/__init__.py +0 -0
  27. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/_announce.py +0 -0
  28. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/_field_meta.py +0 -0
  29. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/_listing.py +0 -0
  30. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/_module_settings.py +0 -0
  31. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/_module_settings_props.py +0 -0
  32. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/_row_masking.py +0 -0
  33. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/_secrets.py +0 -0
  34. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/_unique_write.py +0 -0
  35. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/browse_query.py +0 -0
  36. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/cli.py +0 -0
  37. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/contracts/__init__.py +0 -0
  38. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/contracts/accessor.py +0 -0
  39. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/contracts/events.py +0 -0
  40. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/contracts/invalidation.py +0 -0
  41. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/contracts/registry.py +0 -0
  42. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/contracts/schemas.py +0 -0
  43. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/endpoints/__init__.py +0 -0
  44. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/endpoints/module_api.py +0 -0
  45. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/endpoints/tenant_api.py +0 -0
  46. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/env_vars.py +0 -0
  47. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/exceptions.py +0 -0
  48. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/hydrate.py +0 -0
  49. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/invalidation.py +0 -0
  50. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/locales/de.json +0 -0
  51. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/locales/en.json +0 -0
  52. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/models.py +0 -0
  53. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/module.py +0 -0
  54. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/module_registry.py +0 -0
  55. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/Browse.tsx +0 -0
  56. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/Create.tsx +0 -0
  57. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/Edit.tsx +0 -0
  58. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/ModulesEdit.tsx +0 -0
  59. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/api.generated.ts +0 -0
  60. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/api.ts +0 -0
  61. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/components/FieldInput.tsx +0 -0
  62. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/components/JsonInput.tsx +0 -0
  63. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/components/KeyField.tsx +0 -0
  64. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/components/ResolvedValue.tsx +0 -0
  65. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/components/ScopeTabs.tsx +0 -0
  66. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/components/StoreCards.tsx +0 -0
  67. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/components/StoreTable.tsx +0 -0
  68. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/components/TestConnectionButton.tsx +0 -0
  69. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/components/ValueInput.tsx +0 -0
  70. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/components/last-test.ts +0 -0
  71. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/components/validation-errors.test.ts +0 -0
  72. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/components/validation-errors.ts +0 -0
  73. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/routes.ts +0 -0
  74. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/pages/types.ts +0 -0
  75. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/py.typed +0 -0
  76. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/registration.py +0 -0
  77. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/reload.py +0 -0
  78. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/services.py +0 -0
  79. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/settings.py +0 -0
  80. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/store.py +0 -0
  81. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/tenant_scope.py +0 -0
  82. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/settings/tenant_view.py +0 -0
  83. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_clear_route_per_scope.py +0 -0
  84. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_cli_import.py +0 -0
  85. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_concurrent_overrides.py +0 -0
  86. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_duplicate_and_race.py +0 -0
  87. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_env_pinned.py +0 -0
  88. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_hydrate.py +0 -0
  89. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_hydrate_related_fields.py +0 -0
  90. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_lenient_related_fields_validators.py +0 -0
  91. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_module_api.py +0 -0
  92. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_module_api_json_fields.py +0 -0
  93. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_module_registry.py +0 -0
  94. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_module_settings.py +0 -0
  95. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_module_settings_render.py +0 -0
  96. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_module_settings_serialize.py +0 -0
  97. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_registration.py +0 -0
  98. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_reload.py +0 -0
  99. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_accessor.py +0 -0
  100. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_api.py +0 -0
  101. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_commit_invalidation.py +0 -0
  102. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_default_clears_override.py +0 -0
  103. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_domain_errors.py +0 -0
  104. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_env_unprefixed.py +0 -0
  105. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_events.py +0 -0
  106. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_field_sources.py +0 -0
  107. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_framework_reexports.py +0 -0
  108. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_group_metadata.py +0 -0
  109. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_host_restart_flags.py +0 -0
  110. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_module.py +0 -0
  111. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_schemas.py +0 -0
  112. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_secret_json_roundtrip.py +0 -0
  113. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_secret_masking.py +0 -0
  114. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_service.py +0 -0
  115. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_typed_secret_masking.py +0 -0
  116. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_settings_view_authz.py +0 -0
  117. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_store.py +0 -0
  118. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_store_filters.py +0 -0
  119. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_store_secret_masking.py +0 -0
  120. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_tenant_settings.py +0 -0
  121. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_testable_checks.py +0 -0
  122. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests/test_upload_key_delete.py +0 -0
  123. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests-js/JsonFields.test.tsx +0 -0
  124. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests-js/ModulesEdit.test.tsx +0 -0
  125. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests-js/RestartHint.test.tsx +0 -0
  126. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests-js/ScopeTabs.test.tsx +0 -0
  127. {simple_module_settings-0.0.40 → simple_module_settings-0.0.42}/tests-js/settings_TestConnectionButton.test.tsx +0 -0
  128. {simple_module_settings-0.0.40 → 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.40
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.40
25
- Requires-Dist: simple-module-db==0.0.40
26
- Requires-Dist: simple-module-hosting==0.0.40
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.40",
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.40"
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.40",
25
- "simple_module_db==0.0.40",
26
- "simple_module_hosting==0.0.40",
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)]
@@ -55,10 +55,10 @@ export function FieldSource({ field, onReset }: { field: FieldMeta; onReset: ()
55
55
  const showDefault = !isBlank(field.default) && String(field.default) !== String(field.value);
56
56
  return (
57
57
  // Wraps rather than clips: `SM_FILE_STORAGE_QUOTA_BYTES` has no space to
58
- // break at, so a 210px column cut it mid-word with nothing to say so.
59
- // `break-words` breaks only the tokens that do not fit.
58
+ // break at, and as an unbreakable token it would set the column's minimum
59
+ // width; `overflow-wrap:anywhere` lets it break inside the identifier.
60
60
  <span
61
- className="block break-words font-mono text-[11.5px] leading-snug text-muted-foreground"
61
+ className="block [overflow-wrap:anywhere] font-mono text-[11.5px] leading-snug text-muted-foreground"
62
62
  title={
63
63
  field.env_readable
64
64
  ? t(keys.settings.modules.env_var_hint)
@@ -16,10 +16,11 @@ interface Props {
16
16
  /**
17
17
  * One settings row: label, control, trailing meta.
18
18
  *
19
- * Three columns rather than a stacked label block, because the trailing column
20
- * is what makes the screen readable at a glance — every row answers "where is
21
- * this value from?" in the same place, so the exceptions stand out instead of
22
- * having to be hunted for.
19
+ * Three columns when the editor card has room, because the trailing column is
20
+ * what makes the screen readable at a glance — every row answers "where is
21
+ * this value from?" in the same place, so the exceptions stand out. The tracks
22
+ * follow the card's width (container query), not the viewport: two sidebars
23
+ * can leave a narrow card on a wide screen, and the row stacks instead.
23
24
  */
24
25
  export function ModuleFieldRow({
25
26
  field,
@@ -37,15 +38,14 @@ export function ModuleFieldRow({
37
38
  const showDescription = field.description && field.type !== 'bool';
38
39
 
39
40
  return (
40
- // `minmax(170px, auto)`: a fixed 170px track cannot grow, so a long field
41
- // name overflowed it and printed straight over the input beside it.
42
- <div className="grid gap-3 sm:grid-cols-[minmax(170px,auto)_1fr] lg:grid-cols-[minmax(170px,auto)_1fr_210px] lg:items-center">
41
+ // Query the editor card: two sidebars can leave little space at a wide viewport.
42
+ <div className="grid grid-cols-1 gap-3 @md:grid-cols-[minmax(0,140px)_minmax(180px,1fr)] @xl:grid-cols-[minmax(0,140px)_minmax(180px,1fr)_minmax(0,160px)] @xl:items-center">
43
43
  <div className="min-w-0">
44
- <label htmlFor={id} className="block break-words text-[13px] font-medium">
44
+ <label htmlFor={id} className="block [overflow-wrap:anywhere] text-[13px] font-medium">
45
45
  {field.name}
46
46
  </label>
47
47
  {showDescription && (
48
- <p className="mt-0.5 text-[11.5px] leading-snug text-muted-foreground">
48
+ <p className="mt-0.5 [overflow-wrap:anywhere] text-[11.5px] leading-snug text-muted-foreground">
49
49
  {field.description}
50
50
  </p>
51
51
  )}
@@ -65,7 +65,7 @@ export function ModuleFieldRow({
65
65
  </fieldset>
66
66
  </div>
67
67
 
68
- <div className="sm:col-span-2 lg:col-span-1 lg:justify-self-start">
68
+ <div className="min-w-0 [overflow-wrap:anywhere] @md:col-span-2 @xl:col-span-1 @xl:justify-self-start">
69
69
  <FieldSource field={field} onReset={onReset} />
70
70
  </div>
71
71
 
@@ -75,7 +75,7 @@ export function ModuleFieldRow({
75
75
  {error && (
76
76
  <p
77
77
  data-testid={`field-error-${field.name}`}
78
- className="break-words text-xs text-destructive sm:col-span-1 sm:col-start-2 lg:col-span-2"
78
+ className="min-w-0 [overflow-wrap:anywhere] text-xs text-destructive @md:col-span-1 @md:col-start-2 @xl:col-span-2"
79
79
  >
80
80
  {error}
81
81
  </p>
@@ -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,15 +136,32 @@ 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 (
137
- <div className="flex flex-1 flex-col gap-4">
164
+ <div className="flex min-w-0 flex-1 flex-col gap-4">
138
165
  {/* Outside the card: the heading names the pane, and the deck keeps the
139
166
  card for the fields alone. */}
140
167
  <header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
@@ -178,7 +205,7 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
178
205
  </div>
179
206
  )}
180
207
 
181
- <Card className="flex flex-1 flex-col gap-4 border-border p-6">
208
+ <Card className="@container flex min-w-0 flex-1 flex-col gap-4 border-border p-6">
182
209
  {/* No inner scroller: the card grows and the page scrolls, which is
183
210
  one scrollbar for the screen instead of three nested ones. */}
184
211
  <div className="flex flex-col gap-4">
@@ -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)}