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.
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/PKG-INFO +4 -4
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/package.json +2 -2
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/pyproject.toml +4 -4
- simple_module_settings-0.0.36/settings/_announce.py +36 -0
- simple_module_settings-0.0.36/settings/_listing.py +121 -0
- simple_module_settings-0.0.36/settings/_managed_keys.py +59 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/_module_settings_props.py +23 -1
- simple_module_settings-0.0.36/settings/_unique_write.py +29 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/cli.py +20 -3
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/constants.py +23 -1
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/contracts/__init__.py +2 -1
- simple_module_settings-0.0.36/settings/contracts/invalidation.py +39 -0
- simple_module_settings-0.0.36/settings/contracts/registry.py +121 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/deps.py +10 -1
- simple_module_settings-0.0.36/settings/endpoints/_create_form.py +34 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/endpoints/api.py +36 -2
- simple_module_settings-0.0.36/settings/endpoints/tenant_api.py +88 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/endpoints/views.py +21 -33
- simple_module_settings-0.0.36/settings/errors.py +20 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/hydrate.py +23 -4
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/locales/en.json +2 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/module.py +17 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/Browse.tsx +5 -2
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/Create.tsx +1 -1
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/Edit.tsx +1 -1
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/ModulesEdit.tsx +16 -6
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/ModuleForm.tsx +3 -3
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/StoreTable.tsx +8 -1
- simple_module_settings-0.0.36/settings/service.py +216 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/store.py +42 -10
- simple_module_settings-0.0.36/settings/tenant_scope.py +130 -0
- simple_module_settings-0.0.36/settings/tenant_view.py +82 -0
- simple_module_settings-0.0.36/tests/test_clear_route_per_scope.py +64 -0
- simple_module_settings-0.0.36/tests/test_cli_import.py +108 -0
- simple_module_settings-0.0.36/tests/test_duplicate_and_race.py +113 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_hydrate.py +26 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_api.py +12 -0
- simple_module_settings-0.0.36/tests/test_tenant_settings.py +255 -0
- simple_module_settings-0.0.36/tests/test_upload_key_delete.py +152 -0
- simple_module_settings-0.0.36/tests-js/ModulesEdit.test.tsx +86 -0
- simple_module_settings-0.0.34/settings/contracts/registry.py +0 -66
- simple_module_settings-0.0.34/settings/service.py +0 -270
- simple_module_settings-0.0.34/tests/test_cli_import.py +0 -52
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/.gitignore +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/LICENSE +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/README.md +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/__init__.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/_field_meta.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/_module_settings.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/_row_masking.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/_secrets.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/browse_query.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/contracts/accessor.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/contracts/events.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/contracts/schemas.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/endpoints/__init__.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/endpoints/module_api.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/env_vars.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/known_keys.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/models.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/module_registry.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/FieldInput.tsx +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/FieldSource.tsx +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/KeyField.tsx +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/ModuleFieldRow.tsx +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/ResolvedValue.tsx +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/ScopeTabs.tsx +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/StoreCards.tsx +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/TestConnectionButton.tsx +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/ValueInput.tsx +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/components/last-test.ts +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/routes.ts +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/pages/types.ts +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/py.typed +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/registration.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/reload.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/services.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/settings.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_concurrent_overrides.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_known_keys_meta.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_module_api.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_module_registry.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_module_settings.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_module_settings_render.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_module_settings_serialize.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_registration.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_reload.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_accessor.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_events.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_field_sources.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_module.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_schemas.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_secret_masking.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_secret_urls.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_service.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_settings_view_authz.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_store.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_store_filters.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_store_secret_masking.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests/test_testable_checks.py +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests-js/ModuleForm.test.tsx +0 -0
- {simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/tests-js/ScopeTabs.test.tsx +0 -0
- {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.
|
|
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.
|
|
25
|
-
Requires-Dist: simple-module-db==0.0.
|
|
26
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
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": "^
|
|
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.
|
|
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.
|
|
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.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
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))
|
{simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/_module_settings_props.py
RENAMED
|
@@ -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(
|
|
25
|
-
|
|
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 =
|
|
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
|
-
|
|
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"
|
{simple_module_settings-0.0.34 → simple_module_settings-0.0.36}/settings/contracts/__init__.py
RENAMED
|
@@ -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
|
-
|
|
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:
|