simple-module-settings 0.0.34__tar.gz → 0.0.36__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 (103) hide show
  1. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/PKG-INFO +4 -4
  2. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/package.json +2 -2
  3. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/pyproject.toml +4 -4
  4. simple_module_settings-0.0.36/settings/_announce.py +36 -0
  5. simple_module_settings-0.0.36/settings/_listing.py +121 -0
  6. simple_module_settings-0.0.36/settings/_managed_keys.py +59 -0
  7. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/_module_settings_props.py +23 -1
  8. simple_module_settings-0.0.36/settings/_unique_write.py +29 -0
  9. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/cli.py +20 -3
  10. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/constants.py +23 -1
  11. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/contracts/__init__.py +2 -1
  12. simple_module_settings-0.0.36/settings/contracts/invalidation.py +39 -0
  13. simple_module_settings-0.0.36/settings/contracts/registry.py +121 -0
  14. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/deps.py +10 -1
  15. simple_module_settings-0.0.36/settings/endpoints/_create_form.py +34 -0
  16. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/endpoints/api.py +36 -2
  17. simple_module_settings-0.0.36/settings/endpoints/tenant_api.py +88 -0
  18. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/endpoints/views.py +21 -33
  19. simple_module_settings-0.0.36/settings/errors.py +20 -0
  20. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/hydrate.py +23 -4
  21. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/locales/en.json +2 -0
  22. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/module.py +17 -0
  23. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/Browse.tsx +5 -2
  24. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/Create.tsx +1 -1
  25. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/Edit.tsx +1 -1
  26. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/ModulesEdit.tsx +16 -6
  27. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/ModuleForm.tsx +3 -3
  28. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/StoreTable.tsx +8 -1
  29. simple_module_settings-0.0.36/settings/service.py +216 -0
  30. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/store.py +42 -10
  31. simple_module_settings-0.0.36/settings/tenant_scope.py +130 -0
  32. simple_module_settings-0.0.36/settings/tenant_view.py +82 -0
  33. simple_module_settings-0.0.36/tests/test_clear_route_per_scope.py +64 -0
  34. simple_module_settings-0.0.36/tests/test_cli_import.py +108 -0
  35. simple_module_settings-0.0.36/tests/test_duplicate_and_race.py +113 -0
  36. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_hydrate.py +26 -0
  37. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_api.py +12 -0
  38. simple_module_settings-0.0.36/tests/test_tenant_settings.py +255 -0
  39. simple_module_settings-0.0.36/tests/test_upload_key_delete.py +152 -0
  40. simple_module_settings-0.0.36/tests-js/ModulesEdit.test.tsx +86 -0
  41. simple_module_settings-0.0.34/settings/contracts/registry.py +0 -66
  42. simple_module_settings-0.0.34/settings/service.py +0 -270
  43. simple_module_settings-0.0.34/tests/test_cli_import.py +0 -52
  44. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/.gitignore +0 -0
  45. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/LICENSE +0 -0
  46. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/README.md +0 -0
  47. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/__init__.py +0 -0
  48. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/_field_meta.py +0 -0
  49. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/_module_settings.py +0 -0
  50. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/_row_masking.py +0 -0
  51. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/_secrets.py +0 -0
  52. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/browse_query.py +0 -0
  53. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/contracts/accessor.py +0 -0
  54. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/contracts/events.py +0 -0
  55. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/contracts/schemas.py +0 -0
  56. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/endpoints/__init__.py +0 -0
  57. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/endpoints/module_api.py +0 -0
  58. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/env_vars.py +0 -0
  59. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/known_keys.py +0 -0
  60. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/models.py +0 -0
  61. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/module_registry.py +0 -0
  62. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/FieldInput.tsx +0 -0
  63. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/FieldSource.tsx +0 -0
  64. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/KeyField.tsx +0 -0
  65. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/ModuleFieldRow.tsx +0 -0
  66. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/ResolvedValue.tsx +0 -0
  67. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/ScopeTabs.tsx +0 -0
  68. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/StoreCards.tsx +0 -0
  69. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/TestConnectionButton.tsx +0 -0
  70. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/ValueInput.tsx +0 -0
  71. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/last-test.ts +0 -0
  72. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/routes.ts +0 -0
  73. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/types.ts +0 -0
  74. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/py.typed +0 -0
  75. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/registration.py +0 -0
  76. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/reload.py +0 -0
  77. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/services.py +0 -0
  78. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/settings.py +0 -0
  79. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_concurrent_overrides.py +0 -0
  80. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_known_keys_meta.py +0 -0
  81. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_module_api.py +0 -0
  82. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_module_registry.py +0 -0
  83. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_module_settings.py +0 -0
  84. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_module_settings_render.py +0 -0
  85. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_module_settings_serialize.py +0 -0
  86. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_registration.py +0 -0
  87. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_reload.py +0 -0
  88. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_accessor.py +0 -0
  89. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_events.py +0 -0
  90. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_field_sources.py +0 -0
  91. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_module.py +0 -0
  92. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_schemas.py +0 -0
  93. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_secret_masking.py +0 -0
  94. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_secret_urls.py +0 -0
  95. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_service.py +0 -0
  96. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_view_authz.py +0 -0
  97. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_store.py +0 -0
  98. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_store_filters.py +0 -0
  99. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_store_secret_masking.py +0 -0
  100. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_testable_checks.py +0 -0
  101. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests-js/ModuleForm.test.tsx +0 -0
  102. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests-js/ScopeTabs.test.tsx +0 -0
  103. {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_settings
3
- Version: 0.0.34
3
+ Version: 0.0.36
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.34
25
- Requires-Dist: simple-module-db==0.0.34
26
- Requires-Dist: simple-module-hosting==0.0.34
24
+ Requires-Dist: simple-module-core==0.0.36
25
+ Requires-Dist: simple-module-db==0.0.36
26
+ Requires-Dist: simple-module-hosting==0.0.36
27
27
  Requires-Dist: typer>=0.12
28
28
  Description-Content-Type: text/markdown
29
29
 
@@ -6,13 +6,13 @@
6
6
  "peerDependencies": {
7
7
  "react": "^19.0.0",
8
8
  "react-dom": "^19.0.0",
9
- "@inertiajs/react": "^2.0.0",
9
+ "@inertiajs/react": "^3.7.0",
10
10
  "@simple-module-py/ui": "*"
11
11
  },
12
12
  "devDependencies": {
13
13
  "@simple-module-py/tsconfig": "*"
14
14
  },
15
15
  "dependencies": {
16
- "sonner": "^2.0.7"
16
+ "sonner": "^2.0.8"
17
17
  }
18
18
  }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_settings"
3
- version = "0.0.34"
3
+ version = "0.0.36"
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.34",
25
- "simple_module_db==0.0.34",
26
- "simple_module_hosting==0.0.34",
24
+ "simple_module_core==0.0.36",
25
+ "simple_module_db==0.0.36",
26
+ "simple_module_hosting==0.0.36",
27
27
  "typer>=0.12",
28
28
  ]
29
29
 
@@ -0,0 +1,36 @@
1
+ """Publish a per-(tenant, key) invalidation after a settings write commits.
2
+
3
+ Kept out of ``service.py`` (and its 300-line budget). See
4
+ ``settings.contracts.invalidation`` for the key format.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from simple_module_db.callbacks import register_on_commit
12
+
13
+ from settings.constants import INVALIDATION_CHANNEL, SCOPE_SYSTEM, SCOPE_TENANT
14
+ from settings.contracts.invalidation import invalidation_key
15
+
16
+ if TYPE_CHECKING:
17
+ from simple_module_core.invalidation import InvalidationBus
18
+ from sqlalchemy.ext.asyncio import AsyncSession
19
+
20
+
21
+ def announce(
22
+ db: AsyncSession, bus: InvalidationBus | None, scope: str, scope_id: str, key: str
23
+ ) -> None:
24
+ """Queue the notice for this session's commit; user-scope writes have no consumer.
25
+
26
+ After commit, not inline: a consumer that drops its entry before the row is
27
+ durable re-reads the *old* value and caches it for a full TTL.
28
+ """
29
+ if bus is None or scope not in (SCOPE_SYSTEM, SCOPE_TENANT):
30
+ return
31
+ wire_key = invalidation_key(scope_id if scope == SCOPE_TENANT else None, key)
32
+
33
+ async def publish() -> None:
34
+ await bus.publish(INVALIDATION_CHANNEL, key=wire_key)
35
+
36
+ register_on_commit(db, publish)
@@ -0,0 +1,121 @@
1
+ """Read-side listing queries of :class:`SettingService`, split out for size."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from simple_module_db import LIKE_ESCAPE_CHAR, like_contains_pattern
6
+ from sqlalchemy import func, select
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from settings._row_masking import out
10
+ from settings.constants import (
11
+ ALL_SCOPES,
12
+ DEFAULT_PER_PAGE,
13
+ SCOPE_ALL,
14
+ SYSTEM_SCOPE_ID,
15
+ )
16
+ from settings.contracts.schemas import SettingOut, SettingScope
17
+ from settings.models import Setting
18
+
19
+
20
+ class SettingListing:
21
+ """Mixin: the ``list_*`` / ``count_by_scope`` queries. Needs ``self.db``."""
22
+
23
+ db: AsyncSession
24
+
25
+ # ── Listing ─────────────────────────────────────────────────────
26
+
27
+ async def list_all(self) -> list[SettingOut]:
28
+ result = await self.db.execute(
29
+ select(Setting).order_by(Setting.scope, Setting.scope_id, Setting.key)
30
+ )
31
+ return [out(row) for row in result.scalars()]
32
+
33
+ async def list_filtered(
34
+ self,
35
+ scope: SettingScope | None = None,
36
+ q: str | None = None,
37
+ page: int = 1,
38
+ per_page: int = DEFAULT_PER_PAGE,
39
+ ) -> tuple[list[SettingOut], int]:
40
+ """One page of rows plus the unpaged total for the same filters.
41
+
42
+ The browse screen used to receive every row and filter in the browser,
43
+ which made the payload, the render and find-in-page all scale with the
44
+ whole table instead of with what was asked for. ``q`` matches the key
45
+ only — the search box says "Search keys…", and quietly matching values
46
+ would surface rows whose key has nothing to do with the query.
47
+ """
48
+ conditions = self._filter_conditions(scope, q)
49
+ total = await self.db.scalar(select(func.count()).select_from(Setting).where(*conditions))
50
+ stmt = (
51
+ select(Setting)
52
+ .where(*conditions)
53
+ .order_by(Setting.scope, Setting.scope_id, Setting.key)
54
+ .offset(max(page - 1, 0) * per_page)
55
+ .limit(per_page)
56
+ )
57
+ result = await self.db.execute(stmt)
58
+ return [out(row) for row in result.scalars()], int(total or 0)
59
+
60
+ async def count_by_scope(self, q: str | None = None) -> dict[str, int]:
61
+ """Per-scope tallies for the filter tabs, plus ``all``.
62
+
63
+ Every scope is named even at zero: a tab that disappears when its count
64
+ drops to nothing moves the other tabs under the cursor mid-search.
65
+ The scope filter itself is deliberately not applied — the tabs describe
66
+ what each of them *would* show, so selecting one must not zero the rest.
67
+ """
68
+ conditions = self._filter_conditions(None, q)
69
+ stmt = select(Setting.scope, func.count()).where(*conditions).group_by(Setting.scope)
70
+ result = await self.db.execute(stmt)
71
+ tallies = {str(scope): int(count) for scope, count in result.all()}
72
+ counts = {name: tallies.get(name, 0) for name in ALL_SCOPES}
73
+ return {SCOPE_ALL: sum(counts.values()), **counts}
74
+
75
+ @staticmethod
76
+ def _filter_conditions(scope: SettingScope | None, q: str | None) -> list:
77
+ conditions = []
78
+ if scope is not None:
79
+ conditions.append(Setting.scope == scope.value)
80
+ needle = (q or "").strip()
81
+ if needle:
82
+ # Setting keys are full of underscores, and `_` is a LIKE wildcard:
83
+ # unescaped, a search for "smtp_host" also matches "smtpXhost", and
84
+ # a stray "%" matches the entire table. ``ilike`` is emulated by
85
+ # SQLAlchemy on SQLite (lower() on both sides), so one expression
86
+ # is case-insensitive on both databases.
87
+ conditions.append(
88
+ Setting.key.ilike(like_contains_pattern(needle), escape=LIKE_ESCAPE_CHAR)
89
+ )
90
+ return conditions
91
+
92
+ async def list_by_scope(
93
+ self, scope: SettingScope, scope_id: str = SYSTEM_SCOPE_ID
94
+ ) -> list[SettingOut]:
95
+ result = await self.db.execute(self._scope_stmt(scope, scope_id))
96
+ return [out(row) for row in result.scalars()]
97
+
98
+ async def list_by_scope_unmasked(
99
+ self, scope: SettingScope, scope_id: str = SYSTEM_SCOPE_ID
100
+ ) -> list[SettingOut]:
101
+ """The same rows with their real values, for code that *applies* them.
102
+
103
+ The masking in :func:`_out` is for the screens. Hydration is not a
104
+ screen: ``SettingsStore`` feeds these values back into the live module
105
+ settings objects at boot, so a masked read writes a row of dots over the
106
+ real secret — a mailer that cannot authenticate, and a
107
+ ``reset_password_token_secret`` that no longer verifies the tokens it
108
+ signed. This is the one read that must see through the mask, and it is
109
+ spelled out rather than reached by passing a flag so that every caller
110
+ of it is one grep away.
111
+ """
112
+ result = await self.db.execute(self._scope_stmt(scope, scope_id))
113
+ return [SettingOut.model_validate(row) for row in result.scalars()]
114
+
115
+ @staticmethod
116
+ def _scope_stmt(scope: SettingScope, scope_id: str):
117
+ return (
118
+ select(Setting)
119
+ .where(Setting.scope == scope.value, Setting.scope_id == scope_id)
120
+ .order_by(Setting.key)
121
+ )
@@ -0,0 +1,59 @@
1
+ """Rows of a key set by upload are not deleted by a bare row delete.
2
+
3
+ A key declaring ``SettingDefinition.clear_via`` is a file the owner's upload
4
+ route stores and reaps. Deleting the row any other way leaves the stored file
5
+ behind, so :meth:`SettingService.delete` / ``delete_scoped`` refuse it with
6
+ :class:`ManagedKeyError` **for every scope** — the rule lives in the service so
7
+ a route (or a module calling the service directly) cannot forget it.
8
+
9
+ Two ways through, both deliberate:
10
+
11
+ * the owner passes ``as_owner=True`` after taking responsibility for the file
12
+ (branding's clear routes reap it);
13
+ * a TENANT row whose tenant no longer exists has no live owner left to clear
14
+ it, so a platform operator may delete it by hand. SYSTEM and USER rows get no
15
+ such exception: a SYSTEM row always has a live owner (the platform's own
16
+ upload route), and a USER row of a managed key is cleared through the owner
17
+ too. The guard is only as wide as the registry the service was built with;
18
+ a service built without one (``SettingService(db)``) guards nothing.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Awaitable, Callable
24
+
25
+ from settings.constants import ERR_MANAGED_KEY_DELETE
26
+ from settings.contracts.registry import SettingsRegistry, clear_route
27
+ from settings.contracts.schemas import SettingScope
28
+
29
+ TenantIsLive = Callable[[str], Awaitable[bool]]
30
+
31
+
32
+ class ManagedKeyError(Exception):
33
+ """The key is cleared through ``clear_via``, not by deleting its row."""
34
+
35
+ def __init__(self, key: str, clear_via: str) -> None:
36
+ self.key = key
37
+ self.clear_via = clear_via
38
+ super().__init__(ERR_MANAGED_KEY_DELETE.format(clear_via=clear_via))
39
+
40
+
41
+ async def ensure_deletable(
42
+ registry: SettingsRegistry | None,
43
+ tenant_is_live: TenantIsLive | None,
44
+ scope: str,
45
+ scope_id: str,
46
+ key: str,
47
+ ) -> None:
48
+ """Raise :class:`ManagedKeyError` unless the row may be deleted generically."""
49
+ definition = registry.get(key) if registry is not None else None
50
+ if definition is None or not definition.clear_via:
51
+ return
52
+ orphaned = (
53
+ scope == SettingScope.TENANT.value
54
+ and tenant_is_live is not None
55
+ and not await tenant_is_live(scope_id)
56
+ )
57
+ if orphaned:
58
+ return
59
+ raise ManagedKeyError(key, clear_route(definition, scope))
@@ -9,9 +9,10 @@ from __future__ import annotations
9
9
 
10
10
  from typing import Any
11
11
 
12
+ from fastapi import FastAPI
12
13
  from fastapi.encoders import jsonable_encoder
13
14
 
14
- from settings._module_settings import ModuleSettingsView
15
+ from settings._module_settings import ModuleSettingsView, _package_of
15
16
 
16
17
 
17
18
  def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
@@ -54,3 +55,24 @@ def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
54
55
  }
55
56
  for v in views
56
57
  ]
58
+
59
+
60
+ def testable_packages(app: FastAPI) -> dict[str, list[str]]:
61
+ """Package -> the names of the health checks its module registered.
62
+
63
+ "Test connection" is just that module's health checks run on demand —
64
+ reusing the registry means settings never learns what an SMTP or an S3
65
+ connection is. The names come back with the packages so the button can say
66
+ what it is about to dial ("Test mailer connection") instead of the useless
67
+ "Test connection" a bare package list can produce.
68
+ """
69
+ checks_by_owner: dict[str, list[str]] = {}
70
+ for check in app.state.sm.health_registry.all_checks:
71
+ if check.module:
72
+ checks_by_owner.setdefault(check.module, []).append(check.name)
73
+
74
+ return {
75
+ _package_of(mod): sorted(checks_by_owner[mod.meta.name])
76
+ for mod in getattr(app.state.sm, "modules", ())
77
+ if mod.meta.name in checks_by_owner
78
+ }
@@ -0,0 +1,29 @@
1
+ """Race-safe inserts for the (scope, scope_id, key) unique key.
2
+
3
+ A read-then-insert cannot be made safe by reading harder: two requests can both
4
+ see "no row" and both insert. The unique constraint is the arbiter, so the
5
+ insert runs in a savepoint — a loser's ``IntegrityError`` rolls back only the
6
+ savepoint, leaving the request's transaction (and its session) usable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from sqlalchemy.exc import IntegrityError
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+
14
+ from settings.models import Setting
15
+
16
+
17
+ class DuplicateSettingError(Exception):
18
+ """A row with this (scope, scope_id, key) already exists."""
19
+
20
+
21
+ async def insert_if_free(db: AsyncSession, entity: Setting) -> bool:
22
+ """Insert ``entity``; ``False`` when its (scope, scope_id, key) is already taken."""
23
+ try:
24
+ async with db.begin_nested():
25
+ db.add(entity)
26
+ await db.flush()
27
+ except IntegrityError:
28
+ return False
29
+ return True
@@ -9,9 +9,11 @@ from __future__ import annotations
9
9
 
10
10
  import asyncio
11
11
  import os
12
+ from collections.abc import Mapping
12
13
 
13
14
  import typer
14
15
  from fastapi import FastAPI
16
+ from simple_module_core.dotenv import find_env_file, parse_dotenv
15
17
 
16
18
  from settings.constants import MODULE_PACKAGE
17
19
  from settings.env_vars import env_prefix_for
@@ -21,14 +23,24 @@ from settings.store import SettingsStore
21
23
  app = typer.Typer(help="Settings module administration.", no_args_is_help=True)
22
24
 
23
25
 
24
- async def import_from_env_impl(app_inst: FastAPI, store: SettingsStore) -> int:
25
- """Write a SYSTEM override for every ``SM_<PREFIX>_<FIELD>`` env var set."""
26
+ async def import_from_env_impl(
27
+ app_inst: FastAPI,
28
+ store: SettingsStore,
29
+ environ: Mapping[str, str] | None = None,
30
+ ) -> int:
31
+ """Write a SYSTEM override for every ``SM_<PREFIX>_<FIELD>`` var set.
32
+
33
+ Default source is the project ``.env`` overlaid by the process environment
34
+ (real env wins). ``os.environ`` is never mutated.
35
+ """
36
+ if environ is None:
37
+ environ = {**parse_dotenv(find_env_file()), **os.environ}
26
38
  registry = getattr(app_inst.state, MODULE_PACKAGE).module_registry
27
39
  count = 0
28
40
  for package, cls in registry.items():
29
41
  prefix = env_prefix_for(package)
30
42
  for field_name in cls.model_fields:
31
- raw = os.environ.get(f"{prefix}{field_name.upper()}")
43
+ raw = environ.get(f"{prefix}{field_name.upper()}")
32
44
  if raw is None:
33
45
  continue
34
46
  vtype = value_type_for_field(cls, field_name)
@@ -56,6 +68,11 @@ def import_from_env() -> None:
56
68
  n = await import_from_env_impl(fastapi_app, store)
57
69
  await session.commit()
58
70
  typer.echo(f"Imported {n} override(s) from environment.")
71
+ if n == 0 and find_env_file().is_file():
72
+ typer.echo(
73
+ "Hint: variables must be named SM_<MODULE>_<FIELD> "
74
+ "(e.g. SM_USERS_SMTP_PORT); none in the environment or .env matched."
75
+ )
59
76
  return 0
60
77
 
61
78
  raise typer.Exit(code=asyncio.run(run()))
@@ -70,6 +70,11 @@ API_RESOLVE_PATH: Final = "/resolve/{key}"
70
70
  API_SYSTEM_PATH: Final = "/system/{key}"
71
71
  API_TENANT_PATH: Final = "/tenant/{scope_id}/{key}"
72
72
  API_USER_PATH: Final = "/user/{scope_id}/{key}"
73
+ # The active tenant's own overrides (#382). The tenant comes from
74
+ # ``request.state.tenant_id`` — never from the URL — so "current" is a literal
75
+ # segment, registered ahead of ``API_TENANT_PATH`` so it is not read as an id.
76
+ API_TENANT_CURRENT_PATH: Final = "/tenant/current"
77
+ API_TENANT_CURRENT_KEY_PATH: Final = "/tenant/current/{key}"
73
78
 
74
79
  # ── Menu ─────────────────────────────────────────────────────────────
75
80
  MENU_LABEL: Final = MODULE_NAME
@@ -85,7 +90,17 @@ PERM_VIEW: Final = "settings.view"
85
90
  PERM_CREATE: Final = "settings.create"
86
91
  PERM_EDIT: Final = "settings.edit"
87
92
  PERM_DELETE: Final = "settings.delete"
88
- ALL_PERMISSIONS: Final = (PERM_VIEW, PERM_CREATE, PERM_EDIT, PERM_DELETE)
93
+ # Edit the *active* tenant's overrides of ``tenant_overridable`` keys — and
94
+ # nothing else. Mapped onto ``tenant:owner`` / ``tenant:admin``; the four
95
+ # above stay platform-operator permissions (system scope, any tenant's scope).
96
+ PERM_TENANT_EDIT: Final = "settings.tenant.edit"
97
+ ALL_PERMISSIONS: Final = (PERM_VIEW, PERM_CREATE, PERM_EDIT, PERM_DELETE, PERM_TENANT_EDIT)
98
+
99
+ # ── Cache invalidation ───────────────────────────────────────────────
100
+ # Published (after commit) for every SYSTEM / TENANT write, keyed per
101
+ # (tenant, key) — see ``settings.contracts.invalidation``. Settings caches
102
+ # nothing itself; consumers holding per-tenant resolved values subscribe.
103
+ INVALIDATION_CHANNEL: Final = "settings.values"
89
104
 
90
105
  # ── Database ─────────────────────────────────────────────────────────
91
106
  DB_SCHEMA: Final = MODULE_PACKAGE
@@ -130,16 +145,23 @@ MAX_PER_PAGE: Final = 200
130
145
  # ── User-facing error messages ───────────────────────────────────────
131
146
  ERR_SETTING_NOT_FOUND: Final = "Setting not found"
132
147
  ERR_KEY_ALREADY_EXISTS: Final = "Setting key already exists"
148
+ ERR_SETTING_EXISTS: Final = "A setting with this scope and key already exists"
133
149
  ERR_SYSTEM_SCOPE_NO_ID: Final = "system scope must not have a scope_id"
134
150
  ERR_SCOPED_REQUIRES_ID: Final = "tenant/user scope requires a scope_id"
135
151
  ERR_UNKNOWN_SCOPE: Final = "unknown scope"
136
152
  ERR_VALUE_MISMATCH: Final = "value does not parse as declared value_type"
153
+ ERR_UNKNOWN_TENANT: Final = "Unknown tenant"
154
+ ERR_NO_ACTIVE_TENANT: Final = "No active organisation for this request"
155
+ ERR_MANAGED_KEY_DELETE: Final = "This setting is managed elsewhere; clear it at {clear_via}"
156
+ ERR_NOT_TENANT_OVERRIDABLE: Final = "This setting cannot be changed per organisation"
137
157
 
138
158
  # ── HTTP ─────────────────────────────────────────────────────────────
139
159
  STATUS_CREATED: Final = 201
140
160
  STATUS_NO_CONTENT: Final = 204
161
+ STATUS_FORBIDDEN: Final = 403
141
162
  STATUS_NOT_FOUND: Final = 404
142
163
  STATUS_CONFLICT: Final = 409
164
+ STATUS_UNPROCESSABLE: Final = 422
143
165
 
144
166
  # ── Query parameter names ────────────────────────────────────────────
145
167
  QP_USER_ID: Final = "user_id"
@@ -1,7 +1,7 @@
1
1
  """Settings contracts — public interface for other modules."""
2
2
 
3
3
  from settings.contracts.accessor import SettingsAccessor
4
- from settings.contracts.registry import SettingDefinition, SettingsRegistry
4
+ from settings.contracts.registry import SettingDefinition, SettingsRegistry, clear_route
5
5
  from settings.contracts.schemas import (
6
6
  SettingCreate,
7
7
  SettingOut,
@@ -21,4 +21,5 @@ __all__ = [
21
21
  "SettingValueType",
22
22
  "SettingsAccessor",
23
23
  "SettingsRegistry",
24
+ "clear_route",
24
25
  ]
@@ -0,0 +1,39 @@
1
+ """Per-(tenant, key) invalidation notices for settings writes (#382).
2
+
3
+ ``settings`` keeps no cache of its own, but consumers resolving a value per
4
+ tenant do (branding's per-tenant theme). Every SYSTEM / TENANT write publishes
5
+ on :data:`INVALIDATION_CHANNEL` after commit, with the key built here:
6
+
7
+ * ``"<tenant_id>|<key>"`` — one tenant's override of ``key`` changed;
8
+ * ``"|<key>"`` — the system value changed, so every tenant's view of ``key``
9
+ may have (tenants without an override inherit it).
10
+
11
+ ``|`` cannot appear in a tenant id (``simple_module_db.TENANT_ID_PATTERN``), so
12
+ the first one always separates the two halves. Handlers may only forget.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from settings.constants import INVALIDATION_CHANNEL
18
+
19
+ _SEP = "|"
20
+
21
+
22
+ def invalidation_key(tenant_id: str | None, key: str) -> str:
23
+ """The wire key for a write of ``key`` at ``tenant_id`` (``None`` = system)."""
24
+ return f"{tenant_id or ''}{_SEP}{key}"
25
+
26
+
27
+ def parse_invalidation_key(raw: str | None) -> tuple[str | None, str | None]:
28
+ """``(tenant_id, key)``; ``tenant_id`` is ``None`` for a system write.
29
+
30
+ ``(None, None)`` for a whole-channel clear (``raw is None``) or a key this
31
+ module did not build — both mean "forget everything".
32
+ """
33
+ if raw is None or _SEP not in raw:
34
+ return None, None
35
+ tenant_id, _, key = raw.partition(_SEP)
36
+ return (tenant_id or None), key
37
+
38
+
39
+ __all__ = ["INVALIDATION_CHANNEL", "invalidation_key", "parse_invalidation_key"]
@@ -0,0 +1,121 @@
1
+ """Registry of declared setting keys — lets modules advertise the keys they
2
+ read so admins can see every knob (and its default) in one place.
3
+
4
+ Usage from a consumer module's ``on_startup`` hook:
5
+
6
+ def on_startup(self, app):
7
+ app.state.settings.registry.add(
8
+ SettingDefinition(
9
+ key="orders.checkout.require_terms",
10
+ default="true",
11
+ description="Show the terms-and-conditions checkbox on checkout.",
12
+ )
13
+ )
14
+
15
+ The registry doesn't write anything to the database — it only records
16
+ intent. ``SettingsAccessor.get`` falls back to the registered default when
17
+ no row exists at any scope. ``get_bool`` / ``get_int`` / ``get_json`` cast
18
+ the stored string representation on the way out.
19
+
20
+ API mirrors the other framework registries (MenuRegistry / PermissionRegistry
21
+ / FeatureFlagRegistry): ``add(definition)`` + ``all_definitions``. Unlike
22
+ FeatureFlagRegistry, duplicate keys raise — settings carry defaults and a
23
+ second registration almost always means two owners contended for the same key.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from collections.abc import Awaitable, Callable, Mapping
29
+ from dataclasses import dataclass, field
30
+ from typing import TYPE_CHECKING
31
+
32
+ from settings.constants import ERR_KEY_ALREADY_EXISTS
33
+ from settings.contracts.schemas import SettingScope, SettingValueType
34
+
35
+ if TYPE_CHECKING:
36
+ from starlette.requests import Request
37
+
38
+ TenantValueCheck = Callable[["Request", str, str], Awaitable[None]]
39
+ """``async (request, tenant_id, value)`` run before a TENANT-scope write.
40
+
41
+ Raise ``ValueError`` to reject the value (422) or ``LookupError`` when it names
42
+ something the tenant does not have (404) — e.g. a file id owned by another
43
+ tenant. ``tenant_id`` is the scope being written, which on the platform routes
44
+ is not the caller's own active tenant."""
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class SettingDefinition:
49
+ """Declared metadata for a setting key.
50
+
51
+ ``tenant_overridable`` opts the key into tenant self-service (#382): a
52
+ tenant owner/admin may write it for their *own* active tenant through
53
+ ``/api/settings/tenant/current/{key}``. Keys without it are writable at
54
+ tenant scope only by platform operators (``settings.edit``). ``check``
55
+ vets every TENANT-scope write of the key, from either surface.
56
+ ``upload_url`` marks a key whose value is a file id set by uploading: the
57
+ tenant settings page POSTs the file there and DELETEs it to clear, instead
58
+ of offering a text box for an opaque id. ``description_key`` is the i18n
59
+ key the tenant settings page translates ``description`` with (the English
60
+ ``description`` stays the fallback when the key is missing or unset).
61
+ ``clear_via`` names the route that owns clearing the key (e.g. an upload
62
+ route that also reaps the stored file): the generic DELETE routes answer
63
+ 422 pointing there while the tenant exists. A platform operator may still
64
+ delete the row left behind by a tenant that no longer exists. A key whose
65
+ system and tenant rows are cleared through different routes passes a
66
+ ``{SettingScope: route}`` mapping; read it with :func:`clear_route`.
67
+ """
68
+
69
+ key: str
70
+ default: str = ""
71
+ description: str = ""
72
+ scope: SettingScope = SettingScope.SYSTEM
73
+ value_type: SettingValueType = SettingValueType.STRING
74
+ tenant_overridable: bool = False
75
+ check: TenantValueCheck | None = field(default=None, compare=False)
76
+ clear_via: str | Mapping[SettingScope, str] = ""
77
+ upload_url: str = ""
78
+ description_key: str = ""
79
+
80
+
81
+ def clear_route(definition: SettingDefinition, scope: SettingScope | str) -> str:
82
+ """The route that clears ``definition`` at ``scope`` ("" if unmanaged).
83
+
84
+ A scope the mapping does not name falls back to its first route, so the
85
+ refusal still points somewhere rather than at nothing.
86
+ """
87
+ via = definition.clear_via
88
+ if isinstance(via, str):
89
+ return via
90
+ if not via:
91
+ return ""
92
+ return via.get(SettingScope(scope)) or next(iter(via.values()))
93
+
94
+
95
+ @dataclass(slots=True)
96
+ class SettingsRegistry:
97
+ """In-memory registry of declared setting keys. Populated at module boot
98
+ so admins (and other modules) can discover every knob the app exposes.
99
+ """
100
+
101
+ _defs: dict[str, SettingDefinition] = field(default_factory=dict)
102
+
103
+ def add(self, definition: SettingDefinition) -> None:
104
+ if definition.key in self._defs:
105
+ raise ValueError(f"{ERR_KEY_ALREADY_EXISTS}: {definition.key!r}")
106
+ self._defs[definition.key] = definition
107
+
108
+ def get(self, key: str) -> SettingDefinition | None:
109
+ return self._defs.get(key)
110
+
111
+ @property
112
+ def all_definitions(self) -> list[SettingDefinition]:
113
+ return list(self._defs.values())
114
+
115
+ @property
116
+ def tenant_overridable(self) -> list[SettingDefinition]:
117
+ """Definitions a tenant may override for itself, sorted by key."""
118
+ return sorted((d for d in self._defs.values() if d.tenant_overridable), key=lambda d: d.key)
119
+
120
+ def __contains__(self, key: str) -> bool:
121
+ return key in self._defs
@@ -18,12 +18,21 @@ from settings.constants import MODULE_PACKAGE
18
18
  from settings.contracts.accessor import SettingsAccessor
19
19
  from settings.contracts.registry import SettingsRegistry
20
20
  from settings.service import SettingService
21
+ from settings.tenant_scope import is_known_tenant, registry_of
21
22
 
22
23
 
23
24
  async def get_setting_service(
25
+ request: Request,
24
26
  db: AsyncSession = Depends(get_db),
25
27
  ) -> SettingService:
26
- return SettingService(db)
28
+ bus = getattr(getattr(request.app.state, "sm", None), "invalidation", None)
29
+
30
+ async def tenant_is_live(tenant_id: str) -> bool:
31
+ return await is_known_tenant(request, tenant_id)
32
+
33
+ return SettingService(
34
+ db, invalidation=bus, registry=registry_of(request), tenant_is_live=tenant_is_live
35
+ )
27
36
 
28
37
 
29
38
  def get_settings_registry(request: Request) -> SettingsRegistry: