simple-module-settings 0.0.35__tar.gz → 0.0.37__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.35 → simple_module_settings-0.0.37}/.gitignore +1 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/PKG-INFO +4 -4
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/pyproject.toml +4 -4
- simple_module_settings-0.0.37/settings/_announce.py +36 -0
- simple_module_settings-0.0.37/settings/_listing.py +121 -0
- simple_module_settings-0.0.37/settings/_managed_keys.py +59 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/_module_settings.py +40 -16
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/_module_settings_props.py +25 -1
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/_secrets.py +43 -3
- simple_module_settings-0.0.37/settings/_unique_write.py +29 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/cli.py +20 -3
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/constants.py +23 -1
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/contracts/__init__.py +2 -1
- simple_module_settings-0.0.37/settings/contracts/invalidation.py +39 -0
- simple_module_settings-0.0.37/settings/contracts/registry.py +121 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/deps.py +10 -1
- simple_module_settings-0.0.37/settings/endpoints/_create_form.py +34 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/endpoints/api.py +36 -2
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/endpoints/module_api.py +17 -3
- simple_module_settings-0.0.37/settings/endpoints/tenant_api.py +88 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/endpoints/views.py +20 -32
- simple_module_settings-0.0.37/settings/errors.py +20 -0
- simple_module_settings-0.0.37/settings/hydrate.py +223 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/locales/en.json +11 -2
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/module.py +17 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/module_registry.py +3 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/Browse.tsx +4 -1
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/ModulesEdit.tsx +15 -5
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/FieldInput.tsx +42 -11
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/FieldSource.tsx +5 -5
- simple_module_settings-0.0.37/settings/pages/components/JsonInput.tsx +77 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/ModuleFieldRow.tsx +18 -2
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/ModuleForm.tsx +29 -9
- simple_module_settings-0.0.37/settings/pages/components/validation-errors.test.ts +32 -0
- simple_module_settings-0.0.37/settings/pages/components/validation-errors.ts +45 -0
- simple_module_settings-0.0.37/settings/reload.py +158 -0
- simple_module_settings-0.0.37/settings/service.py +216 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/store.py +42 -10
- simple_module_settings-0.0.37/settings/tenant_scope.py +130 -0
- simple_module_settings-0.0.37/settings/tenant_view.py +82 -0
- simple_module_settings-0.0.37/tests/test_clear_route_per_scope.py +64 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_cli_import.py +46 -0
- simple_module_settings-0.0.37/tests/test_duplicate_and_race.py +113 -0
- simple_module_settings-0.0.37/tests/test_env_pinned.py +121 -0
- simple_module_settings-0.0.37/tests/test_hydrate.py +229 -0
- simple_module_settings-0.0.37/tests/test_hydrate_related_fields.py +53 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_module_api.py +5 -2
- simple_module_settings-0.0.37/tests/test_module_api_json_fields.py +129 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_api.py +12 -0
- simple_module_settings-0.0.37/tests/test_settings_default_clears_override.py +80 -0
- simple_module_settings-0.0.37/tests/test_settings_env_unprefixed.py +49 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_field_sources.py +4 -4
- simple_module_settings-0.0.37/tests/test_settings_secret_json_roundtrip.py +63 -0
- simple_module_settings-0.0.37/tests/test_settings_typed_secret_masking.py +97 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_store_filters.py +13 -5
- simple_module_settings-0.0.37/tests/test_tenant_settings.py +255 -0
- simple_module_settings-0.0.37/tests/test_upload_key_delete.py +152 -0
- simple_module_settings-0.0.37/tests-js/JsonFields.test.tsx +127 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests-js/ModuleForm.test.tsx +54 -1
- simple_module_settings-0.0.37/tests-js/ModulesEdit.test.tsx +86 -0
- simple_module_settings-0.0.35/settings/contracts/registry.py +0 -66
- simple_module_settings-0.0.35/settings/hydrate.py +0 -63
- simple_module_settings-0.0.35/settings/reload.py +0 -63
- simple_module_settings-0.0.35/settings/service.py +0 -270
- simple_module_settings-0.0.35/tests/test_hydrate.py +0 -65
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/LICENSE +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/README.md +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/package.json +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/__init__.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/_field_meta.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/_row_masking.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/browse_query.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/contracts/accessor.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/contracts/events.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/contracts/schemas.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/endpoints/__init__.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/env_vars.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/known_keys.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/models.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/Create.tsx +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/Edit.tsx +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/KeyField.tsx +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/ResolvedValue.tsx +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/ScopeTabs.tsx +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/StoreCards.tsx +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/StoreTable.tsx +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/TestConnectionButton.tsx +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/ValueInput.tsx +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/last-test.ts +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/routes.ts +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/types.ts +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/py.typed +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/registration.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/services.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/settings.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_concurrent_overrides.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_known_keys_meta.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_module_registry.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_module_settings.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_module_settings_render.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_module_settings_serialize.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_registration.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_reload.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_accessor.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_events.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_module.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_schemas.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_secret_masking.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_secret_urls.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_service.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_view_authz.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_store.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_store_secret_masking.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_testable_checks.py +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests-js/ScopeTabs.test.tsx +0 -0
- {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/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.37
|
|
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.37
|
|
25
|
+
Requires-Dist: simple-module-db==0.0.37
|
|
26
|
+
Requires-Dist: simple-module-hosting==0.0.37
|
|
27
27
|
Requires-Dist: typer>=0.12
|
|
28
28
|
Description-Content-Type: text/markdown
|
|
29
29
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_settings"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.37"
|
|
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.37",
|
|
25
|
+
"simple_module_db==0.0.37",
|
|
26
|
+
"simple_module_hosting==0.0.37",
|
|
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.35 → simple_module_settings-0.0.37}/settings/_module_settings.py
RENAMED
|
@@ -19,6 +19,7 @@ from settings._field_meta import choices_for, env_readable_var, resolve_default
|
|
|
19
19
|
from settings._secrets import ( # noqa: F401
|
|
20
20
|
_NEVER_SECRET_TYPES,
|
|
21
21
|
SECRET_MASK,
|
|
22
|
+
annotation_holds_secret,
|
|
22
23
|
conceals_secret,
|
|
23
24
|
embeds_credential,
|
|
24
25
|
is_named_secret,
|
|
@@ -26,7 +27,7 @@ from settings._secrets import ( # noqa: F401
|
|
|
26
27
|
mask,
|
|
27
28
|
)
|
|
28
29
|
from settings.env_vars import env_prefix_for
|
|
29
|
-
from settings.hydrate import value_type_for_field
|
|
30
|
+
from settings.hydrate import env_supplied_fields, input_type_for_field, value_type_for_field
|
|
30
31
|
from settings.service import SettingService
|
|
31
32
|
|
|
32
33
|
|
|
@@ -71,22 +72,28 @@ class ModuleSettingField:
|
|
|
71
72
|
"""
|
|
72
73
|
choices: list[str] | None = None
|
|
73
74
|
"""Closed set of accepted values, or ``None`` when the field is free text."""
|
|
75
|
+
input_type: str = ""
|
|
76
|
+
"""Editor control: ``type`` with an ``X | None`` unwrapped to ``X``."""
|
|
77
|
+
nullable: bool = False
|
|
78
|
+
"""``None`` is a valid value; the editor sends it for an empty input."""
|
|
74
79
|
|
|
75
80
|
@property
|
|
76
81
|
def source(self) -> str:
|
|
77
82
|
"""Where the live value came from: ``db``, ``env`` or ``default``.
|
|
78
83
|
|
|
79
|
-
Mirrors the precedence in ``hydrate_settings``: DB
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
appears for settings classes that
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
Mirrors the precedence in ``hydrate_settings``: env → DB → default
|
|
85
|
+
(GH #486). A stored override for a field the environment sets is
|
|
86
|
+
ignored, so ``env`` wins even when ``db_override`` is true. ``env`` only
|
|
87
|
+
appears for settings classes that declare an ``env_prefix`` (or name
|
|
88
|
+
the field in ``env_seeded_fields``) — the same rule
|
|
89
|
+
``hydrate.env_supplied_fields`` applies; see :attr:`env_set`. Showing
|
|
90
|
+
this is the difference between "why is this not taking effect" being a
|
|
91
|
+
five-minute question and an afternoon.
|
|
85
92
|
"""
|
|
86
|
-
if self.db_override:
|
|
87
|
-
return "db"
|
|
88
93
|
if self.env_set:
|
|
89
94
|
return "env"
|
|
95
|
+
if self.db_override:
|
|
96
|
+
return "db"
|
|
90
97
|
return "default"
|
|
91
98
|
|
|
92
99
|
|
|
@@ -126,28 +133,41 @@ def _field_view(
|
|
|
126
133
|
settings: BaseSettings,
|
|
127
134
|
prefix: str,
|
|
128
135
|
overridden: frozenset[str] = frozenset(),
|
|
136
|
+
env_supplied: frozenset[str] | None = None,
|
|
129
137
|
) -> ModuleSettingField:
|
|
130
138
|
cls = type(settings)
|
|
139
|
+
if env_supplied is None:
|
|
140
|
+
env_supplied = env_supplied_fields(cls)
|
|
131
141
|
info = cls.model_fields[name]
|
|
132
142
|
raw_value = getattr(settings, name)
|
|
133
143
|
value_type = value_type_for_field(cls, name)
|
|
144
|
+
input_type, nullable = input_type_for_field(cls, name)
|
|
134
145
|
named_secret = is_named_secret(name, value_type)
|
|
135
146
|
extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
|
|
136
147
|
default = resolve_default(info)
|
|
137
148
|
env_var = f"{prefix}{name.upper()}"
|
|
138
149
|
live_env_var = env_readable_var(settings, name)
|
|
139
|
-
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
|
|
150
|
+
# The same answer ``build_settings`` acts on (GH #486), so a value supplied
|
|
151
|
+
# by the class's ``.env``, a secrets file or an alias is labelled "env"
|
|
152
|
+
# here exactly when hydration lets it beat a stored override.
|
|
153
|
+
env_set = name in env_supplied
|
|
154
|
+
raw_env = os.environ.get(live_env_var) if env_set and live_env_var is not None else None
|
|
155
|
+
|
|
156
|
+
# Declared as ``SecretStr`` (also inside ``X | None`` or a container):
|
|
157
|
+
# masked whatever the field is called, since the store holds it in clear.
|
|
158
|
+
typed_secret = annotation_holds_secret(info.annotation)
|
|
143
159
|
|
|
144
160
|
def hidden(value: Any) -> bool:
|
|
145
|
-
return conceals_secret(name, value, value_type)
|
|
161
|
+
return typed_secret or conceals_secret(name, value, value_type)
|
|
146
162
|
|
|
147
163
|
# ``is_secret`` drives the editor's input type and the "reveal" affordance,
|
|
148
164
|
# so it has to be true when *any* of the three readings is being hidden —
|
|
149
165
|
# otherwise the form offers to edit a value it is showing as dots.
|
|
150
|
-
secret =
|
|
166
|
+
secret = (
|
|
167
|
+
typed_secret
|
|
168
|
+
or named_secret
|
|
169
|
+
or any(embeds_credential(v) for v in (raw_value, default, raw_env))
|
|
170
|
+
)
|
|
151
171
|
return ModuleSettingField(
|
|
152
172
|
name=name,
|
|
153
173
|
env_var=env_var,
|
|
@@ -166,6 +186,8 @@ def _field_view(
|
|
|
166
186
|
# Resolved value panel's env row into a row of dots for every key.
|
|
167
187
|
env_value=(mask(raw_env) if hidden(raw_env) else raw_env) if raw_env is not None else None,
|
|
168
188
|
choices=choices_for(info),
|
|
189
|
+
input_type=input_type,
|
|
190
|
+
nullable=nullable,
|
|
169
191
|
)
|
|
170
192
|
|
|
171
193
|
|
|
@@ -228,8 +250,10 @@ def _build_view(
|
|
|
228
250
|
) -> ModuleSettingsView:
|
|
229
251
|
prefix = env_prefix_for(package)
|
|
230
252
|
overridden = (overrides or {}).get(package, frozenset())
|
|
253
|
+
from_env = env_supplied_fields(type(settings))
|
|
231
254
|
fields = [
|
|
232
|
-
_field_view(name, settings, prefix, overridden
|
|
255
|
+
_field_view(name, settings, prefix, overridden, from_env)
|
|
256
|
+
for name in type(settings).model_fields
|
|
233
257
|
]
|
|
234
258
|
return ModuleSettingsView(
|
|
235
259
|
module_name=module_name,
|
{simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/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]]:
|
|
@@ -48,9 +49,32 @@ def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
|
|
|
48
49
|
# Turns a pattern-constrained string into a select rather
|
|
49
50
|
# than a text box whose only feedback on a typo is a 422.
|
|
50
51
|
"choices": f.choices,
|
|
52
|
+
"input_type": f.input_type or f.type,
|
|
53
|
+
"nullable": f.nullable,
|
|
51
54
|
}
|
|
52
55
|
for f in v.fields
|
|
53
56
|
],
|
|
54
57
|
}
|
|
55
58
|
for v in views
|
|
56
59
|
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def testable_packages(app: FastAPI) -> dict[str, list[str]]:
|
|
63
|
+
"""Package -> the names of the health checks its module registered.
|
|
64
|
+
|
|
65
|
+
"Test connection" is just that module's health checks run on demand —
|
|
66
|
+
reusing the registry means settings never learns what an SMTP or an S3
|
|
67
|
+
connection is. The names come back with the packages so the button can say
|
|
68
|
+
what it is about to dial ("Test mailer connection") instead of the useless
|
|
69
|
+
"Test connection" a bare package list can produce.
|
|
70
|
+
"""
|
|
71
|
+
checks_by_owner: dict[str, list[str]] = {}
|
|
72
|
+
for check in app.state.sm.health_registry.all_checks:
|
|
73
|
+
if check.module:
|
|
74
|
+
checks_by_owner.setdefault(check.module, []).append(check.name)
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
_package_of(mod): sorted(checks_by_owner[mod.meta.name])
|
|
78
|
+
for mod in getattr(app.state.sm, "modules", ())
|
|
79
|
+
if mod.meta.name in checks_by_owner
|
|
80
|
+
}
|
|
@@ -9,9 +9,11 @@ be reasoned about without a FastAPI app in scope. Re-exported from
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import re
|
|
12
|
-
from typing import Any
|
|
12
|
+
from typing import Any, get_args, get_origin
|
|
13
13
|
from urllib.parse import urlsplit
|
|
14
14
|
|
|
15
|
+
from pydantic import BaseModel, SecretBytes, SecretStr
|
|
16
|
+
|
|
15
17
|
# We intentionally DON'T match the bare word "token" (would mask
|
|
16
18
|
# `verification_token_lifetime_seconds` — just an int) or "key" alone (would
|
|
17
19
|
# mask `s3_bucket_key_prefix`). Only fragments that actually indicate material.
|
|
@@ -25,6 +27,41 @@ SECRET_MASK = "••••••••"
|
|
|
25
27
|
_NEVER_SECRET_TYPES = frozenset({"int", "float", "bool"})
|
|
26
28
|
|
|
27
29
|
|
|
30
|
+
# ``<package>.<field>`` keys whose declared type holds a ``SecretStr`` /
|
|
31
|
+
# ``SecretBytes``. Filled by ``ModuleSettingsRegistry.register`` so the row
|
|
32
|
+
# browser, tenant view and known-keys list — which see only a key and a stored
|
|
33
|
+
# string — mask them too. Only ever grows; keys are package-qualified.
|
|
34
|
+
_TYPED_SECRET_KEYS: set[str] = set()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def annotation_holds_secret(annotation: Any, _seen: frozenset[type] = frozenset()) -> bool:
|
|
38
|
+
"""True if a ``SecretStr``/``SecretBytes`` sits anywhere in ``annotation``.
|
|
39
|
+
|
|
40
|
+
Walks unions (``SecretStr | None``), containers (``dict[str, SecretStr]``)
|
|
41
|
+
and nested models. Since json fields store the real secret (GH #487
|
|
42
|
+
review), a field typed this way must be masked whatever its name.
|
|
43
|
+
"""
|
|
44
|
+
if get_origin(annotation) is None and isinstance(annotation, type):
|
|
45
|
+
if issubclass(annotation, SecretStr | SecretBytes):
|
|
46
|
+
return True
|
|
47
|
+
if issubclass(annotation, BaseModel) and annotation not in _seen:
|
|
48
|
+
seen = _seen | {annotation}
|
|
49
|
+
return any(
|
|
50
|
+
annotation_holds_secret(f.annotation, seen)
|
|
51
|
+
for f in annotation.model_fields.values()
|
|
52
|
+
)
|
|
53
|
+
return any(annotation_holds_secret(arg, _seen) for arg in get_args(annotation))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def register_typed_secrets(package: str, cls: type[BaseModel]) -> None:
|
|
57
|
+
"""Record which of ``cls``'s fields are secrets by type."""
|
|
58
|
+
_TYPED_SECRET_KEYS.update(
|
|
59
|
+
f"{package}.{name}"
|
|
60
|
+
for name, info in cls.model_fields.items()
|
|
61
|
+
if annotation_holds_secret(info.annotation)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
28
65
|
def is_secret_field(name: str) -> bool:
|
|
29
66
|
"""True if a field name suggests it holds credential material."""
|
|
30
67
|
return bool(_SECRET_PATTERNS.search(name))
|
|
@@ -49,11 +86,14 @@ def is_named_secret(name: str, value_type: str | None = None) -> bool:
|
|
|
49
86
|
def conceals_secret(name: str, value: Any, value_type: str | None = None) -> bool:
|
|
50
87
|
"""True if this name/value pair must be masked on the way out.
|
|
51
88
|
|
|
52
|
-
The one rule every read path shares: mask per *
|
|
89
|
+
The one rule every read path shares: mask per *type* (a registered
|
|
90
|
+
``<package>.<field>`` declared as a secret), per *value* and per name,
|
|
53
91
|
so a DSN carrying a password is hidden even on a field called
|
|
54
92
|
``broker_url``, while that field's password-free default is still shown.
|
|
55
93
|
"""
|
|
56
|
-
return
|
|
94
|
+
return (
|
|
95
|
+
name in _TYPED_SECRET_KEYS or is_named_secret(name, value_type) or embeds_credential(value)
|
|
96
|
+
)
|
|
57
97
|
|
|
58
98
|
|
|
59
99
|
def embeds_credential(value: Any) -> bool:
|
|
@@ -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.35 → simple_module_settings-0.0.37}/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
|
]
|